/* ============================================================ REPORTS MODULE Genera reportes de ventas, productos, clientes y márgenes. Depende de: STATE.adminInvoices, STATE.products, STATE.clients, STATE.priceLists, STATE.changeLog (cargados por utils.js). Funciones de entrada: generateAllReports(), switchReportTab(name), downloadReportsAsExcel(), downloadReportsAsPDF(). ============================================================ */ let reportData = { dateFrom: null, dateTo: null }; function switchReportTab(tabName) { document.querySelectorAll('.report-tab-content').forEach(el => { el.classList.remove('active'); el.style.display = 'none'; }); document.querySelectorAll('.reports-tab-btn').forEach(btn => { btn.classList.remove('active'); btn.style.borderBottomColor = 'transparent'; btn.style.color = '#374151'; }); const activeTab = document.getElementById(`report-tab-${tabName}`); if (activeTab) { activeTab.classList.add('active'); activeTab.style.display = 'block'; } const activeBtn = document.querySelector(`.reports-tab-btn[onclick="switchReportTab('${tabName}')"]`); if (activeBtn) { activeBtn.classList.add('active'); activeBtn.style.borderBottomColor = '#4f46e5'; activeBtn.style.color = '#4f46e5'; } if (tabName === 'dashboard' && typeof generateDashboard === 'function') { generateDashboard(); } } function getReportDateRange() { const fromInput = document.getElementById('reports-date-from'); const toInput = document.getElementById('reports-date-to'); const today = new Date(); // Sin fecha de inicio → muestra todo el historial desde 2020 const defaultFrom = new Date('2020-01-01T00:00:00'); reportData.dateFrom = fromInput?.value ? new Date(fromInput.value + 'T00:00:00') : defaultFrom; reportData.dateTo = toInput?.value ? new Date(toInput.value + 'T23:59:59') : today; } function getInvoicesInDateRange() { getReportDateRange(); return STATE.adminInvoices.filter(inv => { // inv.date puede ser es-AR ("25/4/2025 14:30:00") o ISO — parsear antes de comparar const ymd = parseDateToYMD(inv.date || ''); if (!ymd) return false; const invDate = new Date(ymd + 'T12:00:00'); return invDate >= reportData.dateFrom && invDate <= reportData.dateTo; }); } function generatePeriodSummary() { const invoices = getInvoicesInDateRange(); const totalSales = invoices.reduce((sum, inv) => sum + inv.total, 0); const totalOrders = invoices.length; const avgTicket = totalOrders > 0 ? totalSales / totalOrders : 0; // Calculate total margin let totalMargin = 0; invoices.forEach(inv => { inv.items.forEach(item => { const product = STATE.products.find(p => p.id === item.id); if (product) { const cost = product.cost * item.qty; const gain = (item.price * item.qty) - cost; totalMargin += gain; } }); }); // Actualizar los spans existentes en el HTML (no reemplazar el grid — eso rompe las columnas) const salesEl = document.getElementById('report-total-sales'); const ordersEl = document.getElementById('report-total-orders'); const ticketEl = document.getElementById('report-avg-ticket'); const marginEl = document.getElementById('report-total-margin'); if (salesEl) salesEl.textContent = fmt(totalSales); if (ordersEl) ordersEl.textContent = totalOrders; if (ticketEl) ticketEl.textContent = fmt(avgTicket); if (marginEl) marginEl.textContent = fmt(totalMargin); } function generateTopProductsReport() { const invoices = getInvoicesInDateRange(); const productStats = {}; invoices.forEach(inv => { inv.items.forEach(item => { if (!productStats[item.id]) { productStats[item.id] = { name: item.name, qty: 0, revenue: 0, cost: 0, margin: 0 }; } productStats[item.id].qty += item.qty; productStats[item.id].revenue += item.price * item.qty; const product = STATE.products.find(p => p.id === item.id); if (product) { productStats[item.id].cost += product.cost * item.qty; productStats[item.id].margin += (item.price - product.cost) * item.qty; } }); }); const sorted = Object.values(productStats) .sort((a, b) => b.revenue - a.revenue) .slice(0, 10); const tableContainer = document.getElementById('reports-top-products'); if (!tableContainer) return; tableContainer.innerHTML = ` ${sorted.map(p => ` `).join('')}
Producto Cantidad Ingresos Costo Margen
${escapeHtml(p.name)} ${p.qty} u. ${fmt(p.revenue)} ${fmt(p.cost)} ${fmt(p.margin)}
`; } function generateTopClientsReport() { const invoices = getInvoicesInDateRange(); const clientStats = {}; invoices.forEach(inv => { if (!clientStats[inv.clientId]) { clientStats[inv.clientId] = { name: inv.client, orders: 0, total: 0, margin: 0 }; } clientStats[inv.clientId].orders += 1; clientStats[inv.clientId].total += inv.total; // Calculate margin let invMargin = 0; inv.items.forEach(item => { const product = STATE.products.find(p => p.id === item.id); if (product) { const cost = product.cost * item.qty; invMargin += (item.price * item.qty) - cost; } }); clientStats[inv.clientId].margin += invMargin; }); const sorted = Object.values(clientStats) .sort((a, b) => b.total - a.total) .slice(0, 10); const tableContainer = document.getElementById('reports-top-clients'); if (!tableContainer) return; tableContainer.innerHTML = ` ${sorted.map(c => ` `).join('')}
Cliente Pedidos Monto Total Promedio Margen
${escapeHtml(c.name)} ${c.orders} ${fmt(c.total)} ${fmt(c.total / c.orders)} ${fmt(c.margin)}
`; } function generateMarginsReport() { const invoices = getInvoicesInDateRange(); const productStats = {}; invoices.forEach(inv => { inv.items.forEach(item => { if (!productStats[item.id]) { const product = STATE.products.find(p => p.id === item.id); productStats[item.id] = { name: item.name, cost: 0, revenue: 0, margin: 0, marginPercent: product?.margin || 0 }; } const product = STATE.products.find(p => p.id === item.id); if (product) { productStats[item.id].cost += product.cost * item.qty; productStats[item.id].revenue += item.price * item.qty; productStats[item.id].margin += (item.price - product.cost) * item.qty; } }); }); const sorted = Object.values(productStats) .sort((a, b) => b.margin - a.margin) .slice(0, 15); const tableContainer = document.getElementById('reports-margins'); if (!tableContainer) return; const totalCost = sorted.reduce((s, p) => s + p.cost, 0); const totalRevenue = sorted.reduce((s, p) => s + p.revenue, 0); const totalMargin = sorted.reduce((s, p) => s + p.margin, 0); const avgMarginPercent = sorted.length > 0 ? (totalMargin / totalRevenue * 100) : 0; tableContainer.innerHTML = `

Resumen de Márgenes

Costo Total

${fmt(totalCost)}

Ingresos Total

${fmt(totalRevenue)}

Margen Ganado

${fmt(totalMargin)}

% Margen Promedio

${avgMarginPercent.toFixed(1)}%

${sorted.map(p => ` `).join('')}
Producto Costo Total Ingresos Margen Ganado % Margen
${escapeHtml(p.name)} ${fmt(p.cost)} ${fmt(p.revenue)} ${fmt(p.margin)} ${(p.margin / p.revenue * 100).toFixed(1)}%
`; } function generateLowStockReport() { const lowStockProducts = STATE.products.filter(p => p.stock <= 5).sort((a, b) => a.stock - b.stock); const tableContainer = document.getElementById('reports-low-stock'); if (!tableContainer) return; if (lowStockProducts.length === 0) { tableContainer.innerHTML = 'Sin productos con stock bajo'; return; } tableContainer.innerHTML = lowStockProducts.map(p => ` ${escapeHtml(p.name)} ${p.stock} u. `).join(''); } function generatePriceListsReport() { const headerRow = document.getElementById('reports-price-lists-header'); const tableContainer = document.getElementById('reports-price-lists'); if (!headerRow || !tableContainer) return; const activeLists = STATE.priceLists.filter(pl => pl.is_active); if (activeLists.length === 0) { tableContainer.innerHTML = 'Sin listas de precios activas'; return; } headerRow.innerHTML = 'Producto' + activeLists.map(list => `${escapeHtml(list.name)}`).join(''); const htmlRows = STATE.products.map(product => ` ${escapeHtml(product.name)} ${activeLists.map(list => { const price = getPriceForList(product, list.id); return `$${roundPrice(price).toFixed(2)}`; }).join('')} `).join(''); tableContainer.innerHTML = htmlRows; } function generateDashboard() { const today = new Date(); const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1); const totalProducts = STATE.products.length; const lowStock = STATE.products.filter(p => p.stock > 0 && p.stock <= 5).length; const zeroStock = STATE.products.filter(p => p.stock === 0).length; const totalClients = STATE.clients.length; const activeLists = (STATE.priceLists || []).filter(pl => pl.is_active).length; const monthInvoices = (STATE.adminInvoices || []).filter(inv => { const ymd = parseDateToYMD(inv.date || ''); return ymd ? new Date(ymd + 'T12:00:00') >= firstOfMonth : false; }); const monthSales = monthInvoices.reduce((sum, inv) => sum + inv.total, 0); const el = id => document.getElementById(id); if (el('dash-total-products')) el('dash-total-products').textContent = totalProducts; if (el('dash-low-stock')) el('dash-low-stock').textContent = lowStock; if (el('dash-zero-stock')) el('dash-zero-stock').textContent = zeroStock; if (el('dash-total-clients')) el('dash-total-clients').textContent = totalClients; if (el('dash-month-sales')) el('dash-month-sales').textContent = fmt(monthSales); if (el('dash-active-lists')) el('dash-active-lists').textContent = activeLists; // Stock status breakdown const stockEl = el('dash-stock-status'); if (stockEl) { const normalStock = totalProducts - lowStock - zeroStock; const pct = (n, total) => total > 0 ? Math.round(n / total * 100) : 0; stockEl.innerHTML = `
Normal${normalStock}
Stock Bajo (≤5)${lowStock}
Sin Stock${zeroStock}

${totalProducts} productos en total

`; } // Price lists const listsEl = el('dash-price-lists'); if (listsEl) { const lists = STATE.priceLists || []; if (lists.length === 0) { listsEl.innerHTML = '

Sin listas de precios

'; } else { listsEl.innerHTML = lists.map(pl => `
${escapeHtml(pl.name)} ${pl.is_default ? 'Principal' : ''}
×${parseFloat(pl.factor || 1).toFixed(2)} ${pl.is_active ? 'Activa' : 'Inactiva'}
`).join(''); } } // Recent changes const changesEl = el('dash-recent-changes'); if (changesEl) { const log = (STATE.changeLog || []).slice(0, 8); if (log.length === 0) { changesEl.innerHTML = '

Sin cambios registrados

'; } else { const typeColors = { factura: '#3B82F6', stock: '#10B981', precio: '#F59E0B', producto: '#8B5CF6', bulk: '#6366F1' }; changesEl.innerHTML = `
${log.map(entry => { const color = typeColors[entry.type] || '#9CA3AF'; return `
${(entry.type || 'otro').toUpperCase()} ${escapeHtml(entry.productName || entry.note || '—')} ${entry.oldValue || entry.newValue ? `${entry.oldValue ? `${escapeHtml(entry.oldValue)} → ` : ''}${entry.newValue ? `${escapeHtml(entry.newValue)}` : ''}` : ''} ${new Date(entry.date).toLocaleDateString('es-AR')}
`; }).join('')}
`; } } } function generateHistoryReport() { const container = document.getElementById('reports-history'); if (!container) return; if (!STATE.changeLog || STATE.changeLog.length === 0) { container.innerHTML = '

No hay historial de cambios

'; return; } const types = {}; STATE.changeLog.forEach(entry => { types[entry.type] = (types[entry.type] || 0) + 1; }); const summary = Object.entries(types) .map(([type, count]) => `${type.toUpperCase()}: ${count}`) .join(''); let html = `

Resumen de cambios

${summary}
${STATE.changeLog.map(entry => { const color = entry.type === 'precio' ? '#3b82f6' : entry.type === 'stock' ? '#10b981' : entry.type === 'producto' ? '#f59e0b' : '#6b7280'; const date = new Date(entry.date); const dateStr = date.toLocaleDateString('es-AR'); const timeStr = date.toLocaleTimeString('es-AR', { hour: '2-digit', minute: '2-digit' }); return `
${(entry.type || 'otro').toUpperCase()}

${escapeHtml(entry.productName || entry.note || '—')}

${entry.field ? `Campo: ${escapeHtml(entry.field)}` : ''} ${entry.oldValue && entry.newValue ? `• ${escapeHtml(entry.oldValue)}${escapeHtml(entry.newValue)}` : ''}

${dateStr} ${timeStr}
`; }).join('')}
`; container.innerHTML = html; } function generateAllReports() { generateDashboard(); generatePeriodSummary(); generateTopProductsReport(); generateTopClientsReport(); generateMarginsReport(); generateLowStockReport(); generatePriceListsReport(); generateHistoryReport(); showToast('Reportes generados', 'success'); } function generateReports() { generateAllReports(); } function exportReportsToCSV() { const invoices = getInvoicesInDateRange(); const data = []; data.push(['Tipo de Reporte', 'Período', 'Fecha de Generación']); data.push(['Resumen de Ventas', `${formatDate(reportData.dateFrom)} al ${formatDate(reportData.dateTo)}`, getCurrentDateTime()]); data.push([]); data.push(['Producto', 'Cantidad', 'Ingresos', 'Costo', 'Margen']); const productStats = {}; invoices.forEach(inv => { inv.items.forEach(item => { if (!productStats[item.id]) { productStats[item.id] = { name: item.name, qty: 0, revenue: 0, cost: 0, margin: 0 }; } productStats[item.id].qty += item.qty; productStats[item.id].revenue += item.price * item.qty; const product = STATE.products.find(p => p.id === item.id); if (product) { productStats[item.id].cost += product.cost * item.qty; productStats[item.id].margin += (item.price - product.cost) * item.qty; } }); }); Object.values(productStats).forEach(p => { data.push([p.name, p.qty, p.revenue, p.cost, p.margin]); }); const csv = data.map(row => row.map(cell => `"${cell}"`).join(',')).join('\n'); downloadCSV(csv, `reportes-${formatDate(reportData.dateFrom)}.csv`); } function downloadCSV(csv, filename) { const blob = new Blob([csv], { type: 'text/csv; charset=utf-8;' }); const link = document.createElement('a'); const url = URL.createObjectURL(blob); link.setAttribute('href', url); link.setAttribute('download', filename); link.style.visibility = 'hidden'; document.body.appendChild(link); link.click(); document.body.removeChild(link); } function downloadReportsAsExcel() { try { getReportDateRange(); const wb = XLSX.utils.book_new(); const invoices = getInvoicesInDateRange(); const summary = []; const totalSales = invoices.reduce((sum, inv) => sum + inv.total, 0); const totalOrders = invoices.length; const avgTicket = totalOrders > 0 ? totalSales / totalOrders : 0; let totalMargin = 0; invoices.forEach(inv => { inv.items.forEach(item => { const product = STATE.products.find(p => p.id === item.id); if (product) { const cost = product.cost * item.qty; const gain = (item.price * item.qty) - cost; totalMargin += gain; } }); }); summary.push(['RESUMEN DEL PERÍODO', '']); summary.push(['', '']); summary.push(['Período', `${formatDate(reportData.dateFrom)} al ${formatDate(reportData.dateTo)}`]); summary.push(['Total Ventas', totalSales]); summary.push(['Cantidad Pedidos', totalOrders]); summary.push(['Ticket Promedio', avgTicket.toFixed(2)]); summary.push(['Margen Total', totalMargin.toFixed(2)]); const ws1 = XLSX.utils.aoa_to_sheet(summary); ws1['!cols'] = [{wch: 25}, {wch: 20}]; XLSX.utils.book_append_sheet(wb, ws1, 'Resumen'); const productStats = {}; invoices.forEach(inv => { inv.items.forEach(item => { if (!productStats[item.id]) { productStats[item.id] = { name: item.name, qty: 0, revenue: 0, cost: 0, margin: 0 }; } productStats[item.id].qty += item.qty; productStats[item.id].revenue += item.price * item.qty; const product = STATE.products.find(p => p.id === item.id); if (product) { productStats[item.id].cost += product.cost * item.qty; productStats[item.id].margin += (item.price - product.cost) * item.qty; } }); }); const productsData = [['Producto', 'Cantidad', 'Ingresos', 'Costo', 'Margen']]; Object.values(productStats).sort((a, b) => b.revenue - a.revenue).forEach(p => { productsData.push([p.name, p.qty, p.revenue.toFixed(2), p.cost.toFixed(2), p.margin.toFixed(2)]); }); const ws2 = XLSX.utils.aoa_to_sheet(productsData); ws2['!cols'] = [{wch: 30}, {wch: 12}, {wch: 15}, {wch: 15}, {wch: 15}]; XLSX.utils.book_append_sheet(wb, ws2, 'Productos'); const clientStats = {}; invoices.forEach(inv => { if (!clientStats[inv.clientId]) { clientStats[inv.clientId] = { name: inv.client, orders: 0, total: 0, margin: 0 }; } clientStats[inv.clientId].orders += 1; clientStats[inv.clientId].total += inv.total; let invMargin = 0; inv.items.forEach(item => { const product = STATE.products.find(p => p.id === item.id); if (product) { const cost = product.cost * item.qty; invMargin += (item.price * item.qty) - cost; } }); clientStats[inv.clientId].margin += invMargin; }); const clientsData = [['Cliente', 'Pedidos', 'Total Gastado', 'Promedio', 'Margen']]; Object.values(clientStats).sort((a, b) => b.total - a.total).forEach(c => { clientsData.push([c.name, c.orders, c.total.toFixed(2), (c.total / c.orders).toFixed(2), c.margin.toFixed(2)]); }); const ws3 = XLSX.utils.aoa_to_sheet(clientsData); ws3['!cols'] = [{wch: 30}, {wch: 10}, {wch: 15}, {wch: 15}, {wch: 15}]; XLSX.utils.book_append_sheet(wb, ws3, 'Clientes'); const filename = `reportes-${formatDate(reportData.dateFrom)}-${formatDate(reportData.dateTo)}.xlsx`; XLSX.writeFile(wb, filename); showToast('Reporte exportado a Excel', 'success'); } catch (err) { showToast('Error al exportar Excel: ' + err.message, 'error'); } } function downloadReportsAsPDF() { getReportDateRange(); const element = document.getElementById('view-admin'); const opt = { margin: 10, filename: `reportes-${formatDate(reportData.dateFrom)}.pdf`, image: { type: 'jpeg', quality: 0.98 }, html2canvas: { scale: 2 }, jsPDF: { orientation: 'landscape', unit: 'mm', format: 'a4' } }; html2pdf().set(opt).from(element).save(); showToast('Reporte exportado a PDF', 'success'); }