Impresión de tickets correcta.
This commit is contained in:
parent
57dbd5b1fa
commit
ce3d01a180
28
services/manso/package-lock.json
generated
28
services/manso/package-lock.json
generated
@ -16,7 +16,8 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-ejs-layouts": "^2.5.1",
|
"express-ejs-layouts": "^2.5.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-format": "^1.0.4"
|
"pg-format": "^1.0.4",
|
||||||
|
"serve-favicon": "^2.5.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cross-env": "^10.0.0",
|
"cross-env": "^10.0.0",
|
||||||
@ -1293,6 +1294,31 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/serve-favicon": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/serve-favicon/-/serve-favicon-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-JndLBslCLA/ebr7rS3d+/EKkzTsTi1jI2T9l+vHfAaGJ7A7NhtDpSZ0lx81HCNWnnE0yHncG+SSnVf9IMxOwXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"etag": "~1.8.1",
|
||||||
|
"fresh": "~0.5.2",
|
||||||
|
"ms": "~2.1.3",
|
||||||
|
"parseurl": "~1.3.2",
|
||||||
|
"safe-buffer": "~5.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/serve-favicon/node_modules/fresh": {
|
||||||
|
"version": "0.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||||
|
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/serve-static": {
|
"node_modules/serve-static": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
|||||||
@ -22,7 +22,8 @@
|
|||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-ejs-layouts": "^2.5.1",
|
"express-ejs-layouts": "^2.5.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
"pg-format": "^1.0.4"
|
"pg-format": "^1.0.4",
|
||||||
|
"serve-favicon": "^2.5.1"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"description": "Workarround para tener un MVP que llegue al verano para usarse"
|
"description": "Workarround para tener un MVP que llegue al verano para usarse"
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
// app/src/index.js
|
// app/src/index.js
|
||||||
import chalk from 'chalk'; // Colores!
|
import chalk from 'chalk'; // Colores!
|
||||||
|
import favicon from 'serve-favicon'; // Favicon
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import expressLayouts from 'express-ejs-layouts';
|
import expressLayouts from 'express-ejs-layouts';
|
||||||
import cors from 'cors';
|
import cors from 'cors';
|
||||||
@ -47,6 +48,14 @@ app.set("layout", "layouts/main");
|
|||||||
// Archivos estáticos
|
// Archivos estáticos
|
||||||
app.use(express.static(path.join(__dirname, "public")));
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
|
app.use('/favicon', express.static(path.join(__dirname, 'public', 'favicon'), {
|
||||||
|
maxAge: '1y'
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.use(favicon(path.join(__dirname, 'public', 'favicon', 'favicon.ico'), {
|
||||||
|
maxAge: '1y'
|
||||||
|
}));
|
||||||
|
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
// Configuración de conexión PostgreSQL
|
// Configuración de conexión PostgreSQL
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
@ -57,7 +66,6 @@ const dbConfig = {
|
|||||||
database: process.env.DB_NAME,
|
database: process.env.DB_NAME,
|
||||||
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
|
||||||
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
|
||||||
max: 10
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const pool = new Pool(dbConfig);
|
const pool = new Pool(dbConfig);
|
||||||
@ -393,25 +401,40 @@ app.get('/api/comandas', async (req, res, next) => {
|
|||||||
|
|
||||||
// Detalle de una comanda (con nombres de productos)
|
// Detalle de una comanda (con nombres de productos)
|
||||||
|
|
||||||
app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
// GET /api/comandas/:id/detalle
|
||||||
try {
|
app.get('/api/comandas/:id/detalle', (req, res, next) =>
|
||||||
const id = parseInt(req.params.id, 10);
|
pool.query(
|
||||||
if (!Number.isInteger(id) || id <= 0) {
|
`SELECT id_det_comanda, id_producto, producto_nombre,
|
||||||
return res.status(400).json({ error: 'id inválido' });
|
cantidad, pre_unitario, subtotal, observaciones
|
||||||
}
|
FROM public.v_comandas_detalle_items
|
||||||
|
|
||||||
const sql = `
|
|
||||||
SELECT
|
|
||||||
id_det_comanda, id_producto, producto_nombre,
|
|
||||||
cantidad, pre_unitario, subtotal, observaciones
|
|
||||||
FROM public.v_comandas_detalle_items
|
|
||||||
WHERE id_comanda = $1::int
|
WHERE id_comanda = $1::int
|
||||||
ORDER BY id_det_comanda
|
ORDER BY id_det_comanda`,
|
||||||
`;
|
[req.params.id]
|
||||||
const { rows } = await pool.query(sql, [id]);
|
)
|
||||||
res.json(rows);
|
.then(r => res.json(r.rows))
|
||||||
} catch (e) { next(e); }
|
.catch(next)
|
||||||
});
|
);
|
||||||
|
|
||||||
|
|
||||||
|
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
||||||
|
// try {
|
||||||
|
// const id = parseInt(req.params.id, 10);
|
||||||
|
// if (!Number.isInteger(id) || id <= 0) {
|
||||||
|
// return res.status(400).json({ error: 'id inválido' });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// const sql = `
|
||||||
|
// 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); }
|
||||||
|
// });
|
||||||
|
|
||||||
|
|
||||||
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
// app.get('/api/comandas/:id/detalle', async (req, res, next) => {
|
||||||
|
|||||||
BIN
services/manso/src/public/favicon/android-chrome-192x192.png
Normal file
BIN
services/manso/src/public/favicon/android-chrome-192x192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
services/manso/src/public/favicon/android-chrome-512x512.png
Normal file
BIN
services/manso/src/public/favicon/android-chrome-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 528 KiB |
BIN
services/manso/src/public/favicon/apple-touch-icon.png
Normal file
BIN
services/manso/src/public/favicon/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 51 KiB |
BIN
services/manso/src/public/favicon/favicon-16x16.png
Normal file
BIN
services/manso/src/public/favicon/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 488 B |
BIN
services/manso/src/public/favicon/favicon-32x32.png
Normal file
BIN
services/manso/src/public/favicon/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
BIN
services/manso/src/public/favicon/favicon.ico
Normal file
BIN
services/manso/src/public/favicon/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
1
services/manso/src/public/favicon/site.webmanifest
Normal file
1
services/manso/src/public/favicon/site.webmanifest
Normal file
@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
@ -90,6 +90,123 @@
|
|||||||
filtro: ''
|
filtro: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Genera el HTML del ticket de cocina (80mm aprox)
|
||||||
|
function buildKitchenTicketHTML(data) {
|
||||||
|
const mesaTxt = `Mesa #${data.mesa_numero ?? '—'}${data.mesa_apodo ? ' · ' + data.mesa_apodo : ''}`;
|
||||||
|
const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : '';
|
||||||
|
|
||||||
|
const productosHtml = data.productos.map(p => `
|
||||||
|
<div class="row">
|
||||||
|
<div class="qty">x${p.cantidad}</div>
|
||||||
|
<div class="name">${p.nombre}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
return `<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Ticket Cocina</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--w: 80mm; /* Cambia a 58mm si tu rollo es de 58 */
|
||||||
|
--fz: 30px; /* Base más grande */
|
||||||
|
--fz-sm: 13px;
|
||||||
|
--fz-lg: 20px; /* Filas de productos */
|
||||||
|
--fz-xl: 35px; /* Título */
|
||||||
|
}
|
||||||
|
html, body { margin:0; padding:0; }
|
||||||
|
body {
|
||||||
|
width: var(--w);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||||
|
font-size: var(--fz);
|
||||||
|
font-weight: 700; /* TODO en negrita */
|
||||||
|
line-height: 1.35;
|
||||||
|
color:#000;
|
||||||
|
}
|
||||||
|
#ticket { padding: 10px 8px; }
|
||||||
|
.center { text-align:center; }
|
||||||
|
.row { display:flex; gap:8px; margin: 4px 0; }
|
||||||
|
.row .qty {
|
||||||
|
min-width: 24mm;
|
||||||
|
font-size: var(--fz-lg);
|
||||||
|
letter-spacing: 0.2px;
|
||||||
|
}
|
||||||
|
.row .name {
|
||||||
|
flex:1;
|
||||||
|
font-size: var(--fz-lg);
|
||||||
|
text-transform: uppercase; /* Productos en MAYÚSCULAS */
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.hr { border-top: 2px dashed #000; margin: 8px 0; } /* Separador más grueso */
|
||||||
|
.small { font-size: var(--fz-sm); }
|
||||||
|
.bold { font-weight: 700; }
|
||||||
|
.mt4 { margin-top: 4px; }
|
||||||
|
.mt8 { margin-top: 8px; }
|
||||||
|
.mb4 { margin-bottom: 4px; }
|
||||||
|
.mb8 { margin-bottom: 8px; }
|
||||||
|
.title { font-size: var(--fz-xl); letter-spacing: 0.3px; }
|
||||||
|
@page { size: var(--w) auto; margin: 0; }
|
||||||
|
@media print { body { width: var(--w); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="ticket">
|
||||||
|
<div class="center bold title">COMANDA COCINA</div>
|
||||||
|
<div class="center small">#${data.id_comanda}</div>
|
||||||
|
<div class="hr"></div>
|
||||||
|
|
||||||
|
<div class="small">Fecha: ${data.fecha} ${data.hora}</div>
|
||||||
|
<div class="small">${mesaTxt}</div>
|
||||||
|
<div class="small">Mozo: ${data.usuario || '—'}</div>
|
||||||
|
|
||||||
|
${obs ? `<div class="mt8"><span class="bold">OBSERVACIONES:</span><br>${obs}</div>` : ''}
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
<div class="bold mb4">PRODUCTOS</div>
|
||||||
|
${productosHtml}
|
||||||
|
|
||||||
|
<div class="hr"></div>
|
||||||
|
<div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
|
||||||
|
|
||||||
|
<div class="center mt8 small">— fin —</div>
|
||||||
|
</div>
|
||||||
|
<script>window.onload = () => { window.focus(); window.print(); }<\/script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imprime HTML usando un iframe oculto (menos bloqueos de pop-up)
|
||||||
|
function printHtmlViaIframe(html) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let iframe = document.getElementById('printFrame');
|
||||||
|
if (!iframe) {
|
||||||
|
iframe = document.createElement('iframe');
|
||||||
|
iframe.id = 'printFrame';
|
||||||
|
iframe.style.position = 'fixed';
|
||||||
|
iframe.style.right = '-9999px';
|
||||||
|
iframe.style.bottom = '0';
|
||||||
|
iframe.style.width = '0';
|
||||||
|
iframe.style.height = '0';
|
||||||
|
iframe.style.border = '0';
|
||||||
|
document.body.appendChild(iframe);
|
||||||
|
}
|
||||||
|
const doc = iframe.contentWindow.document;
|
||||||
|
doc.open();
|
||||||
|
doc.write(html);
|
||||||
|
doc.close();
|
||||||
|
|
||||||
|
// Salida: remover iframe después de un rato para no acumular
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve();
|
||||||
|
// (si prefieres mantenerlo para reimpresiones, no lo quites)
|
||||||
|
// document.body.removeChild(iframe);
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
|
||||||
const toast = (msg, ok=false) => {
|
const toast = (msg, ok=false) => {
|
||||||
@ -290,13 +407,17 @@
|
|||||||
$('#kpiTotal').textContent = money(total);
|
$('#kpiTotal').textContent = money(total);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear comanda
|
const fmtQty = (n) => Number(n).toFixed(3).replace(/\.?0+$/,'');
|
||||||
|
|
||||||
async function crearComanda() {
|
async function crearComanda() {
|
||||||
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
|
if (!state.carrito.length) { toast('Agrega al menos un producto'); return; }
|
||||||
const id_mesa = parseInt($('#selMesa').value, 10);
|
const id_mesa = parseInt($('#selMesa').value, 10);
|
||||||
const id_usuario = parseInt($('#selUsuario').value, 10);
|
const id_usuario = parseInt($('#selUsuario').value, 10);
|
||||||
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
|
if (!id_mesa || !id_usuario) { toast('Selecciona mesa y usuario'); return; }
|
||||||
|
|
||||||
|
// Snapshot del carrito ANTES de limpiar (para imprimir)
|
||||||
|
const cartSnapshot = state.carrito.map(it => ({ ...it }));
|
||||||
|
|
||||||
const observaciones = $('#obs').value.trim() || null;
|
const observaciones = $('#obs').value.trim() || null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -310,24 +431,91 @@
|
|||||||
|
|
||||||
// 2) detalle
|
// 2) detalle
|
||||||
const id_comanda = com.id_comanda;
|
const id_comanda = com.id_comanda;
|
||||||
const payloads = state.carrito.map(it => ({
|
const payloads = cartSnapshot.map(it => ({
|
||||||
id_comanda,
|
id_comanda,
|
||||||
id_producto: it.id_producto,
|
id_producto: it.id_producto,
|
||||||
cantidad: it.cantidad,
|
cantidad: it.cantidad,
|
||||||
pre_unitario: it.pre_unitario
|
pre_unitario: it.pre_unitario
|
||||||
}));
|
}));
|
||||||
|
|
||||||
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
|
await Promise.all(payloads.map(p => jpost('/api/table/deta_comandas', p)));
|
||||||
|
|
||||||
|
// 3) Datos auxiliares para ticket
|
||||||
|
const mesa = state.mesas.find(m => m.id_mesa === id_mesa) || {};
|
||||||
|
const usuario = state.usuarios.find(u => u.id_usuario === id_usuario) || {};
|
||||||
|
const now = new Date();
|
||||||
|
const fecha = now.toLocaleDateString();
|
||||||
|
const hora = now.toLocaleTimeString();
|
||||||
|
|
||||||
|
// 4) Construir e imprimir Ticket de Cocina (sin precios)
|
||||||
|
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
|
||||||
|
const items = cartSnapshot.length;
|
||||||
|
|
||||||
|
const ticketHtml = buildKitchenTicketHTML({
|
||||||
|
id_comanda,
|
||||||
|
fecha, hora,
|
||||||
|
mesa_numero: mesa?.numero,
|
||||||
|
mesa_apodo: mesa?.apodo,
|
||||||
|
usuario: `${usuario?.nombre || ''} ${usuario?.apellido || ''}`.trim(),
|
||||||
|
observaciones,
|
||||||
|
items,
|
||||||
|
units,
|
||||||
|
productos: cartSnapshot.map(it => ({
|
||||||
|
nombre: it.nombre,
|
||||||
|
cantidad: fmtQty(it.cantidad)
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
|
||||||
|
await printHtmlViaIframe(ticketHtml);
|
||||||
|
|
||||||
|
// 5) Reset UI
|
||||||
state.carrito = [];
|
state.carrito = [];
|
||||||
renderCarrito();
|
renderCarrito();
|
||||||
$('#obs').value = '';
|
$('#obs').value = '';
|
||||||
toast(`Comanda #${id_comanda} creada`, true);
|
toast(`Comanda #${id_comanda} creada e impresa`, true);
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast(e.message || 'No se pudo crear la comanda');
|
toast(e.message || 'No se pudo crear la comanda');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// // 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
|
// GO
|
||||||
init().catch(err => toast(err.message || 'Error cargando datos'));
|
init().catch(err => toast(err.message || 'Error cargando datos'));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -80,6 +80,247 @@
|
|||||||
return 'badge badge-outline';
|
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 (más precisos según estado)
|
||||||
|
const cerr = $('#cerrar'), reab = $('#reabrir');
|
||||||
|
const s = String(r.estado||'').toLowerCase();
|
||||||
|
if (s.includes('abier')) {
|
||||||
|
cerr.disabled = false; cerr.title = '';
|
||||||
|
reab.disabled = true; reab.title = 'Ya está abierta';
|
||||||
|
} else if (s.includes('cerr')) {
|
||||||
|
cerr.disabled = true; cerr.title = 'Ya está cerrada';
|
||||||
|
reab.disabled = false; reab.title = '';
|
||||||
|
} else {
|
||||||
|
// Otros estados: permitir ambas acciones
|
||||||
|
cerr.disabled = false; cerr.title = '';
|
||||||
|
reab.disabled = false; 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 (usa /abrir y /cerrar) =====
|
||||||
|
async function accionComanda(accion){ // 'abrir' | 'cerrar'
|
||||||
|
if (!state.sel) return;
|
||||||
|
try {
|
||||||
|
await jpost(`/api/comandas/${state.sel}/${accion}`, {}); // el body no se usa en el backend
|
||||||
|
toast(`Comanda #${state.sel} ${accion === 'abrir' ? 'reabierta' : 'cerrada'}`, true);
|
||||||
|
|
||||||
|
// Recargar lista y re-aplicar cabecera/detalle si sigue visible
|
||||||
|
const id = state.sel;
|
||||||
|
await loadLista();
|
||||||
|
const found = state.lista.find(x => x.id_comanda === id);
|
||||||
|
if (found) {
|
||||||
|
applyHeader(found);
|
||||||
|
await loadDetalle(found.id_comanda);
|
||||||
|
} else {
|
||||||
|
// Puede desaparecer del listado si está activado "Solo abiertas" y la cerramos
|
||||||
|
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 actualizar la comanda');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Hooks con Sidebar (offcanvas) =====
|
||||||
|
window.scRefreshList = loadLista;
|
||||||
|
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(); });
|
||||||
|
|
||||||
|
// Ahora los botones llaman a los nuevos endpoints
|
||||||
|
$('#cerrar').addEventListener('click', () => accionComanda('cerrar'));
|
||||||
|
$('#reabrir').addEventListener('click', () => accionComanda('abrir'));
|
||||||
|
|
||||||
|
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- <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){
|
async function jget(url){
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const data = await res.json().catch(()=>null);
|
const data = await res.json().catch(()=>null);
|
||||||
@ -288,4 +529,4 @@
|
|||||||
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
|
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
|
||||||
|
|
||||||
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
|
||||||
</script>
|
</script> -->
|
||||||
|
|||||||
@ -5,6 +5,14 @@
|
|||||||
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<link rel="icon" href="/favicon/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" href="/favicon/favicon-16x16.png" sizes="16x16">
|
||||||
|
<link rel="icon" href="/favicon/favicon-32x32.png" sizes="32x32">
|
||||||
|
<link rel="icon" href="/favicon/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<link rel="icon" href="/favicon/android-chrome-512x512.png" sizes="512x512">
|
||||||
|
<link rel="icon" href="/favicon/android-chrome-192x192.png" sizes="192x192">
|
||||||
|
<link rel="manifest" href="/favicon/site.webmanifest">
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
:root { --navbar-h: 56px; }
|
:root { --navbar-h: 56px; }
|
||||||
body { padding-top: var(--navbar-h); background: #f7f8fb; }
|
body { padding-top: var(--navbar-h); background: #f7f8fb; }
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user