2025-08-29 05:09:44 +00:00

488 lines
18 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!-- views/dashboard.ejs -->
<div class="d-flex align-items-center justify-content-between mb-3">
<h1 class="h4 m-0">Dashboard Operativo</h1>
<div class="d-flex align-items-center gap-2">
<button id="dashRefresh" class="btn btn-outline-secondary btn-sm">Recargar</button>
<span id="dashStatus" class="text-muted small"></span>
</div>
</div>
<!-- KPIs -->
<div class="row g-3 mb-3">
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Comandas activas</div>
<div class="h3 m-0" id="kpiActivas">—</div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ventas hoy</div>
<div class="h3 m-0"><span id="kpiVentasHoy">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Ticket promedio (hoy)</div>
<div class="h3 m-0"><span id="kpiTicketProm">—</span></div>
</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="card shadow-sm">
<div class="card-body">
<div class="text-muted small">Productos distintos (hoy)</div>
<div class="h3 m-0" id="kpiProdDist">—</div>
</div>
</div>
</div>
</div>
<!-- Gráficos -->
<div class="row g-3">
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Top 5 productos (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartTopProductos"></canvas>
</div>
<div class="text-muted small mt-2">Basado en detalle de comandas de hoy.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Comandas por hora (últimas 12 h)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartComandasHora"></canvas>
</div>
<div class="text-muted small mt-2">Se agrupa por hora de creación.</div>
</div>
</div>
</div>
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header"><strong>Estados de comandas (hoy)</strong></div>
<div class="card-body">
<div class="chart-box">
<canvas id="chartEstados"></canvas>
</div>
<div class="text-muted small mt-2">Distribución por estado.</div>
</div>
</div>
</div>
<!-- Últimas comandas -->
<div class="col-12 col-lg-6">
<div class="card shadow-sm">
<div class="card-header d-flex align-items-center">
<strong>Últimas 10 comandas</strong>
<div class="ms-auto text-muted small" id="ultAct">—</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<thead class="table-light">
<tr>
<th>#</th>
<th>Fecha</th>
<th>Cierre</th> <!-- NUEVO -->
<th>Estado</th>
<th class="text-end">Total</th>
<th>Acción</th> <!-- NUEVO -->
</tr>
</thead>
<tbody id="ultimasTbody">
<tr><td colspan="6" class="text-muted p-3">Cargando…</td></tr>
</tbody>
</table>
</div>
</div>
<div class="card-footer text-muted small">
Totales calculados como Σ (pre_unitario × cantidad) por comanda.
</div>
</div>
</div>
</div>
<!-- Librería para gráficos -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<script>
// ===== Utilidades =====
const $ = (s, r=document)=>r.querySelector(s);
const fmtMoney = (n)=> isNaN(n) ? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(+n);
const fmtTs = (s)=> { const d = new Date(s); return isNaN(d) ? '—' : d.toLocaleString('es-UY'); };
const setStatus = (t)=> $('#dashStatus').textContent = t || '';
const todayBounds = ()=> {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const end = new Date(start); end.setDate(end.getDate()+1);
return {start, end};
};
const guessKey = (obj, candidates)=> (candidates.find(k => k in obj) || null);
const toDate = (v)=> (v instanceof Date ? v : new Date(v));
const inRange = (d, a, b)=> (d>=a && d<b);
// ===== Estado =====
let charts = {};
const state = {
comandas: [],
deta: [],
productos: [],
keys: {
comFecha: null, comFechaCierre: null, comEstado: null, comId: null, // <-- agregado comFechaCierre
detIdCom: null, detPrecio: null, detCant: null,
prodId: null, prodNombre: null
}
};
// ===== Carga =====
async function jget(u){ const r=await fetch(u); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
async function loadData() {
setStatus('Cargando datos…');
const [comandas, deta, productos] = await Promise.all([
jget('/api/table/comandas?limit=2000').catch(()=>[]),
jget('/api/table/deta_comandas?limit=5000').catch(()=>[]),
jget('/api/table/productos?limit=5000').catch(()=>[])
]);
state.comandas = Array.isArray(comandas)? comandas : [];
state.deta = Array.isArray(deta)? deta : [];
state.productos= Array.isArray(productos)? productos : [];
// Descubrir claves
const c0 = state.comandas[0] || {};
// incluimos fec_creacion y fec_cierre como prioridades
state.keys.comFecha = guessKey(c0, ['fec_creacion','fecha','created_at','creado_en','ts','timestamp','hora','datetime']);
state.keys.comFechaCierre = guessKey(c0, ['fec_cierre','cierre','closed_at','fecha_cierre','ts_cierre','hora_cierre']);
state.keys.comEstado = guessKey(c0, ['estado','status']);
state.keys.comId = guessKey(c0, ['id_comanda','id','comanda_id']);
const d0 = state.deta[0] || {};
state.keys.detIdCom = guessKey(d0, ['id_comanda','comanda_id']);
state.keys.detPrecio = guessKey(d0, ['pre_unitario','precio_unitario','precio','unit_price']);
state.keys.detCant = guessKey(d0, ['cantidad','qty','cantidad_total']);
const p0 = state.productos[0] || {};
state.keys.prodId = guessKey(p0, ['id_producto','id','producto_id']);
state.keys.prodNombre = guessKey(p0, ['nombre','descripcion','titulo','name']);
renderAll();
setStatus('');
}
// ===== Cálculos =====
function isActiva(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['abierta','activa','activo','open','pendiente','en curso'].some(x => s.includes(x));
}
function isAnulada(estadoRaw){
const s = String(estadoRaw||'').toLowerCase();
return ['anulada','anulado','cancelada','cancelado','void'].some(x => s.includes(x));
}
function computeKpis(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const {start, end} = todayBounds();
// activas
const activas = state.comandas.filter(c => comEstado && isActiva(c[comEstado])).length;
$('#kpiActivas').textContent = activas;
// ventas hoy
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
let totalHoy = 0, ticketsHoy = 0;
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
if (total>0) { totalHoy += total; ticketsHoy++; }
}
$('#kpiVentasHoy').textContent = fmtMoney(totalHoy);
$('#kpiTicketProm').textContent = ticketsHoy ? fmtMoney(totalHoy / ticketsHoy) : '—';
// productos distintos hoy
const setProd = new Set();
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => setProd.add(d.id_producto ?? d.producto_id ?? d[state.keys.prodId]));
}
$('#kpiProdDist').textContent = setProd.size || '0';
}
function computeTopProductosHoy(){
const {comFecha, comEstado, comId} = state.keys;
const {detIdCom, detCant, detPrecio} = state.keys;
const {prodId, prodNombre} = state.keys;
const {start, end} = todayBounds();
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const qtyByProd = new Map(); // id -> cantidad total
const amtByProd = new Map(); // id -> importe total
for (const c of state.comandas) {
const when = comFecha ? toDate(c[comFecha]) : null;
const st = comEstado ? c[comEstado] : null;
if (!when || !inRange(when, start, end) || (st && isAnulada(st))) continue;
const dets = byCom.get(c[comId]) || [];
dets.forEach(d => {
const pid = d.id_producto ?? d.producto_id ?? d[prodId];
if (pid==null) return;
const q = Number(d[detCant]||0);
const a = Number(d[detPrecio]||0) * q;
qtyByProd.set(pid, (qtyByProd.get(pid)||0)+q);
amtByProd.set(pid, (amtByProd.get(pid)||0)+a);
});
}
// id -> label
const nameById = new Map(state.productos.map(p => [p[prodId], p[prodNombre] || ('#'+p[prodId])]));
// ordenar por cantidad
const arr = [...qtyByProd.entries()]
.map(([id,qty]) => ({ id, qty, amt: amtByProd.get(id)||0, name: nameById.get(id)||('#'+id) }))
.sort((a,b)=> b.qty - a.qty)
.slice(0,5);
return arr;
}
function computeComandasPorHora12h(){
const {comFecha} = state.keys;
const now = new Date();
const buckets = [];
for (let i=11;i>=0;i--){
const h = new Date(now); h.setHours(now.getHours()-i, 0, 0, 0);
buckets.push({ label: h.getHours().toString().padStart(2,'0')+':00', ts: +h, count: 0 });
}
if (!comFecha) return buckets;
state.comandas.forEach(c => {
const d = toDate(c[comFecha]); if (isNaN(d)) return;
const diffH = Math.floor((now - d) / (60*60*1000));
if (diffH<12 && diffH>=0) {
// bucket por hora exacta
const hour = new Date(d); hour.setMinutes(0,0,0);
const idx = buckets.findIndex(b => b.ts === +hour);
if (idx>=0) buckets[idx].count++;
}
});
return buckets;
}
function computeEstadosHoy(){
const {comFecha, comEstado} = state.keys;
const {start, end} = todayBounds();
const map = new Map();
state.comandas.forEach(c=>{
const when = comFecha ? toDate(c[comFecha]) : null;
if (!when || !inRange(when, start, end)) return;
const st = (c[comEstado] ?? '—').toString().toLowerCase();
map.set(st, (map.get(st)||0)+1);
});
return [...map.entries()].map(([estado,count])=>({estado, count}));
}
// ===== Render =====
function renderAll(){
computeKpis();
// Top productos
const top = computeTopProductosHoy();
drawBar('chartTopProductos', top.map(x=>x.name), top.map(x=>x.qty));
// Comandas por hora
const porHora = computeComandasPorHora12h();
drawLine('chartComandasHora', porHora.map(x=>x.label), porHora.map(x=>x.count));
// Estados
const estados = computeEstadosHoy();
drawDoughnut('chartEstados', estados.map(x=>x.estado), estados.map(x=>x.count));
// Últimas 10
renderUltimas();
}
function renderUltimas(){
const {comFecha, comFechaCierre, comEstado, comId} = state.keys;
const {detIdCom, detPrecio, detCant} = state.keys;
const byCom = new Map();
state.deta.forEach(d => {
const idc = d[detIdCom]; if (idc==null) return;
const arr = byCom.get(idc) || []; arr.push(d); byCom.set(idc, arr);
});
const rows = state.comandas
.slice()
.sort((a,b)=> {
const da = comFecha ? +new Date(a[comFecha]) : 0;
const db = comFecha ? +new Date(b[comFecha]) : 0;
return db - da;
})
.slice(0,10);
const tb = $('#ultimasTbody'); tb.innerHTML = '';
let lastTs = null;
rows.forEach(c=>{
const dets = byCom.get(c[comId]) || [];
const total = dets.reduce((acc,d)=> acc + Number(d[detPrecio]||0) * Number(d[detCant]||0), 0);
const ts = comFecha ? new Date(c[comFecha]) : null;
const tsc = comFechaCierre ? new Date(c[comFechaCierre]) : null;
if (ts) lastTs = (!lastTs || ts>lastTs) ? ts : lastTs;
const activa = isActiva(c[comEstado]);
const btn = activa
? `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${c[comId]}">Cerrar</button>`
: `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${c[comId]}">Abrir</button>`;
const tr = document.createElement('tr');
tr.dataset.id = c[comId];
tr.innerHTML = `
<td>${c[comId] ?? '—'}</td>
<td>${ts ? fmtTs(ts) : '—'}</td>
<td class="c-cierre">${tsc && !isNaN(tsc) ? fmtTs(tsc) : '—'}</td>
<td class="c-estado">${c[comEstado] ?? '—'}</td>
<td class="text-end">${fmtMoney(total)}</td>
<td class="c-accion">${btn}</td>
`;
tb.appendChild(tr);
});
$('#ultAct').textContent = lastTs ? ('Actualizado: ' + fmtTs(lastTs)) : '—';
}
// ===== Charts helpers =====
function destroyChart(id){ if (charts[id]) { charts[id].destroy(); charts[id]=null; } }
function drawBar(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'bar',
data: { labels, datasets: [{ label: 'Cantidad', data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawLine(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'line',
data: { labels, datasets: [{ label: 'Comandas', data, tension:.3, fill:false }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{display:false}} }
});
}
function drawDoughnut(id, labels, data){
destroyChart(id);
const ctx = document.getElementById(id);
charts[id] = new Chart(ctx, {
type: 'doughnut',
data: { labels, datasets: [{ data }] },
options: { responsive:true, maintainAspectRatio:false, plugins:{legend:{position:'bottom'}} }
});
}
// ===== Eventos =====
$('#dashRefresh').addEventListener('click', loadData);
window.addEventListener('sc:refresh-list', loadData); // desde el sidebar "Actualizar listado"
// Abrir/Cerrar comanda (actualiza fila + estado interno + re-render KPIs/gráficos)
document.addEventListener('click', async (ev) => {
const btn = ev.target.closest('.js-cerrar, .js-abrir');
if (!btn) return;
const id = btn.dataset.id;
const isCerrar = btn.classList.contains('js-cerrar');
const url = isCerrar ? `/api/comandas/${id}/cerrar` : `/api/comandas/${id}/abrir`;
btn.disabled = true;
try {
const r = await fetch(url, { method: 'POST' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
// Actualizar estado local
const { comId, comEstado, comFechaCierre } = state.keys;
const idx = state.comandas.findIndex(c => String(c[comId]) === String(id));
if (idx >= 0) {
state.comandas[idx][comEstado] = data.estado ?? state.comandas[idx][comEstado];
if (comFechaCierre) state.comandas[idx][comFechaCierre] = data.fec_cierre ?? state.comandas[idx][comFechaCierre];
}
// Actualizar fila visual
const tr = document.querySelector(`tr[data-id="${id}"]`);
if (tr) {
const tdEstado = tr.querySelector('.c-estado');
const tdCierre = tr.querySelector('.c-cierre');
if (tdEstado) tdEstado.textContent = data.estado ?? tdEstado.textContent;
if (tdCierre) tdCierre.textContent = data.fec_cierre ? fmtTs(data.fec_cierre) : '—';
const acc = tr.querySelector('.c-accion');
if (acc) {
acc.innerHTML = (data.estado && data.estado.toLowerCase().includes('cerr'))
? `<button class="btn btn-outline-success btn-sm js-abrir" data-id="${id}">Abrir</button>`
: `<button class="btn btn-outline-danger btn-sm js-cerrar" data-id="${id}">Cerrar</button>`;
}
}
// Recalcular KPIs y gráficos (sin “crecimiento infinito”, se destruyen antes de redibujar)
renderAll();
} catch (e) {
alert('No se pudo actualizar la comanda: ' + (e.message || 'Error'));
} finally {
btn.disabled = false;
}
});
// Go!
loadData().catch(e => setStatus(e.message || 'Error'));
// Exporta CSV con KPIs y cortes básicos
window.scExportCsv = function () {
const rows = [];
rows.push(["kpi", "valor"]);
rows.push(["comandas_activas", document.getElementById("kpiActivas").textContent.trim()]);
rows.push(["ventas_hoy", document.getElementById("kpiVentasHoy").textContent.trim()]);
rows.push(["ticket_promedio_hoy", document.getElementById("kpiTicketProm").textContent.trim()]);
rows.push(["productos_distintos_hoy", document.getElementById("kpiProdDist").textContent.trim()]);
const csv = rows.map(r => r.map(v => `"${String(v).replaceAll('"','""')}"`).join(",")).join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `dashboard_${new Date().toISOString().slice(0,10)}.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
};
</script>