Actualización de archivos para corresponder a las

funcionalidades de "Compras" y de "Reportes".
This commit is contained in:
Mateo Saldain 2025-09-01 20:32:43 +00:00
parent 93ac1db5f1
commit 25876e733b
2 changed files with 485 additions and 144 deletions

View File

@ -661,25 +661,78 @@ app.post('/api/rpc/asistencia_delete_raw', async (req, res) => {
}
});
app.post('/api/rpc/report_asistencia', async (req,res)=>{
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);
});
// POST /api/rpc/report_tickets { year }
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 || {});
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(e);
res.status(500).json({ error: 'report_tickets failed' });
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) => {

View File

@ -42,26 +42,17 @@
</div>
</div>
<!-- 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>
<!-- 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>
<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>
<!-- Tickets -->
<div class="card shadow-sm mb-3" id="PRINT_TICKETS">
@ -108,48 +99,78 @@
</div>
</div>
<!-- Gastos -->
<!-- Gastos detallados (mes anterior completo) -->
<!-- Gastos detallados (filtrable por mes/año) -->
<div class="card shadow-sm mb-3" id="PRINT_GASTOS_DET">
<div class="card-header d-flex align-items-center">
<strong>Gastos detallados — <span id="gdetTitle">mes anterior</span></strong>
<span class="ms-auto small text-muted" id="gdetInfo">—</span>
</div>
<div class="card-body">
<div class="row g-3 mb-2">
<div class="col-12 col-lg-8">
<div class="d-flex gap-2">
<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>
<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="col-12 col-lg-4 d-grid d-md-block text-lg-end">
<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 class="table-responsive">
<table class="table table-sm align-middle">
<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 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>
</div>
<!-- Comparativo Ventas vs Gastos -->
@ -201,27 +222,84 @@
<style>
.spark rect:hover { filter: brightness(0.9); }
@media print {
body * { visibility: hidden !important; }
#PRINT_ASIST, #PRINT_ASIST *,
#PRINT_TICKETS, #PRINT_TICKETS *,
#PRINT_GASTOS, #PRINT_GASTOS *,
#PRINT_COMP, #PRINT_COMP * { visibility: visible !important; }
#PRINT_ASIST, #PRINT_TICKETS, #PRINT_GASTOS, #PRINT_COMP { position:absolute; left:0; top:0; width:100%; }
.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]||{});
@ -258,7 +336,6 @@ function barsCompareSVG(a,b){ // a=ventas, b=gastos: arrays [{label,value}]
}
/* ===== Asistencias ===== */
const fmtHM = mins => { const h=Math.floor(mins/60); const m=Math.round(mins%60); return `${z2(h)}:${z2(m)}`; };
let cacheAsist=[];
async function fetchAsistencias(desde,hasta){
@ -272,13 +349,162 @@ function renderAsistTabla(rows){
<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, h=$('#asistHasta').value; $('#repStatus').textContent='Cargando asistencias…';
cacheAsist=await fetchAsistencias(d,h)||[]; 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'; }
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;
async function fetchTickets(year){ return await jpost('/api/rpc/report_tickets', { year }); }
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);
@ -311,16 +537,51 @@ async function fetchGastos(year){
const avg = months.reduce((s,m)=>s+m.importe,0)/12;
return { year, months, total, avg };
}
function renderGastos(data){
const months=data?.months||[]; $('#gastosYearTitle').textContent=data?.year||'—';
$('#gToDate').textContent=fmtMoney(data?.total||0);
$('#gAvg').textContent=fmtMoney(data?.avg||0);
$('#gastosChart').innerHTML=barsSVG(months.map(m=>({label:m.nombre,value:Number(m.importe||0)})));
const tb=$('#tbGastos'); 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}</td><td class="text-end">${fmtMoney(m.importe||0)}</td>`; tb.appendChild(tr); });
// 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)
})));
}
$('#gastosInfo').textContent=`${months.length} meses`;
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 ===== */
@ -379,26 +640,18 @@ function exportCompCSV(){
}
/* ===== Gastos detallados (mes anterior) ===== */
let cacheGastosDet = []; // filas detalladas del último mes
let cacheGdetMeta = null; // {desde,hasta}
let cacheGastosDet = [];
let cacheGdetMeta = null;
function rangoMesAnterior() {
const today = new Date();
const y = today.getFullYear(), m = today.getMonth(); // m: 0=ene
const first = new Date(y, m-1, 1, 0,0,0); // 1er día mes anterior
const last = new Date(y, m, 0, 23,59,59,999); // último día mes anterior
return {
desdeISO: first.toISOString(),
hastaISO: last.toISOString(),
titulo: `${first.toLocaleDateString('es-UY', { month:'long', year:'numeric' })}`
};
}
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 };
async function loadGastosDetallado(){
const {desdeISO, hastaISO, titulo} = rangoMesAnterior();
cacheGdetMeta = { desdeISO, hastaISO };
// 1) Traigo tablas base (se filtra en cliente)
// 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(()=>[]),
@ -408,55 +661,54 @@ async function loadGastosDetallado(){
jget('/api/table/productos?limit=10000').catch(()=>[]),
]);
// 2) Filtro compras del mes anterior
const d0 = new Date(desdeISO), d1 = new Date(hastaISO);
// 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 >= d0 && d <= d1;
return d >= from && d <= to;
});
const ids = new Set(comprasMes.map(c=>c.id_compra));
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]));
// 3) Armo filas detalladas
// 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]));
const filas = [];
// 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 item = matName[d.id_mat_prima] || `#${d.id_mat_prima}`;
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,
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 item = prodName[d.id_producto] || `#${d.id_producto}`;
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,
proveedor: prov, tipo: 'Producto', item: (prodName[d.id_producto] || `#${d.id_producto}`),
cantidad: qty, precio: pu, subtotal: qty*pu
});
});
// 4) Render
filas.sort((a,b)=>b.fecha - a.fecha);
filas.sort((a,b)=> b.fecha - a.fecha);
cacheGastosDet = filas;
$('#gdetTitle').textContent = titulo.charAt(0).toUpperCase() + titulo.slice(1);
const tb = $('#tbGdet');
// 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 = '';
@ -468,19 +720,18 @@ async function loadGastosDetallado(){
<td>${r.tipo}</td>
<td>${r.item}</td>
<td class="text-end">${r.cantidad.toLocaleString('es-UY')}</td>
<td class="text-end">${fmtMoney(r.precio)}</td>
<td class="text-end">${fmtMoney(r.subtotal)}</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);
$('#gdetTotal').textContent = fmtMoney(total);
$('#gdetCompras').textContent = comprasMes.length.toLocaleString('es-UY');
$('#gdetRows').textContent = filas.length.toLocaleString('es-UY');
$('#gdetInfo').textContent = `${new Date(d0).toLocaleDateString('es-UY')} - ${new Date(d1).toLocaleDateString('es-UY')}`;
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'];
@ -490,11 +741,13 @@ function exportGdetCSV(){
'Tipo': r.tipo,
'Ítem': r.item,
'Cantidad': r.cantidad,
'Precio': Math.round(r.precio), // entero como pediste
'Precio': Math.round(r.precio),
'Subtotal': Math.round(r.subtotal)
}));
downloadText(`gastos_detalle_${$('#gdetTitle').textContent.replace(/\s+/g,'_')}.csv`, toCSV(rows, headers));
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');
@ -502,6 +755,18 @@ 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);
@ -531,18 +796,41 @@ $('#btnAnualCargar').addEventListener('click', async ()=>{
$('#btnAnualExcel').addEventListener('click', exportCompCSV);
$('#btnAnualPDF'). addEventListener('click', onPDFComp);
$('#btnGastosExcel').addEventListener('click', exportGastosCSV);
$('#btnGastosPDF'). addEventListener('click', onPDFGastos);
/* ===== Defaults al cargar ===== */
loadAsist().catch(()=>{});
(async ()=>{
const y = Number($('#anualYear').value);
cacheTickets = await fetchTickets(y).catch(()=>null); if(cacheTickets) renderTickets(cacheTickets);
cacheGastos = await fetchGastos(y).catch(()=>null); if(cacheGastos) renderGastos(cacheGastos);
if(cacheTickets && cacheGastos) renderComparativo();
/* ✅ detalle del último mes */
await loadGastosDetallado().catch(()=>{});
(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>