Compare commits
2 Commits
c9b4b4871d
...
25876e733b
| Author | SHA1 | Date | |
|---|---|---|---|
| 25876e733b | |||
| 93ac1db5f1 |
@ -1,4 +1,4 @@
|
||||
# compose.tools.yaml
|
||||
# $ compose.tools.yaml
|
||||
name: suitecoffee_tools
|
||||
|
||||
services:
|
||||
@ -14,7 +14,7 @@ services:
|
||||
- dbeaver_logs:/opt/cloudbeaver/logs
|
||||
- dbeaver_workspace:/opt/cloudbeaver/workspace
|
||||
networks:
|
||||
suitecoffee_prod_net: {}
|
||||
# suitecoffee_prod_net: {}
|
||||
suitecoffee_dev_net: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8978 || exit 1"]
|
||||
@ -37,7 +37,7 @@ services:
|
||||
- npm_data:/data
|
||||
- npm_letsencrypt:/etc/letsencrypt
|
||||
networks:
|
||||
suitecoffee_prod_net: {}
|
||||
# suitecoffee_prod_net: {}
|
||||
suitecoffee_dev_net: {}
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:81 || exit 1"]
|
||||
@ -50,8 +50,8 @@ services:
|
||||
networks:
|
||||
suitecoffee_dev_net:
|
||||
external: true
|
||||
suitecoffee_prod_net:
|
||||
external: true
|
||||
# suitecoffee_prod_net:
|
||||
# external: true
|
||||
|
||||
volumes:
|
||||
npm_data:
|
||||
|
||||
@ -84,7 +84,7 @@ const ALLOWED_TABLES = [
|
||||
'mate_primas','deta_comp_materias',
|
||||
'prov_producto','prov_mate_prima',
|
||||
'receta_producto', 'asistencia_resumen_diario',
|
||||
'asistencia_intervalo'
|
||||
'asistencia_intervalo', 'vw_compras'
|
||||
];
|
||||
|
||||
const VALID_IDENT = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
@ -243,6 +243,18 @@ app.get('/usuarios', (req, res) => {
|
||||
res.render('usuarios');
|
||||
});
|
||||
|
||||
app.get('/reportes', (req, res) => {
|
||||
res.locals.pageTitle = 'Reportes';
|
||||
res.locals.pageId = 'reportes';
|
||||
res.render('reportes');
|
||||
});
|
||||
|
||||
app.get('/compras', (req, res) => {
|
||||
res.locals.pageTitle = 'Compras';
|
||||
res.locals.pageId = 'compras';
|
||||
res.render('compras');
|
||||
});
|
||||
|
||||
// ----------------------------------------------------------
|
||||
// API
|
||||
// ----------------------------------------------------------
|
||||
@ -649,6 +661,152 @@ app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/report_tickets { year }
|
||||
app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
try {
|
||||
const y = parseInt(req.body?.year ?? req.query?.year, 10);
|
||||
const year = (Number.isFinite(y) && y >= 2000 && y <= 2100)
|
||||
? y
|
||||
: (new Date()).getFullYear();
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_tickets_year($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_tickets error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_tickets failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/rpc/report_asistencia { desde: 'YYYY-MM-DD', hasta: 'YYYY-MM-DD' }
|
||||
app.post('/api/rpc/report_asistencia', async (req, res) => {
|
||||
try {
|
||||
let { desde, hasta } = req.body || {};
|
||||
// defaults si vienen vacíos/invalidos
|
||||
const re = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!re.test(desde) || !re.test(hasta)) {
|
||||
const end = new Date();
|
||||
const start = new Date(end); start.setDate(end.getDate()-30);
|
||||
desde = start.toISOString().slice(0,10);
|
||||
hasta = end.toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_asistencia($1::date,$2::date) AS j', [desde, hasta]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_asistencia error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_asistencia failed',
|
||||
message: e.message, detail: e.detail, where: e.where, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// app.post('/api/rpc/report_asistencia', async (req,res)=>{
|
||||
// try{
|
||||
// const {desde, hasta} = req.body||{};
|
||||
// const sql = 'SELECT * FROM public.report_asistencia($1::date,$2::date)';
|
||||
// const {rows} = await pool.query(sql,[desde, hasta]);
|
||||
// res.json(rows);
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'report_tickets failed' + e });
|
||||
// }
|
||||
// });
|
||||
|
||||
// app.post('/api/rpc/report_tickets', async (req, res) => {
|
||||
// try {
|
||||
// const { year } = req.body || {};
|
||||
// const sql = 'SELECT public.report_tickets_year($1::int) AS data';
|
||||
// const { rows } = await pool.query(sql, [year]);
|
||||
// res.json(rows[0]?.data || {});
|
||||
// } catch (e) {
|
||||
// console.error(e);
|
||||
// res.status(500).json({ error: 'report_tickets failed' + e });
|
||||
// }
|
||||
// });
|
||||
|
||||
|
||||
// Guardar (insert/update)
|
||||
app.post('/api/rpc/save_compra', async (req, res) => {
|
||||
try {
|
||||
const { id_compra, id_proveedor, fec_compra, detalles } = req.body || {};
|
||||
const sql = 'SELECT * FROM public.save_compra($1::int,$2::int,$3::timestamptz,$4::jsonb)';
|
||||
const args = [id_compra ?? null, id_proveedor, fec_compra ? new Date(fec_compra) : null, JSON.stringify(detalles)];
|
||||
const { rows } = await pool.query(sql, args);
|
||||
res.json(rows[0]); // { id_compra, total }
|
||||
} catch (e) {
|
||||
console.error('save_compra error:', e);
|
||||
res.status(500).json({ error: 'save_compra failed', message: e.message, detail: e.detail, where: e.where, code: e.code });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Obtener para editar
|
||||
app.post('/api/rpc/get_compra', async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
const sql = `SELECT public.get_compra($1::int) AS data`;
|
||||
const { rows } = await pool.query(sql, [id_compra]);
|
||||
res.json(rows[0]?.data || {});
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'get_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
// Eliminar
|
||||
app.post('/api/rpc/delete_compra', async (req, res) => {
|
||||
try {
|
||||
const { id_compra } = req.body || {};
|
||||
await pool.query(`SELECT public.delete_compra($1::int)`, [id_compra]);
|
||||
res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error(e); res.status(500).json({ error: 'delete_compra failed' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// POST /api/rpc/report_gastos { year: 2025 }
|
||||
app.post('/api/rpc/report_gastos', async (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.body?.year ?? new Date().getFullYear(), 10);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_gastos error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_gastos failed',
|
||||
message: e.message, detail: e.detail, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// (Opcional) GET para probar rápido desde el navegador:
|
||||
// /api/rpc/report_gastos?year=2025
|
||||
app.get('/api/rpc/report_gastos', async (req, res) => {
|
||||
try {
|
||||
const year = parseInt(req.query.year ?? new Date().getFullYear(), 10);
|
||||
const { rows } = await pool.query(
|
||||
'SELECT public.report_gastos($1::int) AS j', [year]
|
||||
);
|
||||
res.json(rows[0].j);
|
||||
} catch (e) {
|
||||
console.error('report_gastos error:', e);
|
||||
res.status(500).json({
|
||||
error: 'report_gastos failed',
|
||||
message: e.message, detail: e.detail, code: e.code
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
||||
361
services/manso/src/views/compras.ejs
Normal file
361
services/manso/src/views/compras.ejs
Normal file
@ -0,0 +1,361 @@
|
||||
<% /* Compras / Gastos */ %>
|
||||
<div class="container-fluid py-3">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h3 class="mb-0">Compras / Gastos</h3>
|
||||
<div class="ms-auto d-flex gap-2">
|
||||
<button id="btnNueva" class="btn btn-outline-secondary btn-sm">Nueva</button>
|
||||
<span id="status" class="small text-muted">—</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Formulario -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-header"><strong id="formTitle">Nueva compra</strong></div>
|
||||
<div class="card-body">
|
||||
<form id="frmCompra" class="row g-3">
|
||||
<input type="hidden" id="id_compra" value="">
|
||||
<div class="col-12 col-md-5">
|
||||
<label class="form-label">Proveedor</label>
|
||||
<select id="id_proveedor" class="form-select" required></select>
|
||||
</div>
|
||||
<div class="col-12 col-md-3">
|
||||
<label class="form-label">Fecha</label>
|
||||
<input id="fec_compra" type="datetime-local" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label class="form-label">Total</label>
|
||||
<input id="total" type="text" class="form-control" value="$ 0" disabled>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-semibold">Renglones</div>
|
||||
<div>
|
||||
<button type="button" id="addRow" class="btn btn-sm btn-outline-primary">Agregar renglón</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle" id="tblDet">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th style="width:110px">Tipo</th>
|
||||
<th>Ítem</th>
|
||||
<th style="width:140px" class="text-end">Cantidad</th>
|
||||
<th style="width:160px" class="text-end">Precio</th>
|
||||
<th style="width:140px" class="text-end">Subtotal</th>
|
||||
<th style="width:60px"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="empty">
|
||||
<td colspan="6" class="p-3 text-muted">Sin renglones</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 d-flex gap-2">
|
||||
<button id="btnGuardar" type="submit" class="btn btn-success">Guardar</button>
|
||||
<button id="btnEliminar" type="button" class="btn btn-outline-danger d-none">Eliminar</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Listado -->
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Compras recientes</strong>
|
||||
<input id="buscar" class="form-control form-control-sm ms-auto" style="max-width:260px" placeholder="Buscar proveedor…">
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0" id="tblCompras">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Proveedor</th>
|
||||
<th>Fecha</th>
|
||||
<th class="text-end">Total</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#tblDet select, #tblDet input { min-height: 34px; }
|
||||
.money { text-align: right; }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
const $ = s => document.querySelector(s);
|
||||
const $$ = (s, root=document) => Array.from(root.querySelectorAll(s));
|
||||
const z2 = n => String(n).padStart(2,'0');
|
||||
const parseNum = v => (typeof v==='number') ? v : Number(String(v).replace(/[^\d.,-]/g,'').replace('.','').replace(',','.')) || 0;
|
||||
|
||||
function fmtMoneyInt(v, mode = 'round') {
|
||||
const n = Number(v || 0);
|
||||
const i = mode === 'trunc' ? Math.trunc(n) : mode === 'floor' ? Math.floor(n) : Math.round(n);
|
||||
return '$ ' + i.toLocaleString('es-UY', { maximumFractionDigits: 0 });
|
||||
}
|
||||
|
||||
const onlyDigits = s => String(s ?? '').replace(/\D+/g, '');
|
||||
|
||||
function wireIntInput(input, onChange) {
|
||||
const sync = () => {
|
||||
const n = Number(onlyDigits(input.value) || '0'); // entero
|
||||
input.dataset.raw = String(n); // guardo valor crudo
|
||||
input.value = n.toLocaleString('es-UY'); // muestro con miles
|
||||
if (onChange) onChange(n);
|
||||
};
|
||||
input.addEventListener('input', () => setTimeout(sync, 0));
|
||||
input.addEventListener('blur', sync);
|
||||
// 1a sync
|
||||
sync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
function getIntInput(input) {
|
||||
const s = input?.dataset?.raw ?? onlyDigits(input?.value);
|
||||
return Number(s || '0');
|
||||
}
|
||||
|
||||
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 jpost(u,b){ const r=await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||r.statusText); return j; }
|
||||
|
||||
// Catálogos
|
||||
let CATS = { prov:[], mat:[], prod:[] };
|
||||
|
||||
async function loadCatalogos(){
|
||||
$('#status').textContent = 'Cargando catálogos…';
|
||||
const [prov, mat, prod] = await Promise.all([
|
||||
jget('/api/table/proveedores?limit=10000'),
|
||||
jget('/api/table/mate_primas?limit=10000'),
|
||||
jget('/api/table/productos?limit=10000')
|
||||
]);
|
||||
CATS.prov = prov||[]; CATS.mat = mat||[]; CATS.prod = prod||[];
|
||||
const sel = $('#id_proveedor'); sel.innerHTML = '<option value="">—</option>' + CATS.prov.map(p=>`<option value="${p.id_proveedor}">${p.raz_social||p.nombre||('Prov#'+p.id_proveedor)}</option>`).join('');
|
||||
$('#status').textContent = 'Listo';
|
||||
}
|
||||
|
||||
// Renglón
|
||||
function addRow(data){
|
||||
const tb = $('#tblDet tbody');
|
||||
tb.querySelector('.empty')?.remove();
|
||||
|
||||
const tr = document.createElement('tr');
|
||||
const tipo = data?.tipo || 'MAT'; // MAT | PROD
|
||||
const id = data?.id || '';
|
||||
const cant = data?.cantidad != null ? data.cantidad : 1;
|
||||
const pu = data?.precio != null ? data.precio : 0;
|
||||
|
||||
tr.innerHTML = `
|
||||
<td>
|
||||
<select class="form-select form-select-sm tipo">
|
||||
<option value="MAT"${tipo==='MAT'?' selected':''}>Materia</option>
|
||||
<option value="PROD"${tipo==='PROD'?' selected':''}>Producto</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select class="form-select form-select-sm item"></select>
|
||||
</td>
|
||||
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
|
||||
class="form-control form-control-sm text-end qty" value="${cant}"></td>
|
||||
<td><input type="text" inputmode="numeric" pattern="[0-9]*"
|
||||
class="form-control form-control-sm text-end price" value="${pu}"></td>
|
||||
<td class="text-end sub">$ 0</td>
|
||||
<td><button type="button" class="btn btn-sm btn-outline-danger del">✕</button></td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
|
||||
// load items segun tipo
|
||||
function fillItems(selTipo, selItem, selectedId){
|
||||
const list = selTipo.value === 'MAT' ? CATS.mat : CATS.prod;
|
||||
selItem.innerHTML = '<option value="">—</option>' + list.map(i => {
|
||||
const id = selTipo.value === 'MAT' ? i.id_mat_prima : i.id_producto;
|
||||
const nm = i.nombre || ('#'+id);
|
||||
return `<option value="${id}">${nm}</option>`;
|
||||
}).join('');
|
||||
if (selectedId) selItem.value = selectedId;
|
||||
}
|
||||
|
||||
const selTipo = tr.querySelector('.tipo');
|
||||
const selItem = tr.querySelector('.item');
|
||||
const qty = tr.querySelector('.qty');
|
||||
const price = tr.querySelector('.price');
|
||||
const subCell = tr.querySelector('.sub');
|
||||
|
||||
selTipo.addEventListener('change', ()=>{ fillItems(selTipo, selItem, null); updateRow(); });
|
||||
[selItem, qty, price].forEach(el => el.addEventListener('input', updateRow));
|
||||
tr.querySelector('.del').addEventListener('click', ()=>{ tr.remove(); recalcTotal(); if (!tb.children.length) tb.innerHTML='<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>'; });
|
||||
|
||||
fillItems(selTipo, selItem, id);
|
||||
function updateRow(){
|
||||
const s = getIntInput(qty) * getIntInput(price);
|
||||
subCell.textContent = fmtMoneyInt(s);
|
||||
recalcTotal();
|
||||
}
|
||||
wireIntInput(qty, updateRow);
|
||||
wireIntInput(price, updateRow);
|
||||
updateRow();
|
||||
}
|
||||
|
||||
function recalcTotal(){
|
||||
let tot = 0;
|
||||
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
|
||||
if (tr.classList.contains('empty')) return;
|
||||
const q = getIntInput(tr.querySelector('.qty'));
|
||||
const p = getIntInput(tr.querySelector('.price'));
|
||||
tot += q * p;
|
||||
});
|
||||
$('#total').value = fmtMoneyInt(tot);
|
||||
return tot;
|
||||
}
|
||||
|
||||
function readFormToPayload(){
|
||||
const id_compra = $('#id_compra').value ? Number($('#id_compra').value) : null;
|
||||
const id_proveedor = Number($('#id_proveedor').value || 0);
|
||||
const fec_compra = $('#fec_compra').value
|
||||
? new Date($('#fec_compra').value).toISOString().slice(0,19).replace('T',' ')
|
||||
: null;
|
||||
|
||||
const det = [];
|
||||
// 👇 OJO: iteramos sobre TODAS las filas reales
|
||||
$('#tblDet tbody').querySelectorAll('tr').forEach(tr=>{
|
||||
if (tr.classList.contains('empty')) return;
|
||||
const tipo = tr.querySelector('.tipo').value; // 'MAT' | 'PROD'
|
||||
const id = Number(tr.querySelector('.item').value||0);
|
||||
const qty = getIntInput(tr.querySelector('.qty')); // entero
|
||||
const pu = getIntInput(tr.querySelector('.price')); // entero
|
||||
if (id && qty>0 && pu>=0) det.push({ tipo, id, cantidad: qty, precio: pu });
|
||||
});
|
||||
|
||||
return { id_compra, id_proveedor, fec_compra, detalles: det };
|
||||
}
|
||||
|
||||
// Guardar / Eliminar
|
||||
async function saveCompra(){
|
||||
const payload = readFormToPayload();
|
||||
if (!payload.id_proveedor) { alert('Seleccioná un proveedor.'); return; }
|
||||
if (!payload.fec_compra) { alert('Indicá la fecha.'); return; }
|
||||
if (!payload.detalles.length){ alert('Agregá al menos un renglón.'); return; }
|
||||
|
||||
$('#btnGuardar').disabled = true; $('#status').textContent = 'Guardando…';
|
||||
try{
|
||||
const res = await jpost('/api/rpc/save_compra', payload);
|
||||
$('#id_compra').value = res.id_compra;
|
||||
$('#btnEliminar').classList.remove('d-none');
|
||||
$('#formTitle').textContent = 'Editar compra #' + res.id_compra;
|
||||
await loadListado();
|
||||
alert('Compra guardada.');
|
||||
}catch(e){
|
||||
alert('Error al guardar: ' + e.message);
|
||||
}finally{
|
||||
$('#btnGuardar').disabled = false; $('#status').textContent = 'Listo';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCompra(){
|
||||
const id = Number($('#id_compra').value||0);
|
||||
if (!id) return;
|
||||
if (!confirm('¿Eliminar compra #' + id + '?')) return;
|
||||
$('#btnEliminar').disabled = true;
|
||||
try{
|
||||
await jpost('/api/rpc/delete_compra', { id_compra: id });
|
||||
nuevaCompra();
|
||||
await loadListado();
|
||||
}catch(e){
|
||||
alert('Error al eliminar: '+e.message);
|
||||
}finally{
|
||||
$('#btnEliminar').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nuevaCompra(){
|
||||
$('#formTitle').textContent = 'Nueva compra';
|
||||
$('#id_compra').value = '';
|
||||
$('#id_proveedor').value = '';
|
||||
$('#fec_compra').value = new Date().toISOString().slice(0,16);
|
||||
$('#total').value = '$ 0';
|
||||
$('#btnEliminar').classList.add('d-none');
|
||||
const tb = $('#tblDet tbody'); tb.innerHTML = '<tr class="empty"><td colspan="6" class="p-3 text-muted">Sin renglones</td></tr>';
|
||||
}
|
||||
|
||||
async function cargarCompra(id){
|
||||
$('#status').textContent = 'Cargando compra…';
|
||||
try{
|
||||
const data = await jpost('/api/rpc/get_compra', { id_compra: id });
|
||||
$('#id_compra').value = data.id_compra;
|
||||
$('#id_proveedor').value = data.id_proveedor;
|
||||
$('#fec_compra').value = (data.fec_compra || '').replace(' ', 'T').slice(0,16);
|
||||
const tb = $('#tblDet tbody'); tb.innerHTML='';
|
||||
(data.detalles||[]).forEach(d => addRow(d));
|
||||
recalcTotal();
|
||||
$('#btnEliminar').classList.remove('d-none');
|
||||
$('#formTitle').textContent = 'Editar compra #' + id;
|
||||
} catch(e){
|
||||
alert('No se pudo cargar: ' + e.message);
|
||||
} finally {
|
||||
$('#status').textContent = 'Listo';
|
||||
}
|
||||
}
|
||||
|
||||
// Listado
|
||||
async function loadListado(){
|
||||
// Recomendado: vista vw_compras (más abajo)
|
||||
const rows = await jget('/api/table/vw_compras?limit=200&order_by=fec_compra%20desc');
|
||||
const tb = $('#tblCompras tbody');
|
||||
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="5" class="p-3 text-muted">Sin datos</td></tr>'; return; }
|
||||
tb.innerHTML = '';
|
||||
rows.forEach(r=>{
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.id_compra}</td>
|
||||
<td>${r.proveedor}</td>
|
||||
<td>${(r.fec_compra||'').replace('T',' ').slice(0,16)}</td>
|
||||
<td class="text-end">${fmtMoneyInt(r.total)}</td>
|
||||
<td class="text-end"><button class="btn btn-sm btn-outline-primary ver" data-id="${r.id_compra}">Ver/Editar</button></td>`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
|
||||
$('#buscar').addEventListener('input', (e)=>{
|
||||
const q = e.target.value.toLowerCase();
|
||||
tb.querySelectorAll('tr').forEach(tr=>{
|
||||
const prov = tr.children[1]?.textContent.toLowerCase() || '';
|
||||
tr.style.display = prov.includes(q) ? '' : 'none';
|
||||
});
|
||||
});
|
||||
|
||||
tb.addEventListener('click', (ev)=>{
|
||||
const btn = ev.target.closest('button.ver');
|
||||
if (!btn) return;
|
||||
const id = Number(btn.dataset.id);
|
||||
cargarCompra(id);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
});
|
||||
}
|
||||
|
||||
// Eventos
|
||||
document.getElementById('addRow').addEventListener('click', ()=> addRow());
|
||||
document.getElementById('frmCompra').addEventListener('submit', (ev)=>{ ev.preventDefault(); saveCompra(); });
|
||||
document.getElementById('btnEliminar').addEventListener('click', deleteCompra);
|
||||
document.getElementById('btnNueva').addEventListener('click', nuevaCompra);
|
||||
|
||||
// Init
|
||||
(async function init(){
|
||||
await loadCatalogos();
|
||||
nuevaCompra();
|
||||
await loadListado();
|
||||
})();
|
||||
</script>
|
||||
@ -14,6 +14,8 @@
|
||||
<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="/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="/compras">Compras</a></li>
|
||||
<!-- <li class="nav-item"><a class="nav-link" href="/reportes">Reportes</a></li> -->
|
||||
<!-- agrega las que necesites -->
|
||||
</ul>
|
||||
|
||||
836
services/manso/src/views/reportes.ejs
Normal file
836
services/manso/src/views/reportes.ejs
Normal file
@ -0,0 +1,836 @@
|
||||
<% /* Reportes - Asistencias, Tickets y Gastos */ %>
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h3 class="mb-0">Reportes</h3>
|
||||
<span class="ms-auto small text-muted" id="repStatus">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label mb-1">Asistencias · Rango</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-4"><input id="asistDesde" type="date" class="form-control"></div>
|
||||
<div class="col-6 col-md-4"><input id="asistHasta" type="date" class="form-control"></div>
|
||||
<div class="col-12 col-md-4 d-grid d-md-block">
|
||||
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
|
||||
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
|
||||
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label mb-1">Año (Tickets / Gastos)</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-4"><input id="anualYear" type="number" min="2000" step="1" class="form-control"></div>
|
||||
<div class="col-6 col-md-8 d-grid d-md-block">
|
||||
<button id="btnAnualCargar" class="btn btn-primary me-2">Cargar</button>
|
||||
<button id="btnAnualExcel" class="btn btn-outline-success me-2">Excel (Comparativo)</button>
|
||||
<button id="btnAnualPDF" class="btn btn-outline-secondary">PDF (Comparativo)</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mt-2">
|
||||
Los Excel se generan como CSV. Los PDF se generan con “Imprimir área” del navegador.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ASISTENCIA: Resumen diario (últimos 30 días) -->
|
||||
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Asistencia — Resumen diario (últimos 30 días)</strong>
|
||||
<span class="ms-auto small text-muted" id="resumeCount">Cargando…</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="resumenCards" class="row g-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Tickets -->
|
||||
<div class="card shadow-sm mb-3" id="PRINT_TICKETS">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Ventas (Tickets)</strong>
|
||||
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="emp-card p-3 border rounded">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-semibold">Resumen del año</div>
|
||||
<div class="small text-muted" id="ticketsYearTitle">—</div>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-4"><div class="small text-muted">Tickets YTD</div><div class="fs-5 fw-semibold" id="tYtd">—</div></div>
|
||||
<div class="col-4"><div class="small text-muted">Promedio</div><div class="fs-5 fw-semibold" id="tAvg">—</div></div>
|
||||
<div class="col-4"><div class="small text-muted">Ingresos YTD</div><div class="fs-5 fw-semibold" id="tToDate">—</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="emp-card p-3 border rounded">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-semibold">Tickets por mes</div>
|
||||
<div class="small text-muted">Cantidad</div>
|
||||
</div>
|
||||
<div id="ticketsChart" style="height:140px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr><th>Mes</th><th class="text-end">Tickets</th><th class="text-end">Importe</th><th class="text-end">Ticket promedio</th></tr>
|
||||
</thead>
|
||||
<tbody id="tbTickets"><tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gastos detallados (filtrable por mes/año) -->
|
||||
<div class="card shadow-sm mb-3" id="PRINT_GASTOS_DET">
|
||||
<div class="card-header">
|
||||
<div class="row g-2 align-items-center">
|
||||
<div class="col-12 col-lg-4 d-flex align-items-center">
|
||||
<strong class="me-2">Gastos detallados — </strong>
|
||||
<span id="gdetTitle" class="text-muted small">mes anterior</span>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="row g-2">
|
||||
<div class="col-7">
|
||||
<select id="gdetMes" class="form-select">
|
||||
<option value="1">Enero</option>
|
||||
<option value="2">Febrero</option>
|
||||
<option value="3">Marzo</option>
|
||||
<option value="4">Abril</option>
|
||||
<option value="5">Mayo</option>
|
||||
<option value="6">Junio</option>
|
||||
<option value="7">Julio</option>
|
||||
<option value="8">Agosto</option>
|
||||
<option value="9">Setiembre</option>
|
||||
<option value="10">Octubre</option>
|
||||
<option value="11">Noviembre</option>
|
||||
<option value="12">Diciembre</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-5">
|
||||
<input id="gdetAnio" type="number" min="2000" step="1" class="form-control" placeholder="Año">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4 d-grid d-md-block text-lg-end">
|
||||
<button id="btnGdetCargar" class="btn btn-primary btn-sm me-2">Cargar</button>
|
||||
<button id="btnGdetExcel" class="btn btn-outline-success btn-sm me-2">Excel</button>
|
||||
<button id="btnGdetPDF" class="btn btn-outline-secondary btn-sm">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-2">
|
||||
<div class="col-12">
|
||||
<div class="d-flex gap-2 flex-wrap">
|
||||
<span class="badge bg-primary-subtle border text-primary">Total: <span id="gdetTotal">—</span></span>
|
||||
<span class="badge bg-secondary-subtle border text-secondary">Compras: <span id="gdetCompras">—</span></span>
|
||||
<span class="badge bg-info-subtle border text-info">Renglones: <span id="gdetRows">—</span></span>
|
||||
<span class="badge bg-light border text-muted ms-auto" id="gdetInfo">—</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive table-scroll" id="gdetScroll">
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Fecha</th>
|
||||
<th>Proveedor</th>
|
||||
<th>Tipo</th>
|
||||
<th>Ítem</th>
|
||||
<th class="text-end">Cantidad</th>
|
||||
<th class="text-end">Precio</th>
|
||||
<th class="text-end">Subtotal</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbGdet">
|
||||
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Comparativo Ventas vs Gastos -->
|
||||
<div class="card shadow-sm" id="PRINT_COMP">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Comparativo: Ventas vs Gastos</strong>
|
||||
<span class="ms-auto small text-muted" id="compInfo">—</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="emp-card p-3 border rounded">
|
||||
<div class="row text-center">
|
||||
<div class="col-4"><div class="small text-muted">Ingresos YTD</div><div class="fs-5 fw-semibold" id="cmpSales">—</div></div>
|
||||
<div class="col-4"><div class="small text-muted">Gastos YTD</div><div class="fs-5 fw-semibold" id="cmpCost">—</div></div>
|
||||
<div class="col-4"><div class="small text-muted">Resultado</div><div class="fs-5 fw-semibold" id="cmpDiff">—</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="emp-card p-3 border rounded">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-semibold">Mensual (UYU)</div>
|
||||
<div class="small text-muted" id="compYearTitle">—</div>
|
||||
</div>
|
||||
<div id="compChart" style="height:160px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr><th>Mes</th><th class="text-end">Ingresos</th><th class="text-end">Gastos</th><th class="text-end">Resultado</th></tr>
|
||||
</thead>
|
||||
<tbody id="tbComp"><tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex gap-2 mt-2">
|
||||
<button id="btnCompExcel" class="btn btn-outline-success btn-sm">Excel</button>
|
||||
<button id="btnCompPDF" class="btn btn-outline-secondary btn-sm">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spark rect:hover { filter: brightness(0.9); }
|
||||
.emp-card { border:1px solid #e9ecef; border-radius:.75rem; padding:12px; }
|
||||
.emp-meta .badge { background:#f8f9fa; color:#212529; border:1px solid #e9ecef; }
|
||||
.spark-wrap { width:100%; height:80px; }
|
||||
.spark { width:100%; height:100%; }
|
||||
.spark text { font-size:10px; fill:#6c757d; }
|
||||
.spark rect:hover { filter: brightness(.9); }
|
||||
@media print {
|
||||
body * { visibility: hidden !important; }
|
||||
#PRINT_ASIST, #PRINT_ASIST *,
|
||||
#PRINT_TICKETS, #PRINT_TICKETS *,
|
||||
#PRINT_GASTOS_DET, #PRINT_GASTOS_DET *,
|
||||
#PRINT_COMP, #PRINT_COMP * { visibility: visible !important; }
|
||||
#PRINT_ASIST, #PRINT_TICKETS, #PRINT_GASTOS_DET, #PRINT_COMP { position:absolute; left:0; top:0; width:100%; }
|
||||
}
|
||||
#PRINT_GASTOS_DET { --gdet-h: 48vh; } /* ~mitad de la pantalla */
|
||||
@media (min-width: 992px){ #PRINT_GASTOS_DET { --gdet-h: 420px; } } /* desktop fijo */
|
||||
|
||||
/* Scroll vertical con encabezado fijo */
|
||||
#PRINT_GASTOS_DET .table-scroll{
|
||||
max-height: var(--gdet-h);
|
||||
overflow: auto; /* vertical + horizontal si hace falta */
|
||||
}
|
||||
#PRINT_GASTOS_DET .table-scroll thead th{
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background: var(--bs-table-bg, #fff);
|
||||
}
|
||||
#PRINT_GASTOS_DET .table-scroll tbody tr:last-child td{
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
/* Al imprimir, expandir todo (sin scroll) */
|
||||
@media print{
|
||||
#PRINT_GASTOS_DET .table-scroll{
|
||||
max-height: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/* ===== Helpers ===== */
|
||||
|
||||
const $ = s => document.querySelector(s);
|
||||
const z2 = n => String(n).padStart(2,'0');
|
||||
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
|
||||
const fmtInt = v => Math.round(Number(v||0));
|
||||
const fmtHM = mins => { const h=Math.floor(mins/60); const m=Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
|
||||
const ymd = s => String(s||'').slice(0,10);
|
||||
const monthNames = ['Ene','Feb','Mar','Abr','May','Jun','Jul','Ago','Sep','Oct','Nov','Dic'];
|
||||
const MONTH_NAMES = ['Enero','Febrero','Marzo','Abril','Mayo','Junio','Julio','Agosto','Setiembre','Octubre','Noviembre','Diciembre'];
|
||||
|
||||
async function jget(url){ const r=await fetch(url); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||`${r.status}`); return j; }
|
||||
async function jpost(url, body){ const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}); const j=await r.json().catch(()=>null); if(!r.ok) throw new Error(j?.error||`${r.status}`); return j; }
|
||||
|
||||
|
||||
function getSelectedMonthYear() {
|
||||
const m = parseInt(document.querySelector('#gdetMes')?.value, 10);
|
||||
const y = parseInt(document.querySelector('#gdetAnio')?.value, 10);
|
||||
const now = new Date();
|
||||
const month = (Number.isFinite(m) && m>=1 && m<=12) ? m : (now.getMonth()+1);
|
||||
const year = (Number.isFinite(y) && y>=2000 && y<=2100) ? y : now.getFullYear();
|
||||
return {month, year};
|
||||
}
|
||||
|
||||
function monthRange(month, year) {
|
||||
// month: 1..12
|
||||
const from = new Date(year, month-1, 1, 0,0,0,0);
|
||||
const to = new Date(year, month, 0, 23,59,59,999);
|
||||
return {
|
||||
desdeISO: from.toISOString(),
|
||||
hastaISO: to.toISOString(),
|
||||
titulo: `${MONTH_NAMES[month-1]} ${year}`,
|
||||
spanTxt: `${from.toLocaleDateString('es-UY')} - ${to.toLocaleDateString('es-UY')}`
|
||||
};
|
||||
}
|
||||
|
||||
function toCSV(rows, headers){
|
||||
const esc = v => v==null? '' : (typeof v==='number'? String(v) : /[",\n]/.test(String(v)) ? `"${String(v).replace(/"/g,'""')}"` : String(v));
|
||||
const cols = headers && headers.length? headers : Object.keys(rows?.[0]||{});
|
||||
const out = []; if(headers) out.push(cols.join(','));
|
||||
for(const r of (rows||[])) out.push(cols.map(c=>esc(r[c])).join(','));
|
||||
return out.join('\r\n');
|
||||
}
|
||||
function downloadText(name, text){ const blob=new Blob([text],{type:'text/csv;charset=utf-8;'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=name; a.click(); URL.revokeObjectURL(a.href); }
|
||||
function printArea(){ window.print(); }
|
||||
|
||||
/* === Mini SVGs === */
|
||||
function barsSVG(series){ // [{label, value}]
|
||||
const W=560,H=120,P=10,G=6;
|
||||
const n=series.length||1, max=Math.max(1,...series.map(d=>Number(d.value||0)));
|
||||
const bw=Math.max(6,Math.floor((W-P*2-G*(n-1))/n)); let x=P, bars='';
|
||||
series.forEach(d=>{ const vh=Math.round((Number(d.value||0)/max)*(H-P-26)); const y=H-20-vh;
|
||||
bars+=`<g><rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" fill="#0d6efd"><title>${d.label} · ${fmtMoney(d.value)}</title></rect><text x="${x+bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text></g>`; x+=bw+G; });
|
||||
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none"><line x1="${P}" y1="${H-20}" x2="${W-P}" y2="${H-20}" stroke="#adb5bd"/></svg>` .replace('</svg>', bars+'</svg>');
|
||||
}
|
||||
function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}]
|
||||
const W=560,H=160,P=10,G=8,PAIR=2,INNER=3;
|
||||
const n=a.length, max=Math.max(1,...a.map(d=>Number(d.value||0)),...b.map(d=>Number(d.value||0)));
|
||||
const bw=Math.max(5,Math.floor((W-P*2-G*(n-1))/(n*PAIR)));
|
||||
let x=P, g=''; for(let i=0;i<n;i++){
|
||||
const av=Number(a[i].value||0), bv=Number(b[i].value||0);
|
||||
const ah=Math.round((av/max)*(H-P-26)), bh=Math.round((bv/max)*(H-P-26));
|
||||
const ay=H-20-ah, by=H-20-bh;
|
||||
g+=`<g><rect x="${x}" y="${ay}" width="${bw}" height="${ah}" rx="3" ry="3" fill="#198754"><title>${a[i].label} · Ventas ${fmtMoney(av)}</title></rect></g>`;
|
||||
x+=bw+INNER;
|
||||
g+=`<g><rect x="${x}" y="${by}" width="${bw}" height="${bh}" rx="3" ry="3" fill="#dc3545"><title>${b[i].label} · Gastos ${fmtMoney(bv)}</title></rect><text x="${x-bw/2}" y="${H-6}" text-anchor="middle">${a[i].label}</text></g>`;
|
||||
x+=bw+G;
|
||||
}
|
||||
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none"><line x1="${P}" y1="${H-20}" x2="${W-P}" y2="${H-20}" stroke="#adb5bd"/></svg>` .replace('</svg>', g+'</svg>');
|
||||
}
|
||||
|
||||
/* ===== Asistencias ===== */
|
||||
let cacheAsist=[];
|
||||
|
||||
async function fetchAsistencias(desde,hasta){
|
||||
try { return await jpost('/api/rpc/report_asistencia', { desde, hasta }); }
|
||||
catch { const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`; return await jget(url); }
|
||||
}
|
||||
function renderAsistTabla(rows){
|
||||
const tb=$('#tbAsist'); if(!rows?.length){ tb.innerHTML='<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
|
||||
tb.innerHTML=''; rows.forEach(r=>{ const tr=document.createElement('tr'); tr.innerHTML=`
|
||||
<td>${r.documento||'—'}</td><td>${r.nombre||'—'}</td><td>${r.apellido||'—'}</td><td>${r.fecha||'—'}</td>
|
||||
<td class="text-end">${r.desde_hora||'—'}</td><td class="text-end">${r.hasta_hora||'—'}</td>
|
||||
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>`; tb.appendChild(tr); });
|
||||
}
|
||||
|
||||
async function loadAsist(){
|
||||
let d = $('#asistDesde')?.value;
|
||||
let h = $('#asistHasta')?.value;
|
||||
|
||||
// fallback: últimos 30 días
|
||||
if (!d || !h){
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate() - 30);
|
||||
d = start.toISOString().slice(0,10);
|
||||
h = end.toISOString().slice(0,10);
|
||||
if ($('#asistDesde')) $('#asistDesde').value = d;
|
||||
if ($('#asistHasta')) $('#asistHasta').value = h;
|
||||
}
|
||||
|
||||
$('#repStatus').textContent = 'Cargando asistencias…';
|
||||
cacheAsist = await jpost('/api/rpc/report_asistencia', { desde: d, hasta: h })
|
||||
.catch(async ()=>{
|
||||
// fallback a tabla genérica si el RPC no está
|
||||
const url=`/api/table/asistencia_detalle?desde=${encodeURIComponent(d)}&hasta=${encodeURIComponent(h)}&limit=10000`;
|
||||
return await jget(url);
|
||||
});
|
||||
|
||||
renderAsistTabla(cacheAsist||[]);
|
||||
const minsTot = (cacheAsist||[]).reduce((s,r)=>s+Number(r.dur_min||0),0);
|
||||
$('#asistInfo').textContent = `${cacheAsist?.length||0} registros · ${fmtHM(minsTot)}`;
|
||||
$('#repStatus').textContent = 'Listo';
|
||||
}
|
||||
|
||||
|
||||
function asistBarsSVG(series /* [{x:'YYYY-MM-DD', h:Number}] */, maxH = null) {
|
||||
const W=520, H=80, PAD=6, GAP=3;
|
||||
const n = series.length || 1;
|
||||
const max = maxH ?? Math.max(1, ...series.map(d => d.h || 0));
|
||||
const bw = Math.max(2, Math.floor((W - PAD*2 - GAP*(n-1)) / n));
|
||||
let x = PAD, bars = '';
|
||||
series.forEach(d => {
|
||||
const vh = max ? Math.round((d.h / max) * (H - PAD*2)) : 0;
|
||||
const y = H - PAD - vh;
|
||||
const label = `${d.x} · ${fmtHM((d.h||0)*60)}`;
|
||||
bars += `<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="2" ry="2" data-x="${d.x}" data-h="${d.h??0}"><title>${label}</title></rect>`;
|
||||
x += bw + GAP;
|
||||
});
|
||||
const axis = `<line x1="${PAD}" y1="${H-PAD}" x2="${W-PAD}" y2="${H-PAD}" stroke="#adb5bd" stroke-width="1"/>`;
|
||||
return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" class="spark"><style>rect{fill:#0d6efd}</style>${axis}${bars}</svg>`;
|
||||
}
|
||||
|
||||
// Render de tarjetas por empleado (idéntico a usuarios.ejs)
|
||||
function asistRenderCards(grouped) {
|
||||
const cont = $('#resumenCards');
|
||||
if (!cont) return;
|
||||
cont.innerHTML = '';
|
||||
for (const [key, data] of grouped.entries()) {
|
||||
const { doc, nombre, apellido, rows } = data;
|
||||
rows.sort((a,b)=> a.fecha.localeCompare(b.fecha));
|
||||
const series = rows.map(r => ({ x: r.fecha, h: Number(r.horas)||0 }));
|
||||
const totalH = series.reduce((s,d)=> s + d.h, 0);
|
||||
const dias = series.length;
|
||||
const avgH = dias ? totalH / dias : 0;
|
||||
const pairs = rows.reduce((s,r)=> s + (Number(r.pares)||0), 0);
|
||||
const last = series.at(-1) || {x:'',h:0};
|
||||
const svg = asistBarsSVG(series);
|
||||
|
||||
const col = document.createElement('div');
|
||||
col.className = 'col-12 col-md-6 col-lg-4';
|
||||
col.innerHTML = `
|
||||
<div class="emp-card h-100">
|
||||
<div class="d-flex justify-content-between align-items-start mb-2">
|
||||
<div>
|
||||
<div class="fw-semibold">${nombre||''} ${apellido||''}</div>
|
||||
<div class="text-muted small">${doc}</div>
|
||||
</div>
|
||||
<div class="text-end">
|
||||
<div class="small text-muted">Total</div>
|
||||
<div class="fs-5 fw-semibold">${fmtHM(totalH*60)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="spark-wrap mb-1">${svg}</div>
|
||||
<div class="small text-muted legend">Pasá el mouse por una barra…</div>
|
||||
<div class="d-flex flex-wrap gap-1 emp-meta mt-2">
|
||||
<span class="badge">Días: ${dias}</span>
|
||||
<span class="badge">Prom: ${fmtHM(avgH*60)}</span>
|
||||
<span class="badge">Pares: ${pairs}</span>
|
||||
<span class="badge">Último: ${fmtHM((last.h||0)*60)} ${last.x?`(${last.x})`:''}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
cont.appendChild(col);
|
||||
}
|
||||
const badge = $('#resumeCount'); if (badge) badge.textContent = `${grouped.size} empleado(s)`;
|
||||
}
|
||||
|
||||
// Leyenda al sobrevolar barras
|
||||
const cardsRoot = $('#resumenCards');
|
||||
if (cardsRoot){
|
||||
cardsRoot.addEventListener('mouseover', (e)=>{
|
||||
const r = e.target;
|
||||
if (!(r instanceof SVGRectElement)) return;
|
||||
const card = r.closest('.emp-card');
|
||||
const legend = card?.querySelector('.legend');
|
||||
if (!legend) return;
|
||||
const x = r.getAttribute('data-x')||'';
|
||||
const h = Number(r.getAttribute('data-h')||0);
|
||||
legend.textContent = `${x} · ${fmtHM(h*60)}`;
|
||||
});
|
||||
}
|
||||
|
||||
// Loader: trae la vista asistencia_resumen_diario y arma tarjetas (30 días)
|
||||
async function asistLoadResumenDiario30d(){
|
||||
const badge = $('#resumeCount'); if (badge) badge.textContent = 'Cargando…';
|
||||
try{
|
||||
const rows = await jget('/api/table/asistencia_resumen_diario?limit=5000').catch(()=>[]);
|
||||
const today = new Date(); const cut = new Date(today); cut.setDate(today.getDate()-30);
|
||||
const byKey = new Map();
|
||||
for (const r of (rows||[])) {
|
||||
const fStr = ymd(r.fecha); const fDt = new Date(fStr);
|
||||
if (!(fDt >= cut)) continue;
|
||||
const key = `${r.documento}::${r.nombre||''}::${r.apellido||''}`;
|
||||
if (!byKey.has(key)) byKey.set(key, { doc:r.documento, nombre:r.nombre||'', apellido:r.apellido||'', rows:[] });
|
||||
byKey.get(key).rows.push({
|
||||
fecha: fStr,
|
||||
horas: Number(r.horas_dia ?? r.horas ?? (r.minutos_dia||0)/60),
|
||||
pares: Number(r.pares_dia ?? r.pares ?? 0)
|
||||
});
|
||||
}
|
||||
asistRenderCards(byKey);
|
||||
if (badge) badge.textContent = 'Listo';
|
||||
}catch(e){
|
||||
if (badge) badge.textContent = 'Error';
|
||||
console.error('asistLoadResumenDiario30d:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-carga al abrir reportes (si la card está en el DOM)
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
if (document.getElementById('PRINT_ASIST')) {
|
||||
asistLoadResumenDiario30d();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
/* ===== Tickets (ventas) ===== */
|
||||
let cacheTickets=null;
|
||||
|
||||
function getYearSafe(val){
|
||||
const y = parseInt(val, 10);
|
||||
return Number.isFinite(y) && y >= 2000 && y <= 2100
|
||||
? y
|
||||
: new Date().getFullYear();
|
||||
}
|
||||
|
||||
async function fetchTickets(year){
|
||||
const y = getYearSafe(year);
|
||||
return await jpost('/api/rpc/report_tickets', { year: y });
|
||||
}
|
||||
|
||||
function renderTickets(data){
|
||||
const months=data?.months||[]; $('#ticketsYearTitle').textContent=data?.year||'—';
|
||||
$('#tYtd').textContent=months.reduce((s,m)=>s+Number(m.cant||0),0);
|
||||
$('#tAvg').textContent=fmtMoney(data?.avg||data?.avg_ticket||0);
|
||||
$('#tToDate').textContent=fmtMoney(data?.to_date||0);
|
||||
$('#ticketsChart').innerHTML=barsSVG(months.map(m=>({label:m.nombre||m.mes,value:Number(m.cant||0)})));
|
||||
const tb=$('#tbTickets'); if(!months.length){ tb.innerHTML='<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; } else {
|
||||
tb.innerHTML=''; months.forEach(m=>{ const tr=document.createElement('tr'); tr.innerHTML=`
|
||||
<td>${m.nombre||m.mes}</td><td class="text-end">${m.cant||0}</td>
|
||||
<td class="text-end">${fmtMoney(m.importe||0)}</td><td class="text-end">${fmtMoney(m.avg||0)}</td>`; tb.appendChild(tr); });
|
||||
}
|
||||
$('#ticketsInfo').textContent=`${months.length} meses`;
|
||||
}
|
||||
|
||||
/* ===== Gastos ===== */
|
||||
let cacheGastos=null; // {year, months:[{mes,nombre,importe}], total, avg}
|
||||
async function fetchGastos(year){
|
||||
// 1) Intentar RPC
|
||||
try { return await jpost('/api/rpc/report_gastos', { year }); } catch {}
|
||||
// 2) Fallback: traer compras y agrupar en el cliente
|
||||
const rows = await jget('/api/table/compras?limit=10000&order_by=fec_compra%20asc').catch(()=>[]);
|
||||
const months = Array.from({length:12},(_,i)=>({mes:i+1,nombre:monthNames[i],importe:0}));
|
||||
let total=0;
|
||||
(rows||[]).forEach(r=>{
|
||||
const d=new Date(r.fec_compra||r.fec||r.fecha); if(!d.getFullYear) return;
|
||||
if (d.getFullYear() !== Number(year)) return;
|
||||
const m=d.getMonth(); const t=Number(r.total||0);
|
||||
months[m].importe += t; total += t;
|
||||
});
|
||||
const avg = months.reduce((s,m)=>s+m.importe,0)/12;
|
||||
return { year, months, total, avg };
|
||||
}
|
||||
|
||||
function renderGastos(data){
|
||||
// siempre cacheo para el comparativo
|
||||
cacheGastos = data || { year: new Date().getFullYear(), months: [], total: 0, avg: 0 };
|
||||
|
||||
const months = cacheGastos.months || [];
|
||||
|
||||
// elementos de la antigua card (pueden NO existir)
|
||||
const yTitle = document.querySelector('#gastosYearTitle');
|
||||
const toDate = document.querySelector('#gToDate');
|
||||
const avgEl = document.querySelector('#gAvg');
|
||||
const chart = document.querySelector('#gastosChart');
|
||||
const tb = document.querySelector('#tbGastos');
|
||||
const info = document.querySelector('#gastosInfo');
|
||||
|
||||
// si NO existe ninguno, significa que ya no está la card de Gastos ⇒ solo mantener cache y salir
|
||||
if (!yTitle && !toDate && !avgEl && !chart && !tb && !info) return;
|
||||
|
||||
// a partir de acá, escribir solo si el elemento existe
|
||||
if (yTitle) yTitle.textContent = cacheGastos.year ?? '—';
|
||||
if (toDate) toDate.textContent = fmtMoney(cacheGastos.total || 0);
|
||||
if (avgEl) avgEl.textContent = fmtMoney(cacheGastos.avg || 0);
|
||||
|
||||
if (chart) {
|
||||
chart.innerHTML = barsSVG(months.map(m => ({
|
||||
label: m.nombre || m.mes, value: Number(m.importe || 0)
|
||||
})));
|
||||
}
|
||||
|
||||
if (tb) {
|
||||
if (!months.length) {
|
||||
tb.innerHTML = '<tr><td colspan="2" class="p-3 text-muted">Sin datos</td></tr>';
|
||||
} else {
|
||||
tb.innerHTML = '';
|
||||
months.forEach(m => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${m.nombre || m.mes}</td>
|
||||
<td class="text-end">${fmtMoney(m.importe || 0)}</td>`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (info) info.textContent = `${months.length} meses`;
|
||||
}
|
||||
|
||||
/* ===== Comparativo ===== */
|
||||
function renderComparativo(){
|
||||
if(!cacheTickets?.months || !cacheGastos?.months) return;
|
||||
const y = cacheTickets.year || cacheGastos.year; $('#compYearTitle').textContent=y;
|
||||
const ventas = Array.from({length:12},(_,i)=>Number(cacheTickets.months.find(m=>(m.mes||monthNames.indexOf(m.nombre)+1)===i+1)?.importe||0));
|
||||
const gastos = Array.from({length:12},(_,i)=>Number(cacheGastos.months[i]?.importe||0));
|
||||
const seriesA = ventas.map((v,i)=>({label:monthNames[i], value:v}));
|
||||
const seriesB = gastos.map((v,i)=>({label:monthNames[i], value:v}));
|
||||
$('#compChart').innerHTML = barsCompareSVG(seriesA, seriesB);
|
||||
|
||||
const tb=$('#tbComp'); tb.innerHTML='';
|
||||
let ySales=0,yCost=0;
|
||||
for(let i=0;i<12;i++){
|
||||
const s=ventas[i]||0, g=gastos[i]||0, d=s-g; ySales+=s; yCost+=g;
|
||||
const tr=document.createElement('tr'); tr.innerHTML=`
|
||||
<td>${monthNames[i]}</td>
|
||||
<td class="text-end">${fmtMoney(s)}</td>
|
||||
<td class="text-end">${fmtMoney(g)}</td>
|
||||
<td class="text-end ${d>=0?'text-success':'text-danger'}">${fmtMoney(d)}</td>`;
|
||||
tb.appendChild(tr);
|
||||
}
|
||||
$('#cmpSales').textContent = fmtMoney(ySales);
|
||||
$('#cmpCost').textContent = fmtMoney(yCost);
|
||||
$('#cmpDiff').textContent = fmtMoney(ySales - yCost);
|
||||
$('#compInfo').textContent = '12 meses';
|
||||
}
|
||||
|
||||
/* ===== Exportaciones ===== */
|
||||
function exportAsistCSV(){
|
||||
if(!cacheAsist?.length) return;
|
||||
const headers=['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
|
||||
const rows = cacheAsist.map(r=>({Documento:r.documento||'',Nombre:r.nombre||'',Apellido:r.apellido||'',Fecha:r.fecha||'',Desde:r.desde_hora||'',Hasta:r.hasta_hora||'','Duración(min)':Number(r.dur_min||0)}));
|
||||
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, toCSV(rows,headers));
|
||||
}
|
||||
function exportTicketsCSV(){
|
||||
if(!cacheTickets?.months?.length) return;
|
||||
const toInt=v=>fmtInt(v);
|
||||
const headers=['Año','Mes','Tickets','Importe','Ticket promedio'];
|
||||
const rows=cacheTickets.months.map(m=>({'Año':cacheTickets.year,'Mes':m.nombre||m.mes,'Tickets':Number(m.cant||0),'Importe':toInt(m.importe),'Ticket promedio':toInt(m.avg)}));
|
||||
downloadText(`tickets_${cacheTickets.year}.csv`, toCSV(rows,headers));
|
||||
}
|
||||
function exportGastosCSV(){
|
||||
if(!cacheGastos?.months?.length) return;
|
||||
const toInt=v=>fmtInt(v);
|
||||
const headers=['Año','Mes','Gasto'];
|
||||
const rows=cacheGastos.months.map(m=>({'Año':cacheGastos.year,'Mes':m.nombre,'Gasto':toInt(m.importe)}));
|
||||
downloadText(`gastos_${cacheGastos.year}.csv`, toCSV(rows,headers));
|
||||
}
|
||||
function exportCompCSV(){
|
||||
if(!cacheGastos?.months || !cacheTickets?.months) return;
|
||||
const headers=['Mes','Ingresos','Gastos','Resultado'];
|
||||
const rows=monthNames.map((nm,i)=>{ const s=Number(cacheTickets.months[i]?.importe||0), g=Number(cacheGastos.months[i]?.importe||0); return {Mes:nm,Ingresos:fmtInt(s),Gastos:fmtInt(g),Resultado:fmtInt(s-g)}; });
|
||||
downloadText(`comparativo_${cacheTickets.year||cacheGastos.year}.csv`, toCSV(rows,headers));
|
||||
}
|
||||
|
||||
/* ===== Gastos detallados (mes anterior) ===== */
|
||||
let cacheGastosDet = [];
|
||||
let cacheGdetMeta = null;
|
||||
|
||||
async function loadGastosDetallado(optMonth, optYear){
|
||||
// 1) rango según select (o params)
|
||||
const {month, year} = (Number.isFinite(optMonth) && Number.isFinite(optYear))
|
||||
? {month: optMonth, year: optYear}
|
||||
: getSelectedMonthYear();
|
||||
const {desdeISO, hastaISO, titulo, spanTxt} = monthRange(month, year);
|
||||
cacheGdetMeta = { desdeISO, hastaISO, month, year };
|
||||
|
||||
// 2) traer tablas base
|
||||
const [compras, provs, detMats, detProds, mates, prods] = await Promise.all([
|
||||
jget('/api/table/compras?limit=10000&order_by=fec_compra%20desc').catch(()=>[]),
|
||||
jget('/api/table/proveedores?limit=10000').catch(()=>[]),
|
||||
jget('/api/table/deta_comp_materias?limit=100000').catch(()=>[]),
|
||||
jget('/api/table/deta_comp_producto?limit=100000').catch(()=>[]),
|
||||
jget('/api/table/mate_primas?limit=10000').catch(()=>[]),
|
||||
jget('/api/table/productos?limit=10000').catch(()=>[]),
|
||||
]);
|
||||
|
||||
// 3) filtro por rango seleccionado
|
||||
const from = new Date(desdeISO), to = new Date(hastaISO);
|
||||
const comprasMes = (compras||[]).filter(c=>{
|
||||
const d = new Date(c.fec_compra || c.fecha || c.fec);
|
||||
return d >= from && d <= to;
|
||||
});
|
||||
const ids = new Set(comprasMes.map(c=>c.id_compra));
|
||||
|
||||
// 4) mapas auxiliares
|
||||
const provById = Object.fromEntries((provs||[]).map(p=>[p.id_proveedor, p.raz_social||p.rut||`#${p.id_proveedor}`]));
|
||||
const matName = Object.fromEntries((mates||[]).map(x=>[x.id_mat_prima, x.nombre]));
|
||||
const prodName = Object.fromEntries((prods||[]).map(x=>[x.id_producto, x.nombre]));
|
||||
const mapCompra = Object.fromEntries(comprasMes.map(c=>[c.id_compra, c]));
|
||||
|
||||
// 5) construir filas
|
||||
const filas = [];
|
||||
(detMats||[]).forEach(d=>{
|
||||
if(!ids.has(d.id_compra)) return;
|
||||
const c = mapCompra[d.id_compra]; const fecha = new Date(c.fec_compra||c.fecha||c.fec);
|
||||
const prov = provById[c.id_proveedor] || '—';
|
||||
const qty = Number(d.cantidad||0);
|
||||
const pu = Number(d.pre_unitario||0);
|
||||
filas.push({
|
||||
fecha, fecha_txt: fecha.toLocaleDateString('es-UY'),
|
||||
proveedor: prov, tipo: 'Materia', item: (matName[d.id_mat_prima] || `#${d.id_mat_prima}`),
|
||||
cantidad: qty, precio: pu, subtotal: qty*pu
|
||||
});
|
||||
});
|
||||
(detProds||[]).forEach(d=>{
|
||||
if(!ids.has(d.id_compra)) return;
|
||||
const c = mapCompra[d.id_compra]; const fecha = new Date(c.fec_compra||c.fecha||c.fec);
|
||||
const prov = provById[c.id_proveedor] || '—';
|
||||
const qty = Number(d.cantidad||0);
|
||||
const pu = Number(d.pre_unitario||0);
|
||||
filas.push({
|
||||
fecha, fecha_txt: fecha.toLocaleDateString('es-UY'),
|
||||
proveedor: prov, tipo: 'Producto', item: (prodName[d.id_producto] || `#${d.id_producto}`),
|
||||
cantidad: qty, precio: pu, subtotal: qty*pu
|
||||
});
|
||||
});
|
||||
|
||||
filas.sort((a,b)=> b.fecha - a.fecha);
|
||||
cacheGastosDet = filas;
|
||||
|
||||
// 6) render
|
||||
document.querySelector('#gdetTitle')?.replaceChildren(document.createTextNode(titulo));
|
||||
document.querySelector('#gdetInfo') ?.replaceChildren(document.createTextNode(spanTxt));
|
||||
const tb = document.querySelector('#tbGdet');
|
||||
if(!filas.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; }
|
||||
else{
|
||||
tb.innerHTML = '';
|
||||
filas.forEach(r=>{
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.fecha_txt}</td>
|
||||
<td>${r.proveedor}</td>
|
||||
<td>${r.tipo}</td>
|
||||
<td>${r.item}</td>
|
||||
<td class="text-end">${r.cantidad.toLocaleString('es-UY')}</td>
|
||||
<td class="text-end">${new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(r.precio)}</td>
|
||||
<td class="text-end">${new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(r.subtotal)}</td>`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
const total = filas.reduce((s,r)=>s+r.subtotal,0);
|
||||
document.querySelector('#gdetTotal') ?.replaceChildren(document.createTextNode(new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(total)));
|
||||
document.querySelector('#gdetCompras')?.replaceChildren(document.createTextNode(comprasMes.length.toLocaleString('es-UY')));
|
||||
document.querySelector('#gdetRows') ?.replaceChildren(document.createTextNode(filas.length.toLocaleString('es-UY')));
|
||||
}
|
||||
|
||||
|
||||
function exportGdetCSV(){
|
||||
if(!cacheGastosDet?.length) return;
|
||||
const headers = ['Fecha','Proveedor','Tipo','Ítem','Cantidad','Precio','Subtotal'];
|
||||
const rows = cacheGastosDet.map(r=>({
|
||||
'Fecha': r.fecha_txt,
|
||||
'Proveedor': r.proveedor,
|
||||
'Tipo': r.tipo,
|
||||
'Ítem': r.item,
|
||||
'Cantidad': r.cantidad,
|
||||
'Precio': Math.round(r.precio),
|
||||
'Subtotal': Math.round(r.subtotal)
|
||||
}));
|
||||
const title = document.querySelector('#gdetTitle')?.textContent?.replace(/\s+/g,'_') || 'mes';
|
||||
downloadText(`gastos_detalle_${title}.csv`, toCSV(rows, headers));
|
||||
}
|
||||
|
||||
const onPDFGdet = ()=>printArea('PRINT_GASTOS_DET');
|
||||
const onPDFAsist=()=>printArea('PRINT_ASIST');
|
||||
const onPDFTicket=()=>printArea('PRINT_TICKETS');
|
||||
const onPDFGastos=()=>printArea('PRINT_GASTOS');
|
||||
const onPDFComp=()=>printArea('PRINT_COMP');
|
||||
|
||||
/* ===== Eventos ===== */
|
||||
|
||||
const btnGdetCargar = document.querySelector('#btnGdetCargar');
|
||||
if (btnGdetCargar) btnGdetCargar.addEventListener('click', ()=> loadGastosDetallado());
|
||||
|
||||
// por UX: recargar al cambiar mes/año
|
||||
document.querySelector('#gdetMes') ?.addEventListener('change', ()=> loadGastosDetallado());
|
||||
document.querySelector('#gdetAnio')?.addEventListener('change', ()=> loadGastosDetallado());
|
||||
|
||||
document.querySelector('#btnGdetExcel')?.addEventListener('click', exportGdetCSV);
|
||||
document.querySelector('#btnGdetPDF') ?.addEventListener('click', onPDFGdet);
|
||||
|
||||
|
||||
$('#btnAsistCargar').addEventListener('click', loadAsist);
|
||||
$('#btnAsistExcel'). addEventListener('click', exportAsistCSV);
|
||||
$('#btnAsistPDF'). addEventListener('click', onPDFAsist);
|
||||
|
||||
$('#btnAnualExcel').addEventListener('click', exportCompCSV);
|
||||
$('#btnAnualPDF'). addEventListener('click', onPDFComp);
|
||||
|
||||
/* ✅ Enlaza los botones de la sección comparativo */
|
||||
$('#btnCompExcel').addEventListener('click', exportCompCSV);
|
||||
$('#btnCompPDF'). addEventListener('click', onPDFComp);
|
||||
|
||||
/* ✅ Botones de la nueva sección de gastos detallados */
|
||||
$('#btnGdetExcel').addEventListener('click', exportGdetCSV);
|
||||
$('#btnGdetPDF'). addEventListener('click', onPDFGdet);
|
||||
|
||||
|
||||
$('#btnAnualCargar').addEventListener('click', async ()=>{
|
||||
const y=Number($('#anualYear').value);
|
||||
$('#repStatus').textContent='Cargando ventas y gastos…';
|
||||
cacheTickets = await fetchTickets(y).catch(()=>null);
|
||||
if (cacheTickets) renderTickets(cacheTickets);
|
||||
cacheGastos = await fetchGastos(y).catch(()=>null);
|
||||
if (cacheGastos) renderGastos(cacheGastos);
|
||||
if (cacheTickets && cacheGastos) renderComparativo();
|
||||
$('#repStatus').textContent='Listo';
|
||||
});
|
||||
$('#btnAnualExcel').addEventListener('click', exportCompCSV);
|
||||
$('#btnAnualPDF'). addEventListener('click', onPDFComp);
|
||||
|
||||
/* ===== Defaults al cargar ===== */
|
||||
(function init(){
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
|
||||
if (!$('#anualYear').value) $('#anualYear').value = y;
|
||||
|
||||
if (!$('#asistDesde').value || !$('#asistHasta').value){
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setDate(end.getDate()-30);
|
||||
$('#asistDesde').value = start.toISOString().slice(0,10);
|
||||
$('#asistHasta').value = end.toISOString().slice(0,10);
|
||||
}
|
||||
|
||||
loadAsist().catch(()=>{});
|
||||
(async()=>{
|
||||
cacheTickets = await fetchTickets($('#anualYear').value).catch(()=>null);
|
||||
if (cacheTickets) renderTickets(cacheTickets);
|
||||
cacheGastos = await fetchGastos($('#anualYear').value).catch(()=>null);
|
||||
if (cacheGastos) renderGastos(cacheGastos);
|
||||
if (cacheTickets && cacheGastos) renderComparativo();
|
||||
$('#repStatus').textContent = 'Listo';
|
||||
})();
|
||||
})();
|
||||
|
||||
(function presetMesAnterior(){
|
||||
const now = new Date();
|
||||
const prev = new Date(now.getFullYear(), now.getMonth()-1, 1);
|
||||
const mesSel = document.querySelector('#gdetMes');
|
||||
const anioIn = document.querySelector('#gdetAnio');
|
||||
if (mesSel && !mesSel.value) mesSel.value = String(prev.getMonth()+1);
|
||||
if (anioIn && !anioIn.value) anioIn.value = String(prev.getFullYear());
|
||||
// primera carga
|
||||
loadGastosDetallado(prev.getMonth()+1, prev.getFullYear()).catch(()=>{});
|
||||
})();
|
||||
|
||||
</script>
|
||||
402
services/manso/src/views/reportes.ejs.bak
Normal file
402
services/manso/src/views/reportes.ejs.bak
Normal file
@ -0,0 +1,402 @@
|
||||
<% /* Reportes - Asistencias y Tickets (Comandas) */ %>
|
||||
<div class="container-fluid py-3">
|
||||
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<h3 class="mb-0">Reportes</h3>
|
||||
<span class="ms-auto small text-muted" id="repStatus">—</span>
|
||||
</div>
|
||||
|
||||
<!-- Filtros -->
|
||||
<div class="card shadow-sm mb-3">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label mb-1">Asistencias · Rango</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-4">
|
||||
<input id="asistDesde" type="date" class="form-control">
|
||||
</div>
|
||||
<div class="col-6 col-md-4">
|
||||
<input id="asistHasta" type="date" class="form-control">
|
||||
</div>
|
||||
<div class="col-12 col-md-4 d-grid d-md-block">
|
||||
<button id="btnAsistCargar" class="btn btn-primary me-2">Cargar</button>
|
||||
<button id="btnAsistExcel" class="btn btn-outline-success me-2">Excel</button>
|
||||
<button id="btnAsistPDF" class="btn btn-outline-secondary">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<label class="form-label mb-1">Tickets (Comandas) · Año</label>
|
||||
<div class="row g-2">
|
||||
<div class="col-6 col-md-4">
|
||||
<input id="ticketsYear" type="number" min="2000" step="1" class="form-control">
|
||||
</div>
|
||||
<div class="col-6 col-md-8 d-grid d-md-block">
|
||||
<button id="btnTicketsCargar" class="btn btn-primary me-2">Cargar</button>
|
||||
<button id="btnTicketsExcel" class="btn btn-outline-success me-2">Excel</button>
|
||||
<button id="btnTicketsPDF" class="btn btn-outline-secondary">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="small text-muted mt-2">
|
||||
Los archivos Excel se generan como CSV (compatibles). Los PDF se generan con “Imprimir área” del navegador.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reporte Asistencias -->
|
||||
<div class="card shadow-sm mb-3" id="PRINT_ASIST">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Asistencias</strong>
|
||||
<span class="ms-auto small text-muted" id="asistInfo">—</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Documento</th>
|
||||
<th>Nombre</th>
|
||||
<th>Apellido</th>
|
||||
<th>Fecha</th>
|
||||
<th class="text-end">Desde</th>
|
||||
<th class="text-end">Hasta</th>
|
||||
<th class="text-end">Duración</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbAsist">
|
||||
<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reporte Tickets -->
|
||||
<div class="card shadow-sm" id="PRINT_TICKETS">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<strong>Tickets</strong>
|
||||
<span class="ms-auto small text-muted" id="ticketsInfo">—</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="emp-card p-3 border rounded">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-semibold">Resumen del año</div>
|
||||
<div class="small text-muted" id="ticketsYearTitle">—</div>
|
||||
</div>
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="small text-muted">Tickets YTD</div>
|
||||
<div class="fs-5 fw-semibold" id="tYtd">—</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-muted">Promedio</div>
|
||||
<div class="fs-5 fw-semibold" id="tAvg">—</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="small text-muted">Hasta la fecha</div>
|
||||
<div class="fs-5 fw-semibold" id="tToDate">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="emp-card p-3 border rounded">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div class="fw-semibold">Tickets por mes</div>
|
||||
<div class="small text-muted">Cantidad</div>
|
||||
</div>
|
||||
<div class="spark-wrap" id="ticketsChart" style="height:140px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th>Mes</th>
|
||||
<th class="text-end">Tickets</th>
|
||||
<th class="text-end">Importe</th>
|
||||
<th class="text-end">Ticket promedio</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tbTickets">
|
||||
<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.spark rect:hover { filter: brightness(0.9); }
|
||||
@media print {
|
||||
body * { visibility: hidden !important; }
|
||||
#PRINT_ASIST, #PRINT_ASIST *,
|
||||
#PRINT_TICKETS, #PRINT_TICKETS * { visibility: visible !important; }
|
||||
#PRINT_ASIST, #PRINT_TICKETS { position: absolute; left:0; top:0; width:100%; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
/* =========================
|
||||
Helpers reutilizables
|
||||
========================= */
|
||||
const $ = s => document.querySelector(s);
|
||||
const z2 = n => String(n).padStart(2,'0');
|
||||
const fmtMoney = v => (v==null? '—' : new Intl.NumberFormat('es-UY',{style:'currency',currency:'UYU'}).format(Number(v)));
|
||||
const fmtHM = mins => { const h = Math.floor(mins/60); const m = Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
|
||||
|
||||
// GET JSON simple
|
||||
async function jget(url){
|
||||
const r = await fetch(url);
|
||||
const j = await r.json().catch(()=>null);
|
||||
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
|
||||
return j;
|
||||
}
|
||||
// POST JSON simple
|
||||
async function jpost(url, body){
|
||||
const r = await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})});
|
||||
const j = await r.json().catch(()=>null);
|
||||
if (!r.ok) throw new Error(j?.error || `${r.status} ${r.statusText}`);
|
||||
return j;
|
||||
}
|
||||
|
||||
// CSV (Excel-friendly)
|
||||
|
||||
function toCSV(rows, headers){
|
||||
const esc = v => {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'number') return String(v); // números sin comillas
|
||||
const s = String(v);
|
||||
return /[",\n]/.test(s) ? `"${s.replace(/"/g,'""')}"` : s;
|
||||
};
|
||||
const cols = headers && headers.length ? headers : Object.keys(rows?.[0] || {});
|
||||
const lines = [];
|
||||
if (headers) lines.push(cols.join(','));
|
||||
for (const r of (rows || [])) lines.push(cols.map(c => esc(r[c])).join(','));
|
||||
return lines.join('\r\n');
|
||||
}
|
||||
|
||||
function downloadText(filename, text){
|
||||
const blob = new Blob([text], {type:'text/csv;charset=utf-8;'});
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
// Print solo área
|
||||
function printArea(id){
|
||||
// cambiamos el hash para que @media print muestre el área; luego invocamos print
|
||||
const el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
window.print();
|
||||
}
|
||||
|
||||
// SVG barras simple (sin librerías)
|
||||
function barsSVG(series /* [{label:'Ene', value:Number}] */){
|
||||
const W=560, H=120, PAD=10, GAP=6;
|
||||
const n = series.length||1;
|
||||
const max = Math.max(1, ...series.map(d=>d.value||0));
|
||||
const bw = Math.max(6, Math.floor((W-PAD*2-GAP*(n-1))/n));
|
||||
let x = PAD;
|
||||
let bars = '';
|
||||
series.forEach((d,i)=>{
|
||||
const vh = Math.round((d.value/max)*(H-PAD-26)); // 26px para etiquetas
|
||||
const y = H-20 - vh;
|
||||
const title = `${d.label} · ${d.value}`;
|
||||
bars += `<g>
|
||||
<rect x="${x}" y="${y}" width="${bw}" height="${vh}" rx="3" ry="3" class="bar">
|
||||
<title>${title}</title>
|
||||
</rect>
|
||||
<text x="${x + bw/2}" y="${H-6}" text-anchor="middle">${d.label}</text>
|
||||
</g>`;
|
||||
x += bw + GAP;
|
||||
});
|
||||
const css = `.bar{fill:#0d6efd}`;
|
||||
const axis = `<line x1="${PAD}" y1="${H-20}" x2="${W-PAD}" y2="${H-20}" stroke="#adb5bd" stroke-width="1"/>`;
|
||||
return `<svg viewBox="0 0 ${W} ${H}" class="spark" preserveAspectRatio="none">
|
||||
<style>${css}</style>
|
||||
${axis}
|
||||
${bars}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Data access (enchufable)
|
||||
=========================
|
||||
Estas funciones llaman RPCs del server, que a su vez deben
|
||||
delegar en funciones SQL. Si aún no existen, ver más abajo
|
||||
el bloque "Sugerencia de funciones SQL".
|
||||
*/
|
||||
async function fetchAsistencias(desde, hasta){
|
||||
// endpoint recomendado (RPC):
|
||||
// POST /api/rpc/report_asistencia { desde, hasta }
|
||||
// Retorna [{documento,nombre,apellido,fecha,desde_hora,hasta_hora,dur_min}]
|
||||
try {
|
||||
return await jpost('/api/rpc/report_asistencia', { desde, hasta });
|
||||
} catch {
|
||||
// fallback (si aún no tienes RPC): lee la vista "asistencia_detalle" hipotética
|
||||
const url = `/api/table/asistencia_detalle?desde=${encodeURIComponent(desde)}&hasta=${encodeURIComponent(hasta)}&limit=10000`;
|
||||
return await jget(url);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTickets(year){
|
||||
// endpoint recomendado (RPC):
|
||||
// POST /api/rpc/report_tickets { year }
|
||||
// Retorna: { year, total_ytd, avg_ticket, to_date, months:[{mes:1..12, nombre:'Ene', cant, importe, avg}] }
|
||||
return await jpost('/api/rpc/report_tickets', { year });
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Render Asistencias
|
||||
========================= */
|
||||
let cacheAsist = [];
|
||||
function renderAsistTabla(rows){
|
||||
const tb = $('#tbAsist');
|
||||
if (!rows?.length){ tb.innerHTML = '<tr><td colspan="7" class="p-3 text-muted">Sin datos</td></tr>'; return; }
|
||||
tb.innerHTML = '';
|
||||
rows.forEach(r=>{
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${r.documento||'—'}</td>
|
||||
<td>${r.nombre||'—'}</td>
|
||||
<td>${r.apellido||'—'}</td>
|
||||
<td>${r.fecha||'—'}</td>
|
||||
<td class="text-end">${r.desde_hora||'—'}</td>
|
||||
<td class="text-end">${r.hasta_hora||'—'}</td>
|
||||
<td class="text-end">${fmtHM(Number(r.dur_min||0))}</td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAsist(){
|
||||
const d = $('#asistDesde').value;
|
||||
const h = $('#asistHasta').value;
|
||||
$('#repStatus').textContent = 'Cargando asistencias…';
|
||||
const rows = await fetchAsistencias(d,h);
|
||||
cacheAsist = rows||[];
|
||||
renderAsistTabla(cacheAsist);
|
||||
const minsTot = cacheAsist.reduce((s,r)=> s + Number(r.dur_min||0), 0);
|
||||
$('#asistInfo').textContent = `${cacheAsist.length} registros · ${fmtHM(minsTot)}`;
|
||||
$('#repStatus').textContent = 'Listo';
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Render Tickets
|
||||
========================= */
|
||||
let cacheTickets = null;
|
||||
function renderTickets(data){
|
||||
const months = data?.months||[];
|
||||
$('#ticketsYearTitle').textContent = data?.year || '—';
|
||||
$('#tYtd').textContent = months.reduce((s,m)=> s + Number(m.cant||0), 0);
|
||||
$('#tAvg').textContent = fmtMoney(data?.avg_ticket ?? 0);
|
||||
$('#tToDate').textContent = data?.to_date != null ? fmtMoney(data.to_date) : '—';
|
||||
|
||||
const series = months.map(m=>({ label:m.nombre||m.mes, value:Number(m.cant||0) }));
|
||||
$('#ticketsChart').innerHTML = barsSVG(series);
|
||||
|
||||
const tb = $('#tbTickets');
|
||||
if (!months.length){ tb.innerHTML = '<tr><td colspan="4" class="p-3 text-muted">Sin datos</td></tr>'; return; }
|
||||
tb.innerHTML = '';
|
||||
months.forEach(m=>{
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${m.nombre||m.mes}</td>
|
||||
<td class="text-end">${m.cant||0}</td>
|
||||
<td class="text-end">${fmtMoney(m.importe||0)}</td>
|
||||
<td class="text-end">${fmtMoney(m.avg||0)}</td>
|
||||
`;
|
||||
tb.appendChild(tr);
|
||||
});
|
||||
$('#ticketsInfo').textContent = `${months.length} meses`;
|
||||
}
|
||||
|
||||
async function loadTickets(){
|
||||
const y = Number($('#ticketsYear').value);
|
||||
$('#repStatus').textContent = 'Cargando tickets…';
|
||||
const data = await fetchTickets(y);
|
||||
cacheTickets = data;
|
||||
renderTickets(cacheTickets);
|
||||
$('#repStatus').textContent = 'Listo';
|
||||
}
|
||||
|
||||
/* =========================
|
||||
Excel (CSV) & PDF
|
||||
========================= */
|
||||
function exportAsistCSV(){
|
||||
if (!cacheAsist?.length) return;
|
||||
const headers = ['Documento','Nombre','Apellido','Fecha','Desde','Hasta','Duración(min)'];
|
||||
const rows = cacheAsist.map(r=>({
|
||||
Documento:r.documento||'',
|
||||
Nombre:r.nombre||'',
|
||||
Apellido:r.apellido||'',
|
||||
Fecha:r.fecha||'',
|
||||
Desde:r.desde_hora||'',
|
||||
Hasta:r.hasta_hora||'',
|
||||
'Duración(min)':Number(r.dur_min||0)
|
||||
}));
|
||||
const csv = toCSV(rows, headers);
|
||||
downloadText(`asistencias_${$('#asistDesde').value}_${$('#asistHasta').value}.csv`, csv);
|
||||
}
|
||||
|
||||
function exportTicketsCSV(){
|
||||
if (!cacheTickets?.months?.length) return;
|
||||
const toInt = v => Math.round(Number(v || 0)); // sin decimales
|
||||
const headers = ['Año','Mes','Tickets','Importe','Ticket promedio'];
|
||||
const rows = cacheTickets.months.map(m => ({
|
||||
'Año': cacheTickets.year,
|
||||
'Mes': m.nombre || m.mes,
|
||||
'Tickets': Number(m.cant || 0),
|
||||
'Importe': toInt(m.importe), // ← entero
|
||||
'Ticket promedio': toInt(m.avg) // ← entero
|
||||
}));
|
||||
const csv = toCSV(rows, headers);
|
||||
downloadText(`tickets_${cacheTickets.year}.csv`, csv);
|
||||
}
|
||||
// PDF vía print-area del navegador
|
||||
const onPDFAsist = () => printArea('PRINT_ASIST');
|
||||
const onPDFTicket = () => printArea('PRINT_TICKETS');
|
||||
|
||||
/* =========================
|
||||
Eventos + defaults
|
||||
========================= */
|
||||
document.getElementById('btnAsistCargar').addEventListener('click', loadAsist);
|
||||
document.getElementById('btnTicketsCargar').addEventListener('click', loadTickets);
|
||||
document.getElementById('btnAsistExcel').addEventListener('click', exportAsistCSV);
|
||||
document.getElementById('btnTicketsExcel').addEventListener('click', exportTicketsCSV);
|
||||
document.getElementById('btnAsistPDF').addEventListener('click', onPDFAsist);
|
||||
document.getElementById('btnTicketsPDF').addEventListener('click', onPDFTicket);
|
||||
|
||||
// Defaults: último mes y año actual
|
||||
(function initDefaults(){
|
||||
const today = new Date();
|
||||
const y = today.getFullYear();
|
||||
const hasta = today.toISOString().slice(0,10);
|
||||
const d = new Date(today); d.setMonth(d.getMonth()-1);
|
||||
const desde = d.toISOString().slice(0,10);
|
||||
$('#asistDesde').value = desde;
|
||||
$('#asistHasta').value = hasta;
|
||||
$('#ticketsYear').value = y;
|
||||
// carga inicial
|
||||
loadAsist().catch(()=>{});
|
||||
loadTickets().catch(()=>{});
|
||||
})();
|
||||
</script>
|
||||
Loading…
x
Reference in New Issue
Block a user