Impresión de tickets correcta.

This commit is contained in:
Mateo Saldain 2025-08-29 06:22:10 +00:00
parent 57dbd5b1fa
commit ce3d01a180
13 changed files with 514 additions and 26 deletions

View File

@ -16,7 +16,8 @@
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
"pg-format": "^1.0.4",
"serve-favicon": "^2.5.1"
},
"devDependencies": {
"cross-env": "^10.0.0",
@ -1293,6 +1294,31 @@
"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": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz",

View File

@ -22,7 +22,8 @@
"express": "^5.1.0",
"express-ejs-layouts": "^2.5.1",
"pg": "^8.16.3",
"pg-format": "^1.0.4"
"pg-format": "^1.0.4",
"serve-favicon": "^2.5.1"
},
"keywords": [],
"description": "Workarround para tener un MVP que llegue al verano para usarse"

View File

@ -1,5 +1,6 @@
// app/src/index.js
import chalk from 'chalk'; // Colores!
import favicon from 'serve-favicon'; // Favicon
import express from 'express';
import expressLayouts from 'express-ejs-layouts';
import cors from 'cors';
@ -47,6 +48,14 @@ app.set("layout", "layouts/main");
// Archivos estáticos
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
// ----------------------------------------------------------
@ -57,7 +66,6 @@ const dbConfig = {
database: process.env.DB_NAME,
port: process.env.DB_LOCAL_PORT ? Number(process.env.DB_LOCAL_PORT) : undefined,
ssl: process.env.PGSSL === 'true' ? { rejectUnauthorized: false } : undefined,
max: 10
};
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)
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
// GET /api/comandas/:id/detalle
app.get('/api/comandas/:id/detalle', (req, res, next) =>
pool.query(
`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); }
});
ORDER BY id_det_comanda`,
[req.params.id]
)
.then(r => res.json(r.rows))
.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) => {

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View 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"}

View File

@ -90,6 +90,123 @@
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
const money = (n) => (isNaN(n) ? '—' : new Intl.NumberFormat('es-UY', { style:'currency', currency:'UYU' }).format(Number(n)));
const toast = (msg, ok=false) => {
@ -290,13 +407,17 @@
$('#kpiTotal').textContent = money(total);
}
// Crear comanda
const fmtQty = (n) => Number(n).toFixed(3).replace(/\.?0+$/,'');
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; }
// Snapshot del carrito ANTES de limpiar (para imprimir)
const cartSnapshot = state.carrito.map(it => ({ ...it }));
const observaciones = $('#obs').value.trim() || null;
try {
@ -310,23 +431,90 @@
// 2) detalle
const id_comanda = com.id_comanda;
const payloads = state.carrito.map(it => ({
const payloads = cartSnapshot.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)));
// 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 = [];
renderCarrito();
$('#obs').value = '';
toast(`Comanda #${id_comanda} creada`, true);
toast(`Comanda #${id_comanda} creada e impresa`, true);
} catch (e) {
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
init().catch(err => toast(err.message || 'Error cargando datos'));

View File

@ -80,6 +80,247 @@
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){
const res = await fetch(url);
const data = await res.json().catch(()=>null);
@ -288,4 +529,4 @@
$('#reabrir').addEventListener('click', () => setEstado('abierta'));
(async function main(){ try { await loadLista(); } catch(e){ toast(e.message||'Error cargando comandas'); }})();
</script>
</script> -->

View File

@ -5,6 +5,14 @@
<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>
:root { --navbar-h: 56px; }
body { padding-top: var(--navbar-h); background: #f7f8fb; }