290825-0209
This commit is contained in:
parent
44d1adecdc
commit
57dbd5b1fa
@ -185,19 +185,31 @@ app.use((req, res, next) => {
|
||||
// ----------------------------------------------------------
|
||||
|
||||
app.get("/", (req, res) => {
|
||||
res.locals.pageTitle = "Inicio";
|
||||
res.locals.pageId = "home";
|
||||
res.render("estadoComandas");
|
||||
res.locals.pageTitle = "Dashboard";
|
||||
res.locals.pageId = "home"; // para el sidebar contextual
|
||||
res.render("dashboard");
|
||||
});
|
||||
|
||||
app.get("/dashboard", (req, res) => {
|
||||
res.locals.pageTitle = "Dashboard";
|
||||
res.locals.pageId = "dashboard"; // <- importante
|
||||
res.render("dashboard");
|
||||
});
|
||||
|
||||
// app.get('/', (req, res) => {
|
||||
// res.sendFile(path.join(__dirname, 'pages', 'dashboard.html'));
|
||||
// });
|
||||
|
||||
app.get('/comandas', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'pages', 'comandas.html'));
|
||||
app.get("/comandas", (req, res) => {
|
||||
res.locals.pageTitle = "Comandas";
|
||||
res.locals.pageId = "comandas"; // <- importante para el sidebar contextual
|
||||
res.render("comandas");
|
||||
});
|
||||
|
||||
// app.get('/comandas', (req, res) => {
|
||||
// res.sendFile(path.join(__dirname, 'pages', 'comandas.html'));
|
||||
// });
|
||||
|
||||
app.get("/estadoComandas", (req, res) => {
|
||||
res.locals.pageTitle = "Estado de Comandas";
|
||||
res.locals.pageId = "estadoComandas";
|
||||
@ -325,85 +337,159 @@ app.post('/api/table/:table', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Listado (con join) y totales por comanda
|
||||
app.get('/api/comandas', async (req, res, next) => {
|
||||
try {
|
||||
const estado = (req.query.estado || '').trim();
|
||||
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
const params = [];
|
||||
let where = '';
|
||||
if (estado) { params.push(estado); where = `WHERE c.estado = $${params.length}`; }
|
||||
params.push(limit);
|
||||
const estado = (req.query.estado || '').trim() || null;
|
||||
const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
|
||||
const sql = `
|
||||
WITH items AS (
|
||||
SELECT d.id_comanda,
|
||||
COUNT(*) AS items,
|
||||
SUM(d.cantidad * d.pre_unitario) AS total
|
||||
FROM deta_comandas d
|
||||
GROUP BY d.id_comanda
|
||||
)
|
||||
SELECT
|
||||
c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
|
||||
u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
|
||||
m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
|
||||
COALESCE(i.items, 0) AS items,
|
||||
COALESCE(i.total, 0) AS total
|
||||
FROM comandas c
|
||||
JOIN usuarios u ON u.id_usuario = c.id_usuario
|
||||
JOIN mesas m ON m.id_mesa = c.id_mesa
|
||||
LEFT JOIN items i ON i.id_comanda = c.id_comanda
|
||||
${where}
|
||||
ORDER BY c.id_comanda DESC
|
||||
LIMIT $${params.length}
|
||||
`;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const { rows } = await client.query(sql, params);
|
||||
res.json(rows);
|
||||
} finally { client.release(); }
|
||||
const { rows } = await pool.query(
|
||||
`SELECT * FROM public.f_comandas_resumen($1, $2)`,
|
||||
[estado, limit]
|
||||
);
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
|
||||
// app.get('/api/comandas', async (req, res, next) => {
|
||||
// try {
|
||||
// const estado = (req.query.estado || '').trim();
|
||||
// const limit = Math.min(parseInt(req.query.limit || '200', 10), 1000);
|
||||
// const params = [];
|
||||
// let where = '';
|
||||
// if (estado) { params.push(estado); where = `WHERE c.estado = $${params.length}`; }
|
||||
// params.push(limit);
|
||||
|
||||
// const sql = `
|
||||
// WITH items AS (
|
||||
// SELECT d.id_comanda,
|
||||
// COUNT(*) AS items,
|
||||
// SUM(d.cantidad * d.pre_unitario) AS total
|
||||
// FROM deta_comandas d
|
||||
// GROUP BY d.id_comanda
|
||||
// )
|
||||
// SELECT
|
||||
// c.id_comanda, c.fec_creacion, c.estado, c.observaciones,
|
||||
// u.id_usuario, u.nombre AS usuario_nombre, u.apellido AS usuario_apellido,
|
||||
// m.id_mesa, m.numero AS mesa_numero, m.apodo AS mesa_apodo,
|
||||
// COALESCE(i.items, 0) AS items,
|
||||
// COALESCE(i.total, 0) AS total
|
||||
// FROM comandas c
|
||||
// JOIN usuarios u ON u.id_usuario = c.id_usuario
|
||||
// JOIN mesas m ON m.id_mesa = c.id_mesa
|
||||
// LEFT JOIN items i ON i.id_comanda = c.id_comanda
|
||||
// ${where}
|
||||
// ORDER BY c.id_comanda DESC
|
||||
// LIMIT $${params.length}
|
||||
// `;
|
||||
// const client = await pool.connect();
|
||||
// try {
|
||||
// const { rows } = await client.query(sql, params);
|
||||
// res.json(rows);
|
||||
// } finally { client.release(); }
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
// Detalle de una comanda (con nombres de productos)
|
||||
|
||||
app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
|
||||
const sql = `
|
||||
SELECT d.id_det_comanda, d.id_producto, p.nombre AS producto_nombre,
|
||||
d.cantidad, d.pre_unitario, (d.cantidad * d.pre_unitario) AS subtotal,
|
||||
d.observaciones
|
||||
FROM deta_comandas d
|
||||
JOIN productos p ON p.id_producto = d.id_producto
|
||||
WHERE d.id_comanda = $1
|
||||
ORDER BY d.id_det_comanda
|
||||
SELECT
|
||||
id_det_comanda, id_producto, producto_nombre,
|
||||
cantidad, pre_unitario, subtotal, observaciones
|
||||
FROM public.v_comandas_detalle_items
|
||||
WHERE id_comanda = $1::int
|
||||
ORDER BY id_det_comanda
|
||||
`;
|
||||
const { rows } = await pool.query(sql, [id]);
|
||||
res.json(rows);
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// Cambiar estado (abrir/cerrar)
|
||||
app.post('/api/comandas/:id/estado', async (req, res, next) => {
|
||||
|
||||
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = parseInt(req.params.id, 10);
|
||||
// if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||
|
||||
// const sql = `
|
||||
// SELECT d.id_det_comanda, d.id_producto, p.nombre AS producto_nombre,
|
||||
// d.cantidad, d.pre_unitario, (d.cantidad * d.pre_unitario) AS subtotal,
|
||||
// d.observaciones
|
||||
// FROM deta_comandas d
|
||||
// JOIN productos p ON p.id_producto = d.id_producto
|
||||
// WHERE d.id_comanda = $1
|
||||
// ORDER BY d.id_det_comanda
|
||||
// `;
|
||||
// const { rows } = await pool.query(sql, [id]);
|
||||
// res.json(rows);
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
|
||||
|
||||
// Cerrar comanda (setea estado y fec_cierre en DB)
|
||||
app.post('/api/comandas/:id/cerrar', async (req, res, next) => {
|
||||
try {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
let { estado } = req.body || {};
|
||||
if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||
|
||||
const allowed = new Set(['abierta','cerrada','pagada','anulada']);
|
||||
if (!allowed.has(estado)) return res.status(400).json({ error: 'estado inválido' });
|
||||
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`UPDATE comandas SET estado = $2 WHERE id_comanda = $1 RETURNING *`,
|
||||
[id, estado]
|
||||
`SELECT public.f_cerrar_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length) return res.status(404).json({ error: 'comanda no encontrada' });
|
||||
res.json({ updated: rows[0] });
|
||||
} catch (e) { next(e); }
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Abrir (reabrir) comanda
|
||||
app.post('/api/comandas/:id/abrir', async (req, res, next) => {
|
||||
try {
|
||||
const id = Number(req.params.id);
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
return res.status(400).json({ error: 'id inválido' });
|
||||
}
|
||||
const { rows } = await pool.query(
|
||||
`SELECT public.f_abrir_comanda($1) AS data`,
|
||||
[id]
|
||||
);
|
||||
if (!rows.length || rows[0].data === null) {
|
||||
return res.status(404).json({ error: 'Comanda no encontrada' });
|
||||
}
|
||||
res.json(rows[0].data);
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// // Cambiar estado (abrir/cerrar)
|
||||
// app.post('/api/comandas/:id/estado', async (req, res, next) => {
|
||||
// try {
|
||||
// const id = parseInt(req.params.id, 10);
|
||||
// let { estado } = req.body || {};
|
||||
// if (!id) return res.status(400).json({ error: 'id inválido' });
|
||||
|
||||
// const allowed = new Set(['abierta','cerrada','pagada','anulada']);
|
||||
// if (!allowed.has(estado)) return res.status(400).json({ error: 'estado inválido' });
|
||||
|
||||
// const { rows } = await pool.query(
|
||||
// `UPDATE comandas SET estado = $2 WHERE id_comanda = $1 RETURNING *`,
|
||||
// [id, estado]
|
||||
// );
|
||||
// if (!rows.length) return res.status(404).json({ error: 'comanda no encontrada' });
|
||||
// res.json({ updated: rows[0] });
|
||||
// } catch (e) { next(e); }
|
||||
// });
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// Verificación de conexión
|
||||
// ----------------------------------------------------------
|
||||
|
||||
333
services/manso/src/views/comandas.ejs
Normal file
333
services/manso/src/views/comandas.ejs
Normal file
@ -0,0 +1,333 @@
|
||||
<!-- services/manso/src/views/comandas.ejs -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 m-0">📋 Nueva Comanda</h1>
|
||||
<span class="badge rounded-pill text-bg-light">/api/*</span>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- Columna izquierda: Productos -->
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Productos</strong>
|
||||
<div class="ms-auto small text-muted" id="prodCount">0 ítems</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-center mb-2">
|
||||
<div class="col-12 col-sm">
|
||||
<input id="busqueda" type="search" class="form-control" placeholder="Buscar por nombre o categoría…">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary" id="limpiarBusqueda">Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="listadoProductos" class="border rounded" style="max-height:58vh; overflow:auto;">
|
||||
<!-- tabla de productos renderizada por JS -->
|
||||
<div class="p-3 text-muted">Cargando…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Columna derecha: Detalles + Carrito -->
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong>Detalles</strong></div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2">
|
||||
<div class="col-12 col-sm-6">
|
||||
<label for="selMesa" class="form-label text-muted small mb-1">Mesa</label>
|
||||
<select id="selMesa" class="form-select"></select>
|
||||
</div>
|
||||
<div class="col-12 col-sm-6">
|
||||
<label for="selUsuario" class="form-label text-muted small mb-1">Usuario</label>
|
||||
<select id="selUsuario" class="form-select"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<label for="obs" class="form-label text-muted small mb-1">Observaciones</label>
|
||||
<textarea id="obs" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-secondary mt-3 mb-0 small">
|
||||
La fecha se completa automáticamente y los estados/activos usan sus valores por defecto.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><strong>Carrito</strong></div>
|
||||
<div class="card-body p-0" id="carritoWrap">
|
||||
<div class="p-3 text-muted">Aún no agregaste productos.</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
|
||||
<div class="small"><span class="text-muted">Ítems:</span> <strong id="kpiItems">0</strong></div>
|
||||
<div class="small ms-2"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div class="ms-auto"></div>
|
||||
<button class="btn btn-outline-secondary" id="vaciar">Vaciar</button>
|
||||
<button class="btn btn-primary" id="crear">Crear Comanda</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="msg" class="mt-2 small text-muted"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ====== LÓGICA ====== -->
|
||||
<script>
|
||||
// Helpers DOM
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
|
||||
// Estado
|
||||
const state = {
|
||||
productos: [],
|
||||
mesas: [],
|
||||
usuarios: [],
|
||||
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
|
||||
filtro: ''
|
||||
};
|
||||
|
||||
// Utils
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => {
|
||||
const el = $('#msg');
|
||||
el.className = ok ? 'mt-2 small ok text-success' : 'mt-2 small err text-danger';
|
||||
el.textContent = msg;
|
||||
setTimeout(()=>{ el.textContent=''; el.className='mt-2 small text-muted'; }, 3500);
|
||||
};
|
||||
|
||||
async function jget(url) {
|
||||
const res = await fetch(url);
|
||||
let data; try { data = await res.json(); } catch { data = null; }
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body) {
|
||||
const res = await fetch(url, { method:'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// Carga inicial
|
||||
async function init() {
|
||||
const [prods, mesas, usuarios] = await Promise.all([
|
||||
jget('/api/table/productos?limit=1000'),
|
||||
jget('/api/table/mesas?limit=1000'),
|
||||
jget('/api/table/usuarios?limit=1000')
|
||||
]);
|
||||
|
||||
state.productos = prods.filter(p => p.activo !== false);
|
||||
state.mesas = mesas;
|
||||
state.usuarios = usuarios.filter(u => u.activo !== false);
|
||||
|
||||
hydrateMesas();
|
||||
hydrateUsuarios();
|
||||
renderProductos();
|
||||
renderCarrito();
|
||||
|
||||
$('#busqueda').addEventListener('input', () => {
|
||||
state.filtro = $('#busqueda').value.trim().toLowerCase();
|
||||
renderProductos();
|
||||
});
|
||||
$('#limpiarBusqueda').addEventListener('click', () => {
|
||||
$('#busqueda').value = '';
|
||||
state.filtro = '';
|
||||
renderProductos();
|
||||
});
|
||||
$('#vaciar').addEventListener('click', () => { state.carrito = []; renderCarrito(); });
|
||||
$('#crear').addEventListener('click', crearComanda);
|
||||
}
|
||||
|
||||
function hydrateMesas() {
|
||||
const sel = $('#selMesa'); sel.innerHTML = '';
|
||||
for (const m of state.mesas) {
|
||||
const o = document.createElement('option');
|
||||
o.value = m.id_mesa;
|
||||
o.textContent = `#${m.numero} · ${m.apodo} (${m.estado})`;
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
function hydrateUsuarios() {
|
||||
const sel = $('#selUsuario'); sel.innerHTML = '';
|
||||
for (const u of state.usuarios) {
|
||||
const o = document.createElement('option');
|
||||
o.value = u.id_usuario;
|
||||
o.textContent = `${u.nombre} ${u.apellido}`.trim();
|
||||
sel.appendChild(o);
|
||||
}
|
||||
}
|
||||
|
||||
// Render productos
|
||||
function renderProductos() {
|
||||
let rows = state.productos.slice();
|
||||
if (state.filtro) {
|
||||
rows = rows.filter(p =>
|
||||
(p.nombre || '').toLowerCase().includes(state.filtro) ||
|
||||
String(p.id_categoria ?? '').includes(state.filtro)
|
||||
);
|
||||
}
|
||||
$('#prodCount').textContent = `${rows.length} ítems`;
|
||||
|
||||
if (!rows.length) {
|
||||
$('#listadoProductos').innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Precio</th>
|
||||
<th style="width:210px;">Cantidad</th>
|
||||
<th style="width:100px;"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
for (const p of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${p.nombre}</td>
|
||||
<td class="text-end">${money(p.precio)}</td>
|
||||
<td>
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<input type="number" min="0.001" step="0.001" value="1.000" data-qty class="form-control form-control-sm" style="max-width:120px;">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-dec>-</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" data-inc>+</button>
|
||||
</div>
|
||||
</td>
|
||||
<td><button class="btn btn-sm btn-primary" data-add>Agregar</button></td>
|
||||
`;
|
||||
const qty = tr.querySelector('[data-qty]');
|
||||
tr.querySelector('[data-dec]').addEventListener('click', () => { qty.value = Math.max(0.001, (parseFloat(qty.value||'0') - 1)).toFixed(3); });
|
||||
tr.querySelector('[data-inc]').addEventListener('click', () => { qty.value = (parseFloat(qty.value||'0') + 1).toFixed(3); });
|
||||
tr.querySelector('[data-add]').addEventListener('click', () => addToCart(p, parseFloat(qty.value||'1')) );
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
|
||||
$('#listadoProductos').innerHTML = '';
|
||||
$('#listadoProductos').appendChild(tbl);
|
||||
}
|
||||
|
||||
function addToCart(prod, cantidad) {
|
||||
if (!(cantidad > 0)) { toast('Cantidad inválida'); return; }
|
||||
const precio = parseFloat(prod.precio);
|
||||
const it = state.carrito.find(i => i.id_producto === prod.id_producto && i.pre_unitario === precio);
|
||||
if (it) it.cantidad = Number((it.cantidad + cantidad).toFixed(3));
|
||||
else state.carrito.push({ id_producto: prod.id_producto, nombre: prod.nombre, pre_unitario: precio, cantidad: Number(cantidad.toFixed(3)) });
|
||||
renderCarrito();
|
||||
}
|
||||
|
||||
// Render carrito
|
||||
function renderCarrito() {
|
||||
const wrap = $('#carritoWrap');
|
||||
if (!state.carrito.length) {
|
||||
wrap.innerHTML = '<div class="p-3 text-muted">Aún no agregaste productos.</div>';
|
||||
$('#kpiItems').textContent='0'; $('#kpiTotal').textContent=money(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Unitario</th>
|
||||
<th class="text-end">Cantidad</th>
|
||||
<th class="text-end">Subtotal</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let items = 0, total = 0;
|
||||
state.carrito.forEach((it, idx) => {
|
||||
items += 1;
|
||||
const sub = Number(it.pre_unitario) * Number(it.cantidad);
|
||||
total += sub;
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${it.nombre}</td>
|
||||
<td class="text-end">${money(it.pre_unitario)}</td>
|
||||
<td class="text-end">
|
||||
<input type="number" min="0.001" step="0.001" value="${it.cantidad.toFixed(3)}" class="form-control form-control-sm text-end" style="max-width:120px;">
|
||||
</td>
|
||||
<td class="text-end">${money(sub)}</td>
|
||||
<td class="text-end">
|
||||
<button class="btn btn-sm btn-outline-secondary" data-del>Quitar</button>
|
||||
</td>
|
||||
`;
|
||||
const qty = tr.querySelector('input[type="number"]');
|
||||
qty.addEventListener('change', () => {
|
||||
const v = parseFloat(qty.value||'0');
|
||||
if (!(v>0)) { toast('Cantidad inválida'); qty.value = it.cantidad.toFixed(3); return; }
|
||||
it.cantidad = Number(v.toFixed(3));
|
||||
renderCarrito();
|
||||
});
|
||||
tr.querySelector('[data-del]').addEventListener('click', () => {
|
||||
state.carrito.splice(idx,1);
|
||||
renderCarrito();
|
||||
});
|
||||
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
wrap.innerHTML = '';
|
||||
wrap.appendChild(tbl);
|
||||
$('#kpiItems').textContent = String(items);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// Crear comanda
|
||||
async function crearComanda() {
|
||||
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
|
||||
const id_mesa = parseInt($('#selMesa').value, 10);
|
||||
const id_usuario = parseInt($('#selUsuario').value, 10);
|
||||
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
|
||||
|
||||
const observaciones = $('#obs').value.trim() || null;
|
||||
|
||||
try {
|
||||
// 1) encabezado comanda
|
||||
const { inserted: com } = await jpost('/api/table/comandas', {
|
||||
id_usuario,
|
||||
id_mesa,
|
||||
estado: 'abierta',
|
||||
observaciones
|
||||
});
|
||||
|
||||
// 2) detalle
|
||||
const id_comanda = com.id_comanda;
|
||||
const payloads = state.carrito.map(it => ({
|
||||
id_comanda,
|
||||
id_producto: it.id_producto,
|
||||
cantidad: it.cantidad,
|
||||
pre_unitario: it.pre_unitario
|
||||
}));
|
||||
|
||||
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
|
||||
|
||||
state.carrito = [];
|
||||
renderCarrito();
|
||||
$('#obs').value = '';
|
||||
toast(`Comanda #${id_comanda} creada`, true);
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo crear la comanda');
|
||||
}
|
||||
}
|
||||
|
||||
// GO
|
||||
init().catch(err => toast(err.message || 'Error cargando datos'));
|
||||
</script>
|
||||
487
services/manso/src/views/dashboard.ejs
Normal file
487
services/manso/src/views/dashboard.ejs
Normal 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>
|
||||
@ -1,18 +0,0 @@
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 m-0">Estado de Comandas</h1>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="soloAbiertas">
|
||||
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tu tabla/listado acá -->
|
||||
<table class="table table-sm table-bordered">
|
||||
<thead><tr><th>ID</th><th>Mesa</th><th>Estado</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td><td>5</td>
|
||||
<td><span class="badge badge-outline badge-estado-abierta">Abierta</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
291
services/manso/src/views/estadoComandas.ejs
Normal file
291
services/manso/src/views/estadoComandas.ejs
Normal file
@ -0,0 +1,291 @@
|
||||
<!-- services/manso/src/views/estadoComandas.ejs -->
|
||||
<div class="d-flex align-items-center justify-content-between mb-3">
|
||||
<h1 class="h4 m-0">🧾 Estado de Comandas</h1>
|
||||
<a class="btn btn-sm btn-dark" href="/comandas">➕ Nueva comanda</a>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<!-- ===== Listado (izquierda) ===== -->
|
||||
<div class="col-12 col-lg-7">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Listado</strong>
|
||||
<div class="form-check form-switch ms-auto">
|
||||
<input class="form-check-input" type="checkbox" id="soloAbiertas" checked>
|
||||
<label class="form-check-label" for="soloAbiertas">Solo abiertas</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-2 align-items-center mb-2">
|
||||
<div class="col">
|
||||
<input id="buscar" type="search" class="form-control" placeholder="Buscar por #, mesa o usuario…">
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<button class="btn btn-outline-secondary" id="limpiar">Limpiar</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="lista" class="table-responsive" style="max-height:62vh; overflow:auto;">
|
||||
<div class="p-3 text-muted">Cargando…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===== Detalle (derecha) ===== -->
|
||||
<div class="col-12 col-lg-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Detalle</strong>
|
||||
<span id="detalleEstado" class="badge badge-outline ms-auto">—</span>
|
||||
</div>
|
||||
|
||||
<div class="card-body" id="detalle">
|
||||
<div class="text-muted">Selecciona una comanda para ver el detalle.</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-3 p-3 border-top" style="position:sticky; bottom:0; background:#fff;">
|
||||
<div class="small"><span class="text-muted">ID:</span> <strong id="kpiId">—</strong></div>
|
||||
<div class="small"><span class="text-muted">Mesa:</span> <strong id="kpiMesa">—</strong></div>
|
||||
<div class="small"><span class="text-muted">Total:</span> <strong id="kpiTotal">$ 0.00</strong></div>
|
||||
<div class="ms-auto"></div>
|
||||
<button class="btn btn-outline-secondary" id="reabrir">Reabrir</button>
|
||||
<button class="btn btn-primary" id="cerrar">Cerrar</button>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div id="msg" class="text-muted small"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ===== Helpers =====
|
||||
const $ = (s, r=document) => r.querySelector(s);
|
||||
const $$ = (s, r=document) => Array.from(r.querySelectorAll(s));
|
||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||
const toast = (msg, ok=false) => {
|
||||
const el = $('#msg');
|
||||
el.className = ok ? 'text-success small' : 'text-danger small';
|
||||
el.textContent = msg;
|
||||
setTimeout(()=>{ el.textContent=''; el.className='text-muted small'; }, 3000);
|
||||
};
|
||||
const badgeClass = (estadoRaw) => {
|
||||
const s = String(estadoRaw||'').toLowerCase();
|
||||
if (s.includes('abier')) return 'badge badge-outline badge-estado-abierta';
|
||||
if (s.includes('pagad') || s.includes('paga')) return 'badge badge-outline badge-estado-pagada';
|
||||
if (s.includes('cerr')) return 'badge badge-outline badge-estado-cerrada';
|
||||
if (s.includes('anul') || s.includes('cancel')) return 'badge badge-outline badge-estado-anulada';
|
||||
return 'badge badge-outline';
|
||||
};
|
||||
|
||||
async function jget(url){
|
||||
const res = await fetch(url);
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
async function jpost(url, body){
|
||||
const res = await fetch(url, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
||||
const data = await res.json().catch(()=>null);
|
||||
if (!res.ok) throw new Error(data?.error || `${res.status} ${res.statusText}`);
|
||||
return data;
|
||||
}
|
||||
|
||||
// ===== Estado =====
|
||||
const state = {
|
||||
filtro: '',
|
||||
soloAbiertas: true,
|
||||
lista: [], // [{ id_comanda, mesa_numero, mesa_apodo, usuario_nombre, usuario_apellido, fec_creacion, estado, items, total }]
|
||||
sel: null, // id seleccionado
|
||||
detalle: [] // [{ id_det_comanda, producto_nombre, cantidad, pre_unitario, subtotal, observaciones }]
|
||||
};
|
||||
|
||||
// ===== Data =====
|
||||
async function loadLista() {
|
||||
const estado = state.soloAbiertas ? 'abierta' : '';
|
||||
const url = estado ? `/api/comandas?estado=${encodeURIComponent(estado)}&limit=300` : '/api/comandas?limit=300';
|
||||
const rows = await jget(url);
|
||||
state.lista = Array.isArray(rows) ? rows : [];
|
||||
renderLista();
|
||||
}
|
||||
|
||||
async function loadDetalle(id) {
|
||||
const det = await jget(`/api/comandas/${id}/detalle`);
|
||||
state.detalle = Array.isArray(det) ? det : [];
|
||||
renderDetalle();
|
||||
}
|
||||
|
||||
// ===== UI: Lista =====
|
||||
function renderLista(){
|
||||
let rows = state.lista.slice();
|
||||
const f = state.filtro?.trim().toLowerCase();
|
||||
if (f) {
|
||||
rows = rows.filter(r =>
|
||||
String(r.id_comanda).includes(f) ||
|
||||
String(r.mesa_numero ?? '').includes(f) ||
|
||||
(`${r.usuario_nombre||''} ${r.usuario_apellido||''}`).toLowerCase().includes(f)
|
||||
);
|
||||
}
|
||||
|
||||
const box = $('#lista');
|
||||
if (!rows.length) { box.innerHTML = '<div class="p-3 text-muted">Sin resultados.</div>'; return; }
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Mesa</th>
|
||||
<th>Usuario</th>
|
||||
<th>Fecha</th>
|
||||
<th>Estado</th>
|
||||
<th class="text-end">Ítems</th>
|
||||
<th class="text-end">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
rows.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.innerHTML = `
|
||||
<td>${r.id_comanda}</td>
|
||||
<td>#${r.mesa_numero ?? '—'} ${r.mesa_apodo ? '· '+r.mesa_apodo : ''}</td>
|
||||
<td>${(r.usuario_nombre||'') + ' ' + (r.usuario_apellido||'')}</td>
|
||||
<td>${r.fec_creacion ? new Date(r.fec_creacion).toLocaleString() : '—'}</td>
|
||||
<td><span class="${badgeClass(r.estado)}">${r.estado ?? '—'}</span></td>
|
||||
<td class="text-end">${r.items ?? '—'}</td>
|
||||
<td class="text-end">${money(r.total ?? 0)}</td>
|
||||
`;
|
||||
tr.addEventListener('click', () => { state.sel = r.id_comanda; loadDetalle(r.id_comanda); applyHeader(r); });
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
}
|
||||
|
||||
// ===== UI: Detalle + KPIs =====
|
||||
function applyHeader(r){
|
||||
$('#kpiId').textContent = r.id_comanda ?? '—';
|
||||
$('#kpiMesa').textContent = r.mesa_numero ? `#${r.mesa_numero}` : '—';
|
||||
$('#detalleEstado').className = badgeClass(r.estado);
|
||||
$('#detalleEstado').textContent = r.estado ?? '—';
|
||||
$('#kpiTotal').textContent = money(r.total ?? 0);
|
||||
|
||||
// Botones
|
||||
const cerr = $('#cerrar'), reab = $('#reabrir');
|
||||
if ((r.estado||'').toLowerCase().includes('abier')) {
|
||||
cerr.disabled = false; cerr.title = '';
|
||||
reab.disabled = true; reab.title = 'Ya está abierta';
|
||||
} else {
|
||||
cerr.disabled = false;
|
||||
reab.disabled = false;
|
||||
cerr.title = ''; reab.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetalle(){
|
||||
const box = $('#detalle');
|
||||
if (!state.detalle.length) {
|
||||
box.innerHTML = '<div class="text-muted">Sin detalle.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const tbl = document.createElement('table');
|
||||
tbl.className = 'table table-sm align-middle mb-0';
|
||||
tbl.innerHTML = `
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Producto</th>
|
||||
<th class="text-end">Unitario</th>
|
||||
<th class="text-end">Cantidad</th>
|
||||
<th class="text-end">Subtotal</th>
|
||||
<th>Observaciones</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tb = tbl.querySelector('tbody');
|
||||
|
||||
let total = 0;
|
||||
state.detalle.forEach(r => {
|
||||
const sub = Number(r.subtotal || (Number(r.pre_unitario||0) * Number(r.cantidad||0)));
|
||||
total += sub;
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.producto_nombre ?? '—'}</td>
|
||||
<td class="text-end">${money(r.pre_unitario)}</td>
|
||||
<td class="text-end">${Number(r.cantidad||0).toFixed(3)}</td>
|
||||
<td class="text-end">${money(sub)}</td>
|
||||
<td>${r.observaciones || ''}</td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
box.innerHTML = '';
|
||||
box.appendChild(tbl);
|
||||
$('#kpiTotal').textContent = money(total);
|
||||
}
|
||||
|
||||
// ===== Actions =====
|
||||
async function setEstado(estado){
|
||||
if (!state.sel) return;
|
||||
try {
|
||||
const { updated } = await jpost(`/api/comandas/${state.sel}/estado`, { estado });
|
||||
toast(`Comanda #${updated.id_comanda} → ${updated.estado}`, true);
|
||||
await loadLista();
|
||||
const found = state.lista.find(x => x.id_comanda === updated.id_comanda);
|
||||
if (found) { applyHeader(found); await loadDetalle(found.id_comanda); }
|
||||
else {
|
||||
state.sel = null;
|
||||
$('#detalle').innerHTML = '<div class="text-muted">Selecciona una comanda para ver el detalle.</div>';
|
||||
$('#detalleEstado').textContent = '—'; $('#detalleEstado').className = 'badge badge-outline';
|
||||
$('#kpiId').textContent = '—'; $('#kpiMesa').textContent='—'; $('#kpiTotal').textContent = money(0);
|
||||
}
|
||||
} catch (e) {
|
||||
toast(e.message || 'No se pudo cambiar el estado');
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Hooks con Sidebar (offcanvas) =====
|
||||
// Permite que el botón "Actualizar" del sidebar recargue este listado
|
||||
window.scRefreshList = loadLista;
|
||||
// Exportación simple del listado actual
|
||||
window.scExportCsv = function(){
|
||||
const rows = state.lista.slice();
|
||||
const header = ["id_comanda","mesa_numero","mesa_apodo","usuario","fec_creacion","estado","items","total"];
|
||||
const csv = [header.join(",")].concat(rows.map(r => {
|
||||
const usuario = `${r.usuario_nombre||''} ${r.usuario_apellido||''}`.trim();
|
||||
const vals = [
|
||||
r.id_comanda,
|
||||
r.mesa_numero ?? '',
|
||||
(r.mesa_apodo ?? '').replaceAll('"','""'),
|
||||
usuario.replaceAll('"','""'),
|
||||
r.fec_creacion ?? '',
|
||||
r.estado ?? '',
|
||||
r.items ?? '',
|
||||
r.total ?? ''
|
||||
];
|
||||
return vals.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 = Object.assign(document.createElement("a"), {href:url, download:`estadoComandas_${new Date().toISOString().slice(0,10)}.csv`});
|
||||
document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ===== Init =====
|
||||
$('#soloAbiertas').addEventListener('change', async (e) => { state.soloAbiertas = e.target.checked; await loadLista(); });
|
||||
$('#buscar').addEventListener('input', () => { state.filtro = $('#buscar').value.trim(); renderLista(); });
|
||||
$('#limpiar').addEventListener('click', () => { $('#buscar').value=''; state.filtro=''; renderLista(); });
|
||||
$('#cerrar').addEventListener('click', () => setEstado('cerrada'));
|
||||
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
|
||||
|
||||
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
||||
</script>
|
||||
@ -1,16 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<% include ../partials/_head %>
|
||||
<%- include('../partials/_head') %>
|
||||
</head>
|
||||
<body data-page="<%= pageId %>">
|
||||
<% include ../partials/_navbar %>
|
||||
<%- include('../partials/_navbar') %>
|
||||
|
||||
<main class="container">
|
||||
<%- body %>
|
||||
</main>
|
||||
|
||||
<% include ../partials/_sidebar %>
|
||||
<% include ../partials/_footer %>
|
||||
<%- include('../partials/_sidebar') %>
|
||||
<%- include('../partials/_footer') %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -19,4 +19,19 @@
|
||||
.badge-estado-cerrada { border-color:#6c757d; color:#6c757d; }
|
||||
.badge-estado-anulada { border-color:#dc3545; color:#dc3545; }
|
||||
.badge-estado-pagada { border-color:#146c43; color:#146c43; }
|
||||
|
||||
/* Evita crecimiento infinito de los charts */
|
||||
.chart-box {
|
||||
position: relative;
|
||||
height: 260px; /* altura fija base */
|
||||
}
|
||||
@media (min-width: 992px) {
|
||||
.chart-box { height: 320px; } /* un poquito más grande en desktop */
|
||||
}
|
||||
.chart-box > canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100% !important;
|
||||
height: 100% !important; /* ocupa todo el alto del contenedor */
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -14,6 +14,13 @@
|
||||
// Map de opciones por página. Usa body[data-page] o window.scPageId.
|
||||
const SC_SIDEBAR_ITEMS = {
|
||||
// === ejemplos ===
|
||||
"dashboard": [
|
||||
{ text: "Ver reportes", href: "/reportes" },
|
||||
{ text: "Actualizar", href: "#", attr: { "data-action": "refresh-list" } },
|
||||
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
|
||||
{ text: "Nueva comanda", href: "/comandas" },
|
||||
{ text: "Ir a Estado", href: "/estadoComandas" }
|
||||
],
|
||||
"estadoComandas": [
|
||||
{ text: "➕ Nueva comanda", href: "/comandas" },
|
||||
{ text: "Solo abiertas", href: "#", attr: { "data-action": "toggle-abiertas" } },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user