Files
SekiPOS/templates/gastos.html
SekiDesu0 47cc480cf5 feat: add expenses module, restaurant mode, and dynamic sales filters
- Gastos (Expenses): Added `/gastos` route, auto-creation of `expenses` DB table, and `gastos.html` to track net profit with split month/year dropdowns.
- Sales & Filters: Overhauled `/sales` backend to use pagination. Top summary cards now accurately reflect the selected payment method filter.
- Checkout Improvements:
  - Added "Transferencia" as a payment option with numpad shortcuts.
  - Built a "Pinned Products" quick-access grid using localStorage.
  - Implemented a global processing lock to prevent duplicate sales on double-clicks.
  - Burned the default HTML number arrows with custom CSS.
- Global Settings & Receipts:
  - Created a global settings modal accessible from the navbar.
  - Added localStorage toggles for custom business name and auto-print.
  - Added "Restaurant Mode" toggle to prompt for Client Name and Pickup Time, which now dynamically prints on the receipt.
- Bug Fixes: Resolved Jinja `TemplateSyntaxError` crash and removed the duplicate search bar in the checkout view.
2026-04-15 22:58:12 -04:00

228 lines
9.8 KiB
HTML

{% extends "macros/base.html" %}
{% block title %}Gastos y Utilidad{% endblock %}
{% block content %}
<div class="d-flex justify-content-between align-items-center mb-3 flex-wrap gap-2">
<h3 class="mb-0"><i class="bi bi-wallet2 me-2"></i>Gastos y Utilidad</h3>
<div class="d-flex gap-2">
<select id="month-select" class="form-select form-select-sm"
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
onchange="applyDateFilter()">
</select>
<select id="year-select" class="form-select form-select-sm"
style="width: auto; background: var(--input-bg); color: var(--text-main); border-color: var(--border);"
onchange="applyDateFilter()">
</select>
</div>
</div>
<div class="row g-3 mb-4">
<div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center border-success" style="border-bottom: 4px solid #198754;">
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Ventas Totales</h6>
<h2 class="price-cell mb-0 text-success" style="font-weight: 800;" data-value="{{ sales_total }}"></h2>
</div>
</div>
<div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center border-danger" style="border-bottom: 4px solid #dc3545;">
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Gastos Totales</h6>
<h2 class="price-cell mb-0 text-danger" style="font-weight: 800;" data-value="{{ expenses_total }}"></h2>
</div>
</div>
<div class="col-12 col-md-4">
<div class="discord-card p-3 shadow-sm text-center" style="border-bottom: 4px solid {% if net_profit >= 0 %}#0dcaf0{% else %}#dc3545{% endif %};">
<h6 class="text-muted text-uppercase mb-1" style="font-size: 0.75rem; font-weight: 700;">Utilidad Neta</h6>
<h2 class="price-cell mb-0" style="color: {% if net_profit >= 0 %}#0dcaf0{% else %}#dc3545{% endif %}; font-weight: 800;" data-value="{{ net_profit }}"></h2>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-md-4">
<div class="discord-card p-3 shadow-sm">
<h5 class="mb-3"><i class="bi bi-plus-circle me-2"></i>Registrar Gasto</h5>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Descripción</label>
<input type="text" id="gasto-desc" class="form-control" placeholder="Ej: Pago de luz, Mercadería..." autocomplete="off">
</div>
<div class="mb-3">
<label class="form-label text-muted small mb-1">Monto (CLP)</label>
<input type="text" inputmode="numeric" id="gasto-monto" class="form-control fs-5 fw-bold"
placeholder="$0"
oninput="let v = this.value.replace(/\D/g, ''); this.value = v ? parseInt(v, 10).toLocaleString('es-CL') : '';"
onkeydown="if(event.key === 'Enter') submitGasto()">
</div>
<button class="btn btn-warning w-100 fw-bold" onclick="submitGasto()">
<i class="bi bi-save me-1"></i> Guardar Gasto
</button>
</div>
</div>
<div class="col-md-8">
<div class="discord-card p-3 shadow-sm">
<h5 class="mb-3"><i class="bi bi-list-ul me-2"></i>Historial de Gastos</h5>
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>Fecha</th>
<th>Descripción</th>
<th class="text-end">Monto</th>
<th style="width: 1%;"></th>
</tr>
</thead>
<tbody>
{% if expenses %}
{% for e in expenses %}
<tr>
<td class="utc-date text-muted">{{ e[1] }}</td>
<td>{{ e[2] }}</td>
<td class="text-end text-danger fw-bold price-cell" data-value="{{ e[3] }}"></td>
<td class="text-nowrap">
<button class="btn btn-sm btn-outline-danger py-0 px-2" onclick="deleteGasto({{ e[0] }})" title="Eliminar">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="4" class="text-center text-muted py-4">No hay gastos registrados en este mes.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="modal fade" id="deleteGastoModal" tabindex="-1">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content border-danger">
<div class="modal-header border-0 pb-0">
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center pt-0 pb-4">
<i class="bi bi-exclamation-triangle-fill text-danger mb-3" style="font-size: 3rem;"></i>
<h4 class="mb-3">¿Eliminar Gasto?</h4>
<p class="text-muted px-3">Esta acción eliminará el registro permanentemente y recalculará la utilidad neta.</p>
<div class="d-flex gap-2 justify-content-center mt-4 px-3">
<button class="btn btn-secondary w-50" data-bs-dismiss="modal">Cancelar</button>
<button class="btn btn-danger w-50" onclick="executeDeleteGasto()">Sí, Eliminar</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
const clp = new Intl.NumberFormat('es-CL', { style: 'currency', currency: 'CLP', minimumFractionDigits: 0 });
let gastoToDelete = null;
// Format UI numbers
document.querySelectorAll('.price-cell').forEach(td => {
td.innerText = clp.format(td.getAttribute('data-value'));
});
document.querySelectorAll('.utc-date').forEach(el => {
const date = new Date(el.innerText + " UTC");
if (!isNaN(date)) {
el.innerText = date.toLocaleString('es-CL', { month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' });
}
});
// --- Build the Split Dropdowns ---
const currentSelected = "{{ selected_month }}"; // Comes from backend as "YYYY-MM"
const [selYear, selMonth] = currentSelected.split('-');
const monthSelect = document.getElementById('month-select');
const yearSelect = document.getElementById('year-select');
const monthNames = ["Enero", "Febrero", "Marzo", "Abril", "Mayo", "Junio", "Julio", "Agosto", "Septiembre", "Octubre", "Noviembre", "Diciembre"];
// Populate Months
monthNames.forEach((name, index) => {
const val = String(index + 1).padStart(2, '0');
const option = document.createElement('option');
option.value = val;
option.innerText = name;
if (val === selMonth) option.selected = true;
monthSelect.appendChild(option);
});
// Populate Years (Current year +/- a few years)
const currentYear = new Date().getFullYear();
for (let y = currentYear - 3; y <= currentYear + 1; y++) {
const option = document.createElement('option');
option.value = y;
option.innerText = y;
if (y.toString() === selYear) option.selected = true;
yearSelect.appendChild(option);
}
// Trigger URL change when either dropdown is touched
function applyDateFilter() {
const m = monthSelect.value;
const y = yearSelect.value;
window.location.href = `/gastos?month=${y}-${m}`;
}
async function submitGasto() {
const descInput = document.getElementById('gasto-desc');
const montoInput = document.getElementById('gasto-monto');
const desc = descInput.value.trim();
const rawMonto = montoInput.value.replace(/\./g, '');
const amount = parseInt(rawMonto, 10);
if (!desc || isNaN(amount) || amount <= 0) {
alert("Por favor ingresa una descripción y un monto válido.");
return;
}
try {
const res = await fetch('/api/gastos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: desc, amount: amount })
});
if (res.ok) {
descInput.value = '';
montoInput.value = '';
window.location.href = window.location.pathname + window.location.search;
} else {
alert("Error al guardar el gasto.");
}
} catch (err) {
alert("Error de conexión.");
}
}
// Open Modal
function deleteGasto(id) {
gastoToDelete = id;
bootstrap.Modal.getOrCreateInstance(document.getElementById('deleteGastoModal')).show();
}
// Execute the backend call
async function executeDeleteGasto() {
if (!gastoToDelete) return;
try {
const res = await fetch(`/api/gastos/${gastoToDelete}`, { method: 'DELETE' });
if (res.ok) {
window.location.href = window.location.pathname + window.location.search;
} else {
alert("Error al eliminar.");
}
} catch (err) {
alert("Error de conexión.");
}
}
</script>
{% endblock %}