290825-0209

This commit is contained in:
2025-08-29 05:09:44 +00:00
parent 44d1adecdc
commit 57dbd5b1fa
10 changed files with 1284 additions and 83 deletions
+487
View File
@@ -0,0 +1,487 @@
<!-- 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>