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',
'mate_primas','deta_comp_materias',
'prov_producto','prov_mate_prima',
'receta_producto'
'receta_producto', 'asistencia_resumen_diario',
'asistencia_intervalo'
];
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
@ -235,6 +236,13 @@ app.get("/productos", (req, res) => {
res.locals.pageId = "productos";
res.render("productos");
});
app.get('/usuarios', (req, res) => {
res.locals.pageTitle = 'Usuarios';
res.locals.pageId = 'usuarios';
res.render('usuarios');
});
// ----------------------------------------------------------
// 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
// ----------------------------------------------------------

View File

@ -86,21 +86,58 @@
productos: [],
mesas: [],
usuarios: [],
carrito: [], // [{id_producto, nombre, pre_unitario, cantidad}]
categorias: [], // <--- NUEVO
carrito: [],
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)
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 productosHtml = data.productos.map(p => `
<div class="row">
<div class="qty">x${p.cantidad}</div>
<div class="name">${p.nombre}</div>
</div>
`).join('');
// 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="qty">x${p.cantidad}</div>
<div class="name">${p.nombre}</div>
</div>`;
prevG = p.g;
}
return `<!doctype html>
<html>
@ -109,66 +146,54 @@
<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 */
--w: 80mm;
--fz-base: 16px;
--fz-md: 16px; /* observaciones */
--fz-item: 18px; /* filas */
--fz-xl: 26px; /* <--- NUEVO: tamaño “grande” (mesa) */
--fz-xxl: 34px; /* título (#comanda) */
--fz-sm: 12px;
}
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 */
font-size: var(--fz-base);
line-height: 1.35;
color:#000;
font-weight: 700;
}
#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 */
.row .qty { min-width: 22mm; font-size: var(--fz-item); letter-spacing:.2px; }
.row .name { flex:1; font-size: var(--fz-item); text-transform: uppercase; word-break: break-word; }
.hr { border-top: 2px dashed #000; margin: 8px 0; }
.hr.dotted { border-top: 2px dotted #000; }
.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; }
.bigline { font-size: var(--fz-xxl); text-transform: uppercase; }
.mesa-medium { font-size: var(--fz-xl); text-transform: uppercase; }
.obs { font-size: var(--fz-md); }
.mt4{margin-top:4px}.mt8{margin-top:8px}.mb4{margin-bottom:4px}.mb8{margin-bottom:8px}
@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>
<!-- SIN TÍTULO -->
<div class="center bigline">#${data.id_comanda}</div>
<div class="center ${mesaClass}">${mesaTxt}</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="small mt4">Fecha: ${data.fecha} ${data.hora}</div>
<div class="small mt4">Mozo: ${data.usuario || '—'}</div>
${obs ? `<div class="obs mt8">Obs: ${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="small">Ítems: ${data.items} · Unidades: ${data.units}</div>
<div class="center mt8 small">— fin —</div>
</div>
<script>window.onload = () => { window.focus(); window.print(); }<\/script>
@ -231,15 +256,21 @@
// Carga inicial
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/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.mesas = mesas;
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();
hydrateUsuarios();
@ -450,6 +481,15 @@
const units = cartSnapshot.reduce((acc, it) => acc + Number(it.cantidad || 0), 0);
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({
id_comanda,
fecha, hora,
@ -459,10 +499,7 @@
observaciones,
items,
units,
productos: cartSnapshot.map(it => ({
nombre: it.nombre,
cantidad: fmtQty(it.cantidad)
}))
productos: productosParaTicket // <--- con grupos
});
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="/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="/mesas">Mesas</a></li>
<li class="nav-item"><a class="nav-link" href="/reportes">Reportes</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> -->
<!-- agrega las que necesites -->
</ul>

View File

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

File diff suppressed because it is too large Load Diff