488 lines
18 KiB
Plaintext
488 lines
18 KiB
Plaintext
<!-- 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>
|