feat: add complementos feature for linking multiple accessories and quantities to products

This commit is contained in:
2026-06-22 03:35:06 -04:00
parent 97cbe13196
commit 606ed94722
6 changed files with 231 additions and 4 deletions

View File

@@ -279,6 +279,19 @@ def init_db():
FOREIGN KEY (rendicion_id) REFERENCES rendiciones(id),
FOREIGN KEY (producto_id) REFERENCES productos(id))''')
# Complementos tables
c.execute('''CREATE TABLE IF NOT EXISTS complementos
(id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL)''')
c.execute('''CREATE TABLE IF NOT EXISTS producto_complementos
(id INTEGER PRIMARY KEY AUTOINCREMENT,
producto_id INTEGER NOT NULL,
complemento_id INTEGER NOT NULL,
cantidad INTEGER NOT NULL DEFAULT 1,
FOREIGN KEY (producto_id) REFERENCES productos(id) ON DELETE CASCADE,
FOREIGN KEY (complemento_id) REFERENCES complementos(id) ON DELETE CASCADE,
UNIQUE(producto_id, complemento_id))''')
# Migrate: add bank fields if missing
for col in ['nombre_banco', 'numero_cuenta', 'tipo_cuenta', 'rut_banco']:
try:

View File

@@ -100,3 +100,24 @@ class RendicionItem(db.Model):
cantidad = db.Column(db.Integer, nullable=False)
precio_historico = db.Column(db.Integer, nullable=False)
comision_historica = db.Column(db.Integer, nullable=False)
class Complemento(db.Model):
__tablename__ = 'complementos'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, unique=True, nullable=False)
class ProductoComplemento(db.Model):
__tablename__ = 'producto_complementos'
__table_args__ = _TABLE_ARGS
id = db.Column(db.Integer, primary_key=True)
producto_id = db.Column(db.Integer, db.ForeignKey('productos.id', ondelete='CASCADE'), nullable=False)
complemento_id = db.Column(db.Integer, db.ForeignKey('complementos.id', ondelete='CASCADE'), nullable=False)
cantidad = db.Column(db.Integer, nullable=False, default=1)
producto = db.relationship('Producto', backref=db.backref('complementos_assoc', lazy=True, cascade="all, delete-orphan"))
complemento = db.relationship('Complemento', backref=db.backref('productos_assoc', lazy=True))

View File

@@ -6,7 +6,7 @@ from datetime import date, datetime
from models.models import (
db, Zona, Modulo, Producto, PrecioHistorico,
Worker, Rendicion, RendicionItem,
Worker, Rendicion, RendicionItem, Complemento, ProductoComplemento,
)
from utils import (
admin_required, validate_rut, format_rut, validate_phone,
@@ -336,7 +336,13 @@ def manage_products():
productos = Producto.query.order_by(Producto.name).all()
productos_dict = {}
for p in productos:
productos_dict[p.id] = {'id': p.id, 'name': p.name, 'precios': {}, 'futuros': []}
productos_dict[p.id] = {
'id': p.id,
'name': p.name,
'precios': {},
'futuros': [],
'complementos': []
}
for z in zonas:
z_id, z_name = z
price, comm = price_map.get((p.id, z_id), (None, None))
@@ -364,7 +370,23 @@ def manage_products():
'fecha': ph.fecha_activacion,
})
return render_template('admin_productos.html', zonas=zonas, productos=productos_dict.values())
# Fetch associated complementos
assoc_rows = (
db.session.query(ProductoComplemento.id, ProductoComplemento.producto_id, ProductoComplemento.cantidad, Complemento.name)
.join(Complemento, ProductoComplemento.complemento_id == Complemento.id)
.all()
)
for assoc_id, prod_id, cantidad, comp_name in assoc_rows:
if prod_id in productos_dict:
productos_dict[prod_id]['complementos'].append({
'id': assoc_id,
'name': comp_name,
'cantidad': cantidad
})
complementos_catalogo = Complemento.query.order_by(Complemento.name).all()
return render_template('admin_productos.html', zonas=zonas, productos=productos_dict.values(), complementos_catalogo=complementos_catalogo)
@admin_bp.route('/productos/delete/<int:id>', methods=['POST'])
@@ -383,6 +405,53 @@ def delete_product(id):
return redirect(url_for('admin.manage_products'))
@admin_bp.route('/productos/<int:prod_id>/complementos/add', methods=['POST'])
@admin_required
def add_producto_complemento(prod_id):
comp_id = request.form.get('complemento_id')
comp_name_nuevo = request.form.get('complemento_nombre_nuevo', '').strip()
cantidad = int(request.form.get('cantidad', 1) or 1)
if comp_id == '__nuevo__':
if not comp_name_nuevo:
flash("Debes ingresar el nombre del nuevo complemento.", "danger")
return redirect(url_for('admin.manage_products'))
comp = Complemento.query.filter_by(name=comp_name_nuevo).first()
if not comp:
comp = Complemento(name=comp_name_nuevo)
db.session.add(comp)
db.session.flush()
else:
comp = db.session.get(Complemento, int(comp_id))
if not comp:
flash("Complemento no encontrado.", "danger")
return redirect(url_for('admin.manage_products'))
assoc = ProductoComplemento.query.filter_by(producto_id=prod_id, complemento_id=comp.id).first()
if assoc:
assoc.cantidad += cantidad
else:
assoc = ProductoComplemento(producto_id=prod_id, complemento_id=comp.id, cantidad=cantidad)
db.session.add(assoc)
db.session.commit()
flash("Complemento vinculado al producto exitosamente.", "success")
return redirect(url_for('admin.manage_products'))
@admin_bp.route('/productos/complementos/delete/<int:assoc_id>', methods=['POST'])
@admin_required
def delete_producto_complemento(assoc_id):
assoc = db.session.get(ProductoComplemento, assoc_id)
if assoc:
db.session.delete(assoc)
db.session.commit()
flash("Complemento desvinculado del producto.", "info")
return redirect(url_for('admin.manage_products'))
@admin_bp.route('/productos/precios/<int:id>', methods=['POST'])
@admin_required
def update_product_prices(id):

View File

@@ -39,4 +39,17 @@ document.addEventListener("DOMContentLoaded", function () {
});
});
}
window.toggleNuevoComplementoInput = function(prodId, selectEl) {
const wrapper = document.getElementById(`nuevo_comp_wrapper_${prodId}`);
if (wrapper) {
if (selectEl.value === '__nuevo__') {
wrapper.style.display = 'block';
wrapper.querySelector('input').setAttribute('required', 'required');
} else {
wrapper.style.display = 'none';
wrapper.querySelector('input').removeAttribute('required');
}
}
};
});

View File

@@ -1,5 +1,5 @@
{% extends "macros/base.html" %}
{% from 'macros/modals.html' import confirm_modal, edit_product_modal, add_product_modal %}
{% from 'macros/modals.html' import confirm_modal, edit_product_modal, add_product_modal, manage_complementos_modal %}
{% from "macros/ui.html" import flashed_messages %}
{% block title %}Catálogo de Productos{% endblock %}
@@ -28,6 +28,7 @@
<thead class="table-dark">
<tr>
<th>Producto Maestro</th>
<th>Complementos de Regalo</th>
<th class="text-end">Acciones</th>
</tr>
</thead>
@@ -35,10 +36,22 @@
{% for prod in productos %}
<tr class="product-row">
<td class="align-middle fw-bold">{{ prod.name }}</td>
<td class="align-middle">
{% if prod.complementos %}
{% for comp in prod.complementos %}
<span class="badge bg-secondary mb-1" style="font-size: 0.85em;">{{ comp.name }} (x{{ comp.cantidad }})</span>
{% endfor %}
{% else %}
<span class="text-muted italic small">Sin complementos</span>
{% endif %}
</td>
<td class="text-end">
<button type="button" class="btn btn-info btn-sm text-white" onclick="showHistory({{ prod.id }}, '{{ prod.name }}')">
<i class="bi bi-graph-up"></i> Historial
</button>
<button type="button" class="btn btn-warning btn-sm text-dark" data-bs-toggle="modal" data-bs-target="#complementosModal{{ prod.id }}">
<i class="bi bi-gift"></i> Complementos
</button>
<button type="button" class="btn btn-success btn-sm" data-bs-toggle="modal" data-bs-target="#pricesModal{{ prod.id }}">
<i class="bi bi-currency-dollar"></i> Precios
</button>
@@ -54,6 +67,7 @@
btn_class='btn-danger',
btn_text='Eliminar'
) }}
{{ manage_complementos_modal(prod, complementos_catalogo) }}
</td>
</tr>
{% endfor %}
@@ -174,6 +188,22 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3 bg-light-subtle border p-2 rounded align-items-end">
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Desde</label>
<input type="date" class="form-control form-control-sm" id="chartFilterDesde">
</div>
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Hasta</label>
<input type="date" class="form-control form-control-sm" id="chartFilterHasta">
</div>
<div class="col-md-2">
<button type="button" class="btn btn-primary btn-sm w-100" id="btnFilterChart"><i class="bi bi-filter"></i> Filtrar</button>
</div>
</div>
<div class="alert alert-info py-1 px-2 mb-2 text-center" style="font-size: 0.85em;">
<i class="bi bi-info-circle-fill me-1"></i> Puedes hacer clic en los nombres de las zonas en la leyenda de arriba para ocultar o mostrar sus líneas en el gráfico.
</div>
<canvas id="priceChart" width="400" height="200"></canvas>
</div>
</div>

View File

@@ -800,3 +800,84 @@
</div>
</div>
{% endmacro %}
{% macro manage_complementos_modal(prod, complementos_catalogo) %}
<div class="modal fade" id="complementosModal{{ prod.id }}" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Complementos de: <span class="text-primary">{{ prod.name }}</span></h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-start">
<h6 class="fw-bold mb-3">Complementos Vinculados (Se entregan de regalo al vender este producto)</h6>
{% if prod.complementos %}
<div class="table-responsive mb-4">
<table class="table table-striped table-hover table-bordered mb-0 align-middle">
<thead class="table-dark">
<tr>
<th>Nombre Complemento</th>
<th class="text-center" style="width: 120px;">Cantidad</th>
<th class="text-end" style="width: 100px;">Desvincular</th>
</tr>
</thead>
<tbody>
{% for comp in prod.complementos %}
<tr>
<td>{{ comp.name }}</td>
<td class="text-center fw-bold">{{ comp.cantidad }}</td>
<td class="text-end">
<form method="POST" action="{{ url_for('admin.delete_producto_complemento', assoc_id=comp.id) }}" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm py-0 px-2" title="Desvincular">
<i class="bi bi-trash"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="alert alert-light border text-center text-muted mb-4 py-3">
<i class="bi bi-info-circle me-1"></i> Este producto aún no tiene complementos asociados.
</div>
{% endif %}
<hr>
<h6 class="fw-bold mb-3">Vincular Nuevo Complemento</h6>
<form method="POST" action="{{ url_for('admin.add_producto_complemento', prod_id=prod.id) }}">
<div class="row g-3 align-items-end">
<div class="col-md-5">
<label class="form-label small text-muted mb-1">Seleccionar del Catálogo</label>
<select class="form-select form-select-sm" name="complemento_id" onchange="toggleNuevoComplementoInput({{ prod.id }}, this)" required>
<option value="" selected disabled>Elegir...</option>
{% for cat in complementos_catalogo %}
<option value="{{ cat.id }}">{{ cat.name }}</option>
{% endfor %}
<option value="__nuevo__">+ Crear nuevo complemento...</option>
</select>
</div>
<div class="col-md-5" id="nuevo_comp_wrapper_{{ prod.id }}" style="display:none;">
<label class="form-label small text-muted mb-1">Nombre del Nuevo Complemento</label>
<input type="text" class="form-control form-control-sm" name="complemento_nombre_nuevo" placeholder="Ej. Paño microfibra">
</div>
<div class="col-md-2" id="cantidad_comp_wrapper_{{ prod.id }}">
<label class="form-label small text-muted mb-1">Cantidad</label>
<input type="number" class="form-control form-control-sm text-center" name="cantidad" value="1" min="1" required>
</div>
<div class="col-md-12 text-end mt-2">
<button type="submit" class="btn btn-success btn-sm"><i class="bi bi-plus-lg"></i> Vincular</button>
</div>
</div>
</form>
</div>
<div class="modal-footer py-2">
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Cerrar</button>
</div>
</div>
</div>
</div>
{% endmacro %}