- 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.
228 lines
9.8 KiB
HTML
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 %} |