Creación de sección Usuarios para administrar las entradas y salidas del personal

This commit is contained in:
Mateo Saldain 2025-08-30 04:49:59 +00:00
parent 9c5219863b
commit c9b4b4871d
5 changed files with 1196 additions and 53 deletions

View File

@ -83,7 +83,8 @@ const ALLOWED_TABLES = [
'proveedores','compras','deta_comp_producto', 'proveedores','compras','deta_comp_producto',
'mate_primas','deta_comp_materias', 'mate_primas','deta_comp_materias',
'prov_producto','prov_mate_prima', 'prov_producto','prov_mate_prima',
'receta_producto' 'receta_producto', 'asistencia_resumen_diario',
'asistencia_intervalo'
]; ];
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/; const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
@ -235,6 +236,13 @@ app.get("/productos", (req, res) => {
res.locals.pageId = "productos"; res.locals.pageId = "productos";
res.render("productos"); res.render("productos");
}); });
app.get('/usuarios', (req, res) => {
res.locals.pageTitle = 'Usuarios';
res.locals.pageId = 'usuarios';
res.render('usuarios');
});
// ---------------------------------------------------------- // ----------------------------------------------------------
// API // API
// ---------------------------------------------------------- // ----------------------------------------------------------
@ -578,6 +586,71 @@ app.post('/api/rpc/save_materia', async (req, res) => {
} }
}); });
// POST /api/rpc/find_usuarios_por_documentos { docs: ["12345678","09123456", ...] }
app.post('/api/rpc/find_usuarios_por_documentos', async (req, res) => {
try {
const docs = Array.isArray(req.body?.docs) ? req.body.docs : [];
const sql = 'SELECT public.find_usuarios_por_documentos($1::jsonb) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(docs)]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'find_usuarios_por_documentos failed' });
}
});
// POST /api/rpc/import_asistencia { registros: [...], origen?: "AGL_001.txt" }
app.post('/api/rpc/import_asistencia', async (req, res) => {
try {
const registros = Array.isArray(req.body?.registros) ? req.body.registros : [];
const origen = req.body?.origen || null;
const sql = 'SELECT public.import_asistencia($1::jsonb,$2) AS data';
const { rows } = await pool.query(sql, [JSON.stringify(registros), origen]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e);
res.status(500).json({ error: 'import_asistencia failed' });
}
});
// Consultar datos de asistencia (raw + pares) para un usuario y rango
app.post('/api/rpc/asistencia_get', async (req, res) => {
try {
const { doc, desde, hasta } = req.body || {};
const sql = 'SELECT public.asistencia_get($1::text,$2::date,$3::date) AS data';
const { rows } = await pool.query(sql, [doc, desde, hasta]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_get failed' });
}
});
// Editar un registro crudo y recalcular pares
app.post('/api/rpc/asistencia_update_raw', async (req, res) => {
try {
const { id_raw, fecha, hora, modo } = req.body || {};
const sql = 'SELECT public.asistencia_update_raw($1::bigint,$2::date,$3::text,$4::text) AS data';
const { rows } = await pool.query(sql, [id_raw, fecha, hora, modo ?? null]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_update_raw failed' });
}
});
// Eliminar un registro crudo y recalcular pares
app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
try {
const { id_raw } = req.body || {};
const sql = 'SELECT public.asistencia_delete_raw($1::bigint) AS data';
const { rows } = await pool.query(sql, [id_raw]);
res.json(rows[0]?.data || {});
} catch (e) {
console.error(e); res.status(500).json({ error: 'asistencia_delete_raw failed' });
}
});
// ---------------------------------------------------------- // ----------------------------------------------------------
// Verificación de conexión // Verificación de conexión
// ---------------------------------------------------------- // ----------------------------------------------------------

View File

@ -86,21 +86,58 @@
productos: [], productos: [],
mesas: [], mesas: [],
usuarios: [], usuarios: [],
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}] categorias: [], // <--- NUEVO
carrito: [],
filtro: '' filtro: ''
}; };
function norm(s='') {
return s.toString().toLowerCase()
.normalize('NFD').replace(/\p{Diacritic}/gu,''); // "café" -> "cafe"
}
function isTakeaway(apodo) {
return /^takeaway$/i.test(String(apodo || '').trim());
}
function groupOrderByCatName(catName='') {
const n = norm(catName);
if (n.includes('bar')) return 1;
if (n.includes('cafe')) return 2;
if (n.includes('cafeter')) return 3;
if (n.includes('trago') || n.includes('refresc')) return 4;
return 99; // otros
}
// Genera el HTML del ticket de cocina (80mm aprox) // Genera el HTML del ticket de cocina (80mm aprox)
function buildKitchenTicketHTML(data) { function buildKitchenTicketHTML(data) {
const mesaTxt = `Mesa #${data.mesa_numero ?? '—'}${data.mesa_apodo ? ' · ' + data.mesa_apodo : ''}`; const apodo = String(data.mesa_apodo ?? '').trim();
const numero = data.mesa_numero ?? '';
const take = isTakeaway(apodo);
const mesaTxt = take ? apodo.toUpperCase() : `Mesa #${numero}${apodo ? ' · ' + apodo : ''}`;
// const isTakeaway = /Takeaway/i.test(String(data.mesa_apodo ?? '')) || /Takeaway/i.test(String(data.mesa_numero ?? ''));
const mesaClass = take ? 'bigline' : 'mesa-medium';
const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : ''; const obs = (data.observaciones && data.observaciones.trim()) ? data.observaciones.trim() : '';
const productosHtml = data.productos.map(p => `
// Productos ya vienen con su "g" (grupo numérico 1..4/99) y cantidad formateada
const items = data.productos.slice().sort((a,b)=> (a.g||99) - (b.g||99));
let productosHtml = '';
let prevG = null;
for (const p of items) {
if (prevG !== null && p.g !== prevG) {
productosHtml += `<div class="hr dotted"></div>`; // separación punteada entre grupos
}
productosHtml += `
<div class="row"> <div class="row">
<div class="qty">x${p.cantidad}</div> <div class="qty">x${p.cantidad}</div>
<div class="name">${p.nombre}</div> <div class="name">${p.nombre}</div>
</div> </div>`;
`).join(''); prevG = p.g;
}
return `<!doctype html> return `<!doctype html>
<html> <html>
@ -109,66 +146,54 @@
<title>Ticket Cocina</title> <title>Ticket Cocina</title>
<style> <style>
:root { :root {
--w: 80mm; /* Cambia a 58mm si tu rollo es de 58 */ --w: 80mm;
--fz: 30px; /* Base más grande */ --fz-base: 16px;
--fz-sm: 13px; --fz-md: 16px; /* observaciones */
--fz-lg: 20px; /* Filas de productos */ --fz-item: 18px; /* filas */
--fz-xl: 35px; /* Título */ --fz-xl: 26px; /* <--- NUEVO: tamaño “grande” (mesa) */
--fz-xxl: 34px; /* título (#comanda) */
--fz-sm: 12px;
} }
html, body { margin:0; padding:0; } html, body { margin:0; padding:0; }
body { body {
width: var(--w); width: var(--w);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: var(--fz); font-size: var(--fz-base);
font-weight: 700; /* TODO en negrita */
line-height: 1.35; line-height: 1.35;
color:#000; color:#000;
font-weight: 700;
} }
#ticket { padding: 10px 8px; } #ticket { padding: 10px 8px; }
.center { text-align:center; } .center { text-align:center; }
.row { display:flex; gap:8px; margin: 4px 0; } .row { display:flex; gap:8px; margin: 4px 0; }
.row .qty { .row .qty { min-width: 22mm; font-size: var(--fz-item); letter-spacing:.2px; }
min-width: 24mm; .row .name { flex:1; font-size: var(--fz-item); text-transform: uppercase; word-break: break-word; }
font-size: var(--fz-lg); .hr { border-top: 2px dashed #000; margin: 8px 0; }
letter-spacing: 0.2px; .hr.dotted { border-top: 2px dotted #000; }
}
.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); } .small { font-size: var(--fz-sm); }
.bold { font-weight: 700; } .bigline { font-size: var(--fz-xxl); text-transform: uppercase; }
.mt4 { margin-top: 4px; } .mesa-medium { font-size: var(--fz-xl); text-transform: uppercase; }
.mt8 { margin-top: 8px; } .obs { font-size: var(--fz-md); }
.mb4 { margin-bottom: 4px; } .mt4{margin-top:4px}.mt8{margin-top:8px}.mb4{margin-bottom:4px}.mb8{margin-bottom:8px}
.mb8 { margin-bottom: 8px; }
.title { font-size: var(--fz-xl); letter-spacing: 0.3px; }
@page { size: var(--w) auto; margin: 0; } @page { size: var(--w) auto; margin: 0; }
@media print { body { width: var(--w); } } @media print { body { width: var(--w); } }
</style> </style>
</head> </head>
<body> <body>
<div id="ticket"> <div id="ticket">
<div class="center bold title">COMANDA COCINA</div> <!-- SIN TÍTULO -->
<div class="center small">#${data.id_comanda}</div> <div class="center bigline">#${data.id_comanda}</div>
<div class="hr"></div> <div class="center ${mesaClass}">${mesaTxt}</div>
<div class="small">Fecha: ${data.fecha} ${data.hora}</div> <div class="small mt4">Fecha: ${data.fecha} ${data.hora}</div>
<div class="small">${mesaTxt}</div> <div class="small mt4">Mozo: ${data.usuario || '—'}</div>
<div class="small">Mozo: ${data.usuario || '—'}</div> ${obs ? `<div class="obs mt8">Obs: ${obs}</div>` : ''}
${obs ? `<div class="mt8"><span class="bold">OBSERVACIONES:</span><br>${obs}</div>` : ''}
<div class="hr"></div> <div class="hr"></div>
<div class="bold mb4">PRODUCTOS</div>
${productosHtml} ${productosHtml}
<div class="hr"></div> <div class="hr"></div>
<div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div> <div class="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
<div class="center mt8 small">— fin —</div> <div class="center mt8 small">— fin —</div>
</div> </div>
<script>window.onload = () => { window.focus(); window.print(); }<\/script> <script>window.onload = () => { window.focus(); window.print(); }<\/script>
@ -231,15 +256,21 @@
// Carga inicial // Carga inicial
async function init() { async function init() {
const [prods, mesas, usuarios] = await Promise.all([ const [prods, mesas, usuarios, categorias] = await Promise.all([
jget('/api/table/productos?limit=1000'), jget('/api/table/productos?limit=1000'),
jget('/api/table/mesas?limit=1000'), jget('/api/table/mesas?limit=1000'),
jget('/api/table/usuarios?limit=1000') jget('/api/table/usuarios?limit=1000'),
jget('/api/table/categorias?limit=1000') // <--- NUEVO
]); ]);
state.productos = prods.filter(p => p.activo !== false); state.productos = prods.filter(p => p.activo !== false);
state.mesas = mesas; state.mesas = mesas;
state.usuarios = usuarios.filter(u => u.activo !== false); state.usuarios = usuarios.filter(u => u.activo !== false);
state.categorias = Array.isArray(categorias) ? categorias : [];
// Mapas para buscar categoría por id de producto
state.catById = new Map(state.categorias.map(c => [c.id_categoria, (c.nombre||'').toString()]));
state.prodCatNameById = new Map(state.productos.map(p => [p.id_producto, state.catById.get(p.id_categoria)||'']));
hydrateMesas(); hydrateMesas();
hydrateUsuarios(); hydrateUsuarios();
@ -450,6 +481,15 @@
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0); const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
const items = cartSnapshot.length; const items = cartSnapshot.length;
// map producto -> nombre de categoría
const prodCat = state.prodCatNameById || new Map();
const productosParaTicket = cartSnapshot.map(it => ({
nombre: it.nombre,
cantidad: fmtQty(it.cantidad),
g: groupOrderByCatName(prodCat.get(it.id_producto) || '') // 1..4/99
}));
const ticketHtml = buildKitchenTicketHTML({ const ticketHtml = buildKitchenTicketHTML({
id_comanda, id_comanda,
fecha, hora, fecha, hora,
@ -459,10 +499,7 @@
observaciones, observaciones,
items, items,
units, units,
productos: cartSnapshot.map(it => ({ productos: productosParaTicket // <--- con grupos
nombre: it.nombre,
cantidad: fmtQty(it.cantidad)
}))
}); });
await printHtmlViaIframe(ticketHtml); await printHtmlViaIframe(ticketHtml);

View File

@ -13,8 +13,8 @@
<li class="nav-item"><a class="nav-link" href="/comandas">Comandas</a></li> <li class="nav-item"><a class="nav-link" href="/comandas">Comandas</a></li>
<li class="nav-item"><a class="nav-link" href="/estadoComandas">Estado</a></li> <li class="nav-item"><a class="nav-link" href="/estadoComandas">Estado</a></li>
<li class="nav-item"><a class="nav-link" href="/productos">Productos</a></li> <li class="nav-item"><a class="nav-link" href="/productos">Productos</a></li>
<li class="nav-item"><a class="nav-link" href="/mesas">Mesas</a></li> <li class="nav-item"><a class="nav-link" href="/usuarios">Usuarios</a></li>
<li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li> <!-- <li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li> -->
<!-- agrega las que necesites --> <!-- agrega las que necesites -->
</ul> </ul>

View File

@ -36,6 +36,9 @@
{ text: "Nuevo producto", href: "/productos/nuevo" }, { text: "Nuevo producto", href: "/productos/nuevo" },
{ text: "Importar catálogo", href: "/productos/importar" }, { text: "Importar catálogo", href: "/productos/importar" },
{ text: "Reportes", href: "/reportes" }, { text: "Reportes", href: "/reportes" },
],
"usuarios": [
{ text: "Exportar (CSV)", href: "#", attr: { "data-action": "export-csv" } },
] ]
}; };

File diff suppressed because it is too large Load Diff