Creación de la sección "Reportes" y "Compras"
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user