modified: database.py

modified:   routes_admin.py
	modified:   routes_worker.py
	modified:   templates/admin_productos.html
	modified:   templates/admin_workers.html
	modified:   templates/macros/modals.html
	modified:   templates/worker_dashboard.html
This commit is contained in:
2026-05-28 00:59:37 -04:00
parent a8256860a2
commit 9c4753cd1f
7 changed files with 534 additions and 135 deletions

View File

@@ -21,103 +21,254 @@
{{ edit_product_modal(zonas) }}
<div class="card mb-4 shadow-sm">
<div class="card-header bg-primary text-white">Agregar Nuevo Producto</div>
<div class="card-header bg-primary text-white">Agregar Producto Maestro</div>
<div class="card-body">
<form method="POST" action="{{ url_for('manage_products') }}">
<div class="row g-3">
<div class="col-md-3">
<label class="form-label">Zona</label>
<select class="form-select" name="zona_id" required>
<option value="" selected disabled>Seleccionar Zona...</option>
{% for zona in zonas %}
<option value="{{ zona[0] }}">{{ zona[1] }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-4">
<label class="form-label">Nombre del Producto</label>
<input type="text" class="form-control" name="name" required>
<div class="col-md-10">
<input type="text" class="form-control" name="name" placeholder="Nombre del Producto" required>
</div>
<div class="col-md-2">
<label class="form-label">Precio de Venta</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="text" class="form-control money-input" name="price" placeholder="1.500" required>
</div>
</div>
<div class="col-md-2">
<label class="form-label">Comisión</label>
<div class="input-group">
<span class="input-group-text">$</span>
<input type="text" class="form-control money-input" name="commission" placeholder="500" required>
</div>
</div>
<div class="col-md-1 d-flex align-items-end">
<button type="submit" class="btn btn-primary w-100">+</button>
<button type="submit" class="btn btn-primary w-100">Crear Producto</button>
</div>
</div>
</form>
</div>
</div>
<div class="input-group mb-3 shadow-sm">
<span class="input-group-text bg-body-tertiary"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="searchProduct" placeholder="Buscar producto maestro...">
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Zona</th>
<th>Producto</th>
<th>Precio</th>
<th>Comisión</th>
<th>Producto Maestro</th>
<th class="text-end">Acciones</th>
</tr>
</thead>
<tbody>
{% for prod in productos %}
<tr>
<td class="align-middle"><span class="badge bg-info text-dark">{{ prod[4] }}</span></td>
<td class="align-middle">{{ prod[1] }}</td>
<td class="align-middle">${{ "{:,.0f}".format(prod[2]).replace(',', '.') }}</td>
<td class="align-middle">${{ "{:,.0f}".format(prod[3]).replace(',', '.') }}</td>
<tr class="product-row">
<td class="align-middle fw-bold">{{ prod.name }}</td>
<td class="text-end">
<button type="button"
class="btn btn-primary btn-sm btn-edit-sm"
data-bs-toggle="modal"
data-bs-target="#editProductModal"
data-id="{{ prod[0] }}"
data-name="{{ prod[1] }}"
data-price="{{ prod[2]|int }}"
data-commission="{{ prod[3]|int }}"
data-zona="{{ prod[5] }}">
<i class="bi bi-pencil"></i>
<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-danger btn-sm btn-del-sm" data-bs-toggle="modal" data-bs-target="#deleteProd{{ prod[0] }}">
<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>
<button type="button" class="btn btn-danger btn-sm" data-bs-toggle="modal" data-bs-target="#deleteProd{{ prod.id }}">
<i class="bi bi-trash"></i>
</button>
{{ confirm_modal(
id='deleteProd' ~ prod[0],
id='deleteProd' ~ prod.id,
title='Eliminar Producto',
message='¿Estás seguro de que deseas eliminar "' ~ prod[1] ~ '"?',
action_url=url_for('delete_product', id=prod[0]),
message='¿Eliminar "' ~ prod.name ~ '"? Esto fallará si el producto ya tiene ventas registradas.',
action_url=url_for('delete_product', id=prod.id),
btn_class='btn-danger',
btn_text='Eliminar permanentemente'
btn_text='Eliminar'
) }}
</td>
</tr>
{% else %}
<tr>
<td colspan="5" class="text-center py-3 text-muted">No hay productos registrados.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% for prod in productos %}
<div class="modal fade" id="pricesModal{{ prod.id }}" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<form method="POST" action="{{ url_for('update_product_prices', id=prod.id) }}">
<div class="modal-header">
<h5 class="modal-title">Precios: {{ prod.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row fw-bold mb-2">
<div class="col-4">Zona</div>
<div class="col-4">Precio</div>
<div class="col-4">Comisión</div>
</div>
{% for z_id, datos in prod.precios.items() %}
<div class="row mb-2">
<div class="col-4 d-flex align-items-center">
<span class="badge bg-info text-dark w-100">{{ datos.zona_name }}</span>
</div>
<div class="col-4">
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="text" class="form-control money-input" name="price_{{ z_id }}" value="{{ datos.price }}">
</div>
</div>
<div class="col-4">
<div class="input-group input-group-sm">
<span class="input-group-text">$</span>
<input type="text" class="form-control money-input" name="comm_{{ z_id }}" value="{{ datos.commission }}">
</div>
</div>
</div>
{% endfor %}
</div>
<div class="modal-body bg-body-tertiary border-top pb-3">
<label class="form-label text-warning fw-bold mb-1">
<i class="bi bi-clock-history"></i> Programar Cambio de Precio (Opcional)
</label>
<input type="datetime-local" class="form-control form-control-sm mb-1" name="fecha_activacion">
<small class="text-muted" style="font-size: 0.85em;">Si lo dejas en blanco, el cambio se aplicará inmediatamente.</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cerrar</button>
<button type="submit" class="btn btn-primary">Guardar Precios</button>
</div>
</form>
{% if prod.futuros %}
<div class="modal-body border-top">
<h6 class="text-info fw-bold mb-3">
<i class="bi bi-calendar-event"></i> Cambios Programados a Futuro
</h6>
<div class="table-responsive">
<table class="table table-sm table-bordered text-center mb-0" style="font-size: 0.85rem;">
<thead class="table-dark">
<tr>
<th>Fecha Programada</th>
<th>Zona</th>
<th>Precio</th>
<th>Comisión</th>
<th>Cancelar</th>
</tr>
</thead>
<tbody>
{% for futuro in prod.futuros %}
<tr>
<td class="align-middle text-nowrap text-warning">{{ futuro.fecha }}</td>
<td class="align-middle fw-bold">{{ futuro.zona_name }}</td>
<td class="align-middle">${{ "{:,.0f}".format(futuro.price).replace(',', '.') }}</td>
<td class="align-middle">${{ "{:,.0f}".format(futuro.commission).replace(',', '.') }}</td>
<td class="align-middle">
<form action="{{ url_for('cancel_scheduled_price', id=futuro.id) }}" method="POST" class="d-inline">
<button type="submit" class="btn btn-danger btn-sm py-0 px-2" title="Cancelar este cambio">
<i class="bi bi-x-lg"></i>
</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</tbody>
</table>
</div>
</div>
<div class="modal fade" id="chartModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="chartModalTitle">Historial de Precios</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<canvas id="priceChart" width="400" height="200"></canvas>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script>
let priceChartInstance = null;
async function showHistory(prodId, prodName) {
// Abrir el modal inmediatamente para que no parezca que la app murió
const modal = new bootstrap.Modal(document.getElementById('chartModal'));
document.getElementById('chartModalTitle').innerText = 'Fluctuación de Precio: ' + prodName;
modal.show();
// Traer la data desde nuestra nueva API
const res = await fetch(`/admin/api/productos/${prodId}/historial`);
const data = await res.json();
// Extraer zonas únicas y fechas únicas (limpiando la hora para el eje X)
const zonas = [...new Set(data.map(d => d.zona))];
const fechas = [...new Set(data.map(d => d.fecha.split(' ')[0]))].sort();
const colors = ['#0d6efd', '#198754', '#dc3545', '#ffc107', '#0dcaf0'];
const datasets = zonas.map((zona, index) => {
let lastPrice = 0;
// Rellenar huecos: Si no hubo cambio un día, se mantiene el precio del día anterior
const dataPoints = fechas.map(f => {
const hits = data.filter(d => d.zona === zona && d.fecha.startsWith(f));
if (hits.length > 0) {
lastPrice = hits[hits.length - 1].price; // Tomar el último cambio de ese día
}
return lastPrice;
});
return {
label: zona,
data: dataPoints,
borderColor: colors[index % colors.length],
backgroundColor: colors[index % colors.length],
stepped: true, // Hace que se vea como escaleras en vez de curvas raras
borderWidth: 2
};
});
// Dibujar (o redibujar) el gráfico
const ctx = document.getElementById('priceChart').getContext('2d');
if (priceChartInstance) {
priceChartInstance.destroy();
}
priceChartInstance = new Chart(ctx, {
type: 'line',
data: {
labels: fechas,
datasets: datasets
},
options: {
responsive: true,
interaction: {
mode: 'index',
intersect: false
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) { return '$' + value.toLocaleString('es-CL'); }
}
}
}
}
});
}
</script>
<script>
const editModal = document.getElementById('editProductModal');
if (editModal) {
@@ -158,5 +309,19 @@
}
});
});
const searchInput = document.getElementById('searchProduct');
const productRows = document.querySelectorAll('.product-row');
if (searchInput) {
searchInput.addEventListener('input', function() {
const term = this.value.toLowerCase();
productRows.forEach(row => {
// Asume que el nombre está en la primera celda
const name = row.cells[0].textContent.toLowerCase();
row.style.display = name.includes(term) ? '' : 'none';
});
});
}
</script>
{% endblock %}

View File

@@ -59,6 +59,34 @@
</div>
</div>
<div class="card mb-4 shadow-sm border-0">
<div class="card-body bg-body-tertiary rounded">
<div class="row g-3">
<div class="col-md-5">
<div class="input-group">
<span class="input-group-text"><i class="bi bi-search"></i></span>
<input type="text" class="form-control" id="searchWorker" placeholder="Buscar por Nombre o RUT...">
</div>
</div>
<div class="col-md-4">
<select class="form-select" id="filterModule">
<option value="all">Todos los Módulos</option>
{% for mod in modulos %}
<option value="{{ mod[0] }}">{{ mod[2] }} - {{ mod[1] }}</option>
{% endfor %}
</select>
</div>
<div class="col-md-3">
<select class="form-select" id="filterType">
<option value="all">Todas las Jornadas</option>
<option value="Full Time">Full Time</option>
<option value="Part Time">Part Time</option>
</select>
</div>
</div>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<table class="table table-striped table-hover mb-0">
@@ -74,7 +102,7 @@
</thead>
<tbody>
{% for worker in workers %}
<tr>
<tr class="worker-row" data-modulo="{{ worker[5] }}" data-tipo="{{ worker[6] }}">
<td class="align-middle">{{ worker[1] }}</td>
<td class="align-middle">{{ worker[2] }}</td>
<td class="align-middle">{{ worker[3] }}</td>
@@ -207,5 +235,40 @@
document.querySelectorAll('.phone-input, #phoneInput').forEach(inp => {
inp.addEventListener('input', () => formatPhone(inp));
});
// --- LÓGICA DE FILTRADO DE TRABAJADORES ---
const searchInputWorker = document.getElementById('searchWorker');
const moduleSelectFilter = document.getElementById('filterModule');
const typeSelectFilter = document.getElementById('filterType');
const workerRows = document.querySelectorAll('.worker-row');
function filterWorkers() {
const searchTerm = searchInputWorker.value.toLowerCase();
const selectedModule = moduleSelectFilter.value;
const selectedType = typeSelectFilter.value;
workerRows.forEach(row => {
// Asumiendo que celda 0 es RUT y celda 1 es Nombre
const rut = row.cells[0].textContent.toLowerCase();
const name = row.cells[1].textContent.toLowerCase();
const rowModule = row.getAttribute('data-modulo');
const rowType = row.getAttribute('data-tipo');
const matchesSearch = rut.includes(searchTerm) || name.includes(searchTerm);
const matchesModule = selectedModule === 'all' || rowModule === selectedModule;
const matchesType = selectedType === 'all' || rowType === selectedType;
if (matchesSearch && matchesModule && matchesType) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
searchInputWorker.addEventListener('input', filterWorkers);
moduleSelectFilter.addEventListener('change', filterWorkers);
typeSelectFilter.addEventListener('change', filterWorkers);
</script>
{% endblock %}

View File

@@ -215,7 +215,7 @@
<dd class="col-sm-7">{{ rendicion[2] }}
{% if session.get('is_admin') %}
<span class="badge {% if rendicion[14] %}bg-success{% else %}bg-secondary{% endif %} ms-1" style="font-size: 0.65em;">
{% if rendicion[14] %}$ Sí{% else %}$ No{% endif %}
{% if rendicion[14] %}$ Si Recibe Comision{% else %}$ No Recibe Comision{% endif %}
</span>
{% endif %}
</dd>
@@ -226,7 +226,7 @@
{{ rendicion[10] }}
{% if session.get('is_admin') %}
<span class="badge {% if rendicion[15] %}bg-success{% else %}bg-secondary{% endif %} ms-1" style="font-size: 0.65em;">
{% if rendicion[15] %}$ Sí{% else %}$ No{% endif %}
{% if rendicion[15] %}$ Si Recibe Comision{% else %}$ No Recibe Comision{% endif %}
</span>
{% endif %}
{% else %}

View File

@@ -214,6 +214,10 @@
</div>
</div>
<div id="discrepancy_warning" class="alert alert-warning mb-4" style="display: none;">
<i class="bi bi-exclamation-triangle-fill me-2"></i> <span id="discrepancy_text"></span>
</div>
<button type="button" class="btn btn-primary w-100 py-3 mb-5" data-bs-toggle="modal" data-bs-target="#confirmSubmitModal">
<i class="bi bi-send-check me-2"></i> Enviar Rendición Diaria
</button>
@@ -277,6 +281,8 @@
}
});
displayTotalProductos.value = granTotal.toLocaleString('es-CL');
checkWarnings();
}
inputsCantidad.forEach(input => {
@@ -292,6 +298,41 @@
calcularVentaProductos();
});
});
function checkWarnings() {
const getVal = (id) => {
const el = document.getElementById(id);
if (!el || !el.value.trim() || el.value === '0') return 0;
return parseInt(el.value.replace(/\D/g, '')) || 0;
};
const totalProductos = getVal('total_productos_calc');
const totalDeclarado = getVal('total_general');
const gastos = getVal('gastos');
const efectivo = getVal('venta_efectivo');
let warnings = [];
// Comprobar diferencias en totales (solo si se ha ingresado algo para no mostrar el error de entrada)
if ((totalProductos > 0 || totalDeclarado > 0) && totalProductos !== totalDeclarado) {
warnings.push("El <strong>Total Venta por Productos</strong> no coincide con el <strong>Total Ventas Declaradas</strong>.");
}
// Comprobar si los gastos superan el efectivo
if (gastos > efectivo) {
warnings.push("El <strong>Monto de Gastos</strong> es mayor que el <strong>Efectivo</strong> declarado.");
}
const warningContainer = document.getElementById('discrepancy_warning');
const warningText = document.getElementById('discrepancy_text');
if (warnings.length > 0) {
warningText.innerHTML = warnings.join("<br>");
warningContainer.style.display = 'block';
} else {
warningContainer.style.display = 'none';
}
}
</script>
<script>
@@ -361,6 +402,8 @@ document.addEventListener('DOMContentLoaded', function() {
document.getElementById('total_digital').value = totalDigital.toLocaleString('es-CL');
document.getElementById('total_general').value = totalGeneral.toLocaleString('es-CL');
checkWarnings();
}
inputsVenta.forEach(input => {