// /home/Autopartes/pos/static/js/dashboard.js // Dashboard module: fetches real data from POS APIs const Dashboard = (() => { function token() { return localStorage.getItem('pos_token') || ''; } function checkAuth() { if (!token()) { window.location.href = '/pos/login'; return false; } return true; } function headers() { return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' }; } function fmt(n) { return '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); } function fmtInt(n) { return parseInt(n || 0).toLocaleString('es-MX'); } /** Today as YYYY-MM-DD */ function todayStr() { return new Date().toISOString().slice(0, 10); } /** Return a date N days ago as YYYY-MM-DD */ function daysAgo(n) { const d = new Date(); d.setDate(d.getDate() - n); return d.toISOString().slice(0, 10); } const DAY_NAMES_SHORT = ['Dom', 'Lun', 'Mar', 'Mie', 'Jue', 'Vie', 'Sab']; const MONTH_NAMES = ['enero', 'febrero', 'marzo', 'abril', 'mayo', 'junio', 'julio', 'agosto', 'septiembre', 'octubre', 'noviembre', 'diciembre']; const DAY_NAMES_LONG = ['Domingo', 'Lunes', 'Martes', 'Miercoles', 'Jueves', 'Viernes', 'Sabado']; // ------------------------------------------------------------------------- // Theme switcher // ------------------------------------------------------------------------- function setTheme(theme) { document.documentElement.setAttribute('data-theme', theme); try { localStorage.setItem('pos_theme', theme); } catch(e) {} const btnInd = document.getElementById('btn-industrial'); const btnMod = document.getElementById('btn-modern'); if (btnInd) btnInd.classList.toggle('active', theme === 'industrial'); if (btnMod) btnMod.classList.toggle('active', theme === 'modern'); } window.setTheme = setTheme; // ------------------------------------------------------------------------- // Sidebar toggle (mobile) // ------------------------------------------------------------------------- function toggleSidebar() { const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); if (!sidebar) return; const isOpen = sidebar.classList.contains('open'); sidebar.classList.toggle('open', !isOpen); if (overlay) overlay.classList.toggle('open', !isOpen); document.body.style.overflow = isOpen ? '' : 'hidden'; } window.toggleSidebar = toggleSidebar; function closeSidebar() { const sidebar = document.getElementById('sidebar'); const overlay = document.getElementById('sidebar-overlay'); if (sidebar) sidebar.classList.remove('open'); if (overlay) overlay.classList.remove('open'); document.body.style.overflow = ''; } window.closeSidebar = closeSidebar; window.addEventListener('resize', function() { if (window.innerWidth >= 768) closeSidebar(); }); // ------------------------------------------------------------------------- // Period selector // ------------------------------------------------------------------------- function setPeriod(btn) { btn.closest('.period-selector').querySelectorAll('.period-btn').forEach(function(b) { b.classList.remove('active'); }); btn.classList.add('active'); const period = btn.textContent.trim().toLowerCase(); loadChart(period); } window.setPeriod = setPeriod; // ------------------------------------------------------------------------- // Generic fetch helper // ------------------------------------------------------------------------- async function apiFetch(url) { try { const resp = await fetch(url, { headers: headers() }); if (resp.status === 401) { window.location.href = '/pos/login'; return null; } if (!resp.ok) return null; return await resp.json(); } catch (err) { console.error('Dashboard fetch error:', url, err); return null; } } // ------------------------------------------------------------------------- // Set header greeting with real date and user info // ------------------------------------------------------------------------- function setGreeting() { const now = new Date(); const h = now.getHours(); let saludo = 'Buenos dias'; if (h >= 12 && h < 19) saludo = 'Buenas tardes'; else if (h >= 19) saludo = 'Buenas noches'; // Try to get user name from JWT payload let userName = ''; try { const payload = JSON.parse(atob(token().split('.')[1])); userName = payload.name || payload.sub || ''; } catch(e) {} const greetEl = document.getElementById('header-greeting'); if (greetEl) greetEl.textContent = userName ? `${saludo}, ${userName}` : saludo; const subEl = document.getElementById('header-subtitle'); if (subEl) { const dayName = DAY_NAMES_LONG[now.getDay()]; const day = now.getDate(); const month = MONTH_NAMES[now.getMonth()]; const year = now.getFullYear(); subEl.textContent = `${dayName}, ${day} de ${month} de ${year}`; } } // ------------------------------------------------------------------------- // 1. Ventas de hoy KPIs (daily summary) // ------------------------------------------------------------------------- async function loadDailySummary() { const today = todayStr(); const data = await apiFetch(`/pos/api/register/daily-summary?date=${today}`); if (!data) { setKpiError('kpi-ventas-total', 'kpi-ventas-meta'); setKpiError('kpi-tickets-count', 'kpi-tickets-meta'); setKpiError('kpi-promedio-value', 'kpi-promedio-meta'); return null; } // Ventas Hoy const totalEl = document.getElementById('kpi-ventas-total'); const metaEl = document.getElementById('kpi-ventas-meta'); if (totalEl) totalEl.textContent = fmt(data.total_sales); if (metaEl) { const methods = Object.entries(data.sales_by_method || {}) .map(([m, v]) => `${capitalize(m)}: ${fmt(v.amount)}`) .join(' | '); metaEl.innerHTML = methods ? `${methods}` : 'Sin ventas aun'; } // Tickets Hoy const ticketsEl = document.getElementById('kpi-tickets-count'); const ticketsMetaEl = document.getElementById('kpi-tickets-meta'); if (ticketsEl) ticketsEl.textContent = fmtInt(data.total_sales_count); if (ticketsMetaEl) { const cancelled = data.cancelled_count || 0; ticketsMetaEl.innerHTML = cancelled > 0 ? `${cancelled} cancelada${cancelled > 1 ? 's' : ''}de ${data.total_sales_count + cancelled} totales` : `${data.total_sales_count} completada${data.total_sales_count !== 1 ? 's' : ''}`; } // Ticket Promedio const promedioEl = document.getElementById('kpi-promedio-value'); const promedioMetaEl = document.getElementById('kpi-promedio-meta'); const avg = data.total_sales_count > 0 ? data.total_sales / data.total_sales_count : 0; if (promedioEl) promedioEl.textContent = fmt(avg); if (promedioMetaEl) { promedioMetaEl.innerHTML = `sobre ${fmtInt(data.total_sales_count)} ventas`; } // Cajas del Día KPI const cajasCountEl = document.getElementById('kpi-cajas-count'); const cajasMetaEl = document.getElementById('kpi-cajas-meta'); const regs = data.registers || []; const activeRegs = regs.filter(r => r.status === 'open'); const closedRegs = regs.filter(r => r.status === 'closed'); if (cajasCountEl) cajasCountEl.textContent = regs.length; if (cajasMetaEl) { cajasMetaEl.innerHTML = `${activeRegs.length} abierta${activeRegs.length !== 1 ? 's' : ''}${closedRegs.length} cerrada${closedRegs.length !== 1 ? 's' : ''}`; } // Also populate registers list renderRegisters(regs); return data; } // ------------------------------------------------------------------------- // 1b. Historical sales KPIs (imported data) // ------------------------------------------------------------------------- async function loadHistoricalSummary() { try { const now = new Date(); const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0).toISOString().slice(0, 10); // All historical sales const all = await apiFetch('/pos/api/historical-sales?per_page=1'); const totalRecords = all.pagination ? all.pagination.total : 0; // Current month historical sales const month = await apiFetch(`/pos/api/historical-sales?date_from=${firstDay}&date_to=${lastDay}&per_page=200`); const monthRows = month.data || []; const monthTotal = monthRows.reduce((a, r) => a + (r.total || 0), 0); const totalEl = document.getElementById('kpi-historico-total-value'); const totalMetaEl = document.getElementById('kpi-historico-total-meta'); if (totalEl) totalEl.textContent = fmt(monthTotal); if (totalMetaEl) totalMetaEl.innerHTML = `${fmtInt(totalRecords)} tickets importados`; const mesEl = document.getElementById('kpi-historico-mes-value'); const mesMetaEl = document.getElementById('kpi-historico-mes-meta'); if (mesEl) mesEl.textContent = fmt(monthTotal); if (mesMetaEl) mesMetaEl.innerHTML = `${monthRows.length} tickets este mes`; const countEl = document.getElementById('kpi-historico-count-value'); const countMetaEl = document.getElementById('kpi-historico-count-meta'); if (countEl) countEl.textContent = fmtInt(totalRecords); if (countMetaEl) countMetaEl.innerHTML = `Registros históricos`; } catch (err) { console.error('Error loading historical summary:', err); const ids = [ ['kpi-historico-total-value', 'kpi-historico-total-meta'], ['kpi-historico-mes-value', 'kpi-historico-mes-meta'], ['kpi-historico-count-value', 'kpi-historico-count-meta'], ]; ids.forEach(([v, m]) => setKpiError(v, m)); } } function setKpiError(valueId, metaId) { const v = document.getElementById(valueId); const m = document.getElementById(metaId); if (v) v.innerHTML = '--'; if (m) m.innerHTML = 'Error al cargar'; } function capitalize(s) { if (!s) return ''; return s.charAt(0).toUpperCase() + s.slice(1); } // ------------------------------------------------------------------------- // 2. Registers list // ------------------------------------------------------------------------- function renderRegisters(registers) { const container = document.getElementById('registers-list'); if (!container) return; if (!registers || registers.length === 0) { container.innerHTML = renderEmptyState({ icon: '', title: 'Sin cajas hoy', subtitle: 'Ninguna caja ha sido abierta el día de hoy.', action: 'Abrir POS' }); return; } const maxSale = Math.max(...registers.map(r => r.sale_total || 0), 1); container.innerHTML = registers.map((r, i) => { const statusClass = r.status === 'open' ? 'kpi-tag--up' : 'kpi-tag--neutral'; const statusLabel = r.status === 'open' ? 'Abierta' : 'Cerrada'; const pct = maxSale > 0 ? Math.round((r.sale_total / maxSale) * 100) : 0; return `
${i + 1}
Caja ${r.register_number || r.id}
${r.employee_name || 'Sin cajero'}  ·  ${r.sale_count || 0} ventas  ${statusLabel}
${fmt(r.sale_total)}
`; }).join(''); } // ------------------------------------------------------------------------- // 3. Inventory alerts // ------------------------------------------------------------------------- async function loadAlerts() { const data = await apiFetch('/pos/api/inventory/alerts'); const container = document.getElementById('alerts-grid'); if (!container) return; if (!data || !data.data || data.data.length === 0) { container.innerHTML = `
Sin alertas
Todo el inventario dentro de parametros normales.
`; return; } const alerts = data.data; // Group by type const zero = alerts.filter(a => a.type === 'zero'); const low = alerts.filter(a => a.type === 'low'); const over = alerts.filter(a => a.type === 'over'); let html = ''; if (zero.length > 0) { const items = zero.slice(0, 5).map(a => `${a.name}: ${a.stock} uds`).join(' · '); html += buildAlertCard('error', 'Stock Agotado', `${zero.length} items`, items, ''); } if (low.length > 0) { const items = low.slice(0, 5).map(a => `${a.name}: ${a.stock} uds (min ${a.min_stock})`).join(' · '); html += buildAlertCard('warning', 'Stock Bajo', `${low.length} items`, items, ''); } if (over.length > 0) { const items = over.slice(0, 5).map(a => `${a.name}: ${a.stock} uds (max ${a.max_stock})`).join(' · '); html += buildAlertCard('orange', 'Sobrestock', `${over.length} items`, items, ''); } container.innerHTML = html || '
Sin alertas
'; } function buildAlertCard(severity, title, badge, desc, iconSvg) { return `
${iconSvg}
${title} ${badge}
${desc}
Ver
`; } // ------------------------------------------------------------------------- // 4. Top Products (from today's sales detail) // ------------------------------------------------------------------------- async function loadTopProducts() { const today = todayStr(); // Fetch all today's sales with pagination const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&status=completed&per_page=200`); const container = document.getElementById('top-products-list'); if (!container) return; if (!data || !data.data || data.data.length === 0) { container.innerHTML = renderEmptyState({ icon: '', title: 'Sin ventas hoy', subtitle: 'Aún no hay transacciones registradas el día de hoy.', action: 'Nueva venta' }); return; } // Fetch detail for each sale to get items (up to 20 sales for performance) const salesToFetch = data.data.slice(0, 20); const details = await Promise.all( salesToFetch.map(s => apiFetch(`/pos/api/sales/${s.id}`)) ); // Aggregate items const productMap = {}; for (const sale of details) { if (!sale || !sale.items) continue; for (const item of sale.items) { const key = item.part_number || item.name; if (!productMap[key]) { productMap[key] = { name: item.name, part_number: item.part_number || '', qty: 0, revenue: 0 }; } productMap[key].qty += item.quantity || 0; productMap[key].revenue += item.subtotal || 0; } } const sorted = Object.values(productMap).sort((a, b) => b.revenue - a.revenue).slice(0, 5); if (sorted.length === 0) { container.innerHTML = renderEmptyState({ icon: '', title: 'Sin productos vendidos', subtitle: 'No hay suficiente información para mostrar el ranking.' }); return; } const maxRev = sorted[0].revenue || 1; container.innerHTML = sorted.map((p, i) => { const pct = Math.round((p.revenue / maxRev) * 100); return `
${i + 1}
${escHtml(p.name)}
${escHtml(p.part_number)}  ·  ${p.qty} pzas vendidas
${fmt(p.revenue)}
`; }).join(''); } function escHtml(s) { const div = document.createElement('div'); div.textContent = s || ''; return div.innerHTML; } // ------------------------------------------------------------------------- // Helpers for chart grouping // ------------------------------------------------------------------------- function isoWeek(date) { const tmp = new Date(date.valueOf()); const dayNum = (date.getDay() + 6) % 7; tmp.setDate(tmp.getDate() - dayNum + 3); const firstThursday = tmp.valueOf(); tmp.setMonth(0, 1); if (tmp.getDay() !== 4) { tmp.setMonth(0, 1 + ((4 - tmp.getDay()) + 7) % 7); } return 1 + Math.ceil((firstThursday - tmp) / 604800000); } function weekLabel(date) { return `Sem ${isoWeek(date)}`; } function monthLabel(date) { return MONTH_NAMES[date.getMonth()].slice(0, 3); } // ------------------------------------------------------------------------- // 5. Sales chart (today / week / month / year) // ------------------------------------------------------------------------- async function loadChart(period) { const chartEl = document.getElementById('bar-chart'); const totalEl = document.getElementById('chart-week-total'); const legendEl = document.getElementById('chart-legend'); const titleEl = document.querySelector('.chart-header .section-title'); if (!chartEl) return; period = period || 'semana'; let dateFrom, dateTo, labels = [], buckets = {}, labelOrder = []; const now = new Date(); if (period === 'hoy') { dateFrom = dateTo = todayStr(); labelOrder = ['Hoy']; buckets['Hoy'] = 0; } else if (period === 'semana') { const days = []; for (let i = 6; i >= 0; i--) { days.push(daysAgo(i)); } dateFrom = days[0]; dateTo = days[6]; days.forEach(d => { const date = new Date(d + 'T12:00:00'); const label = DAY_NAMES_SHORT[date.getDay()]; labelOrder.push(label); buckets[label] = { total: 0, date: d }; }); } else if (period === 'mes') { const year = now.getFullYear(); const month = now.getMonth(); const lastDay = new Date(year, month + 1, 0).getDate(); dateFrom = new Date(year, month, 1).toISOString().slice(0, 10); dateTo = new Date(year, month, lastDay).toISOString().slice(0, 10); for (let i = 1; i <= 4; i++) { const label = `Sem ${i}`; labelOrder.push(label); buckets[label] = { total: 0, week: i }; } } else if (period === 'año') { for (let i = 11; i >= 0; i--) { const d = new Date(now.getFullYear(), now.getMonth() - i, 1); const label = monthLabel(d); labelOrder.push(label); buckets[label] = 0; } dateFrom = new Date(now.getFullYear(), now.getMonth() - 11, 1).toISOString().slice(0, 10); dateTo = todayStr(); } // Fetch normal sales for short periods let normalByKey = {}; if (period === 'hoy' || period === 'semana') { const days = period === 'hoy' ? [todayStr()] : (function() { const arr = []; for (let i = 6; i >= 0; i--) arr.push(daysAgo(i)); return arr; })(); const summaries = await Promise.all( days.map(d => apiFetch(`/pos/api/register/daily-summary?date=${d}`)) ); days.forEach((d, i) => { const date = new Date(d + 'T12:00:00'); const key = period === 'hoy' ? 'Hoy' : DAY_NAMES_SHORT[date.getDay()]; normalByKey[key] = summaries[i] ? (summaries[i].total_sales || 0) : 0; }); } // Fetch historical sales for the range let histRows = []; try { const perPage = period === 'año' ? 2000 : 1000; const histData = await apiFetch(`/pos/api/historical-sales?date_from=${dateFrom}&date_to=${dateTo}&per_page=${perPage}`); histRows = histData.data || []; const totalPages = histData.pagination ? histData.pagination.total_pages : 1; for (let p = 2; p <= totalPages && p <= 20; p++) { const more = await apiFetch(`/pos/api/historical-sales?date_from=${dateFrom}&date_to=${dateTo}&per_page=${perPage}&page=${p}`); histRows = histRows.concat(more.data || []); } } catch (e) { histRows = []; } // Group historical sales histRows.forEach(r => { if (!r.sale_date) return; const date = new Date(r.sale_date + 'T12:00:00'); let key; if (period === 'hoy') key = 'Hoy'; else if (period === 'semana') key = DAY_NAMES_SHORT[date.getDay()]; else if (period === 'mes') { const day = date.getDate(); const weekNum = day <= 7 ? 1 : day <= 14 ? 2 : day <= 21 ? 3 : 4; key = `Sem ${weekNum}`; } else { key = monthLabel(date); } if (key) { if (typeof buckets[key] === 'object') buckets[key].total += (r.total || 0); else buckets[key] = (buckets[key] || 0) + (r.total || 0); } }); // Build chart data let chartTotal = 0; const dayData = labelOrder.map(label => { let normalTotal = normalByKey[label] || 0; let histTotal = 0; if (typeof buckets[label] === 'object') { histTotal = buckets[label].total; } else { histTotal = buckets[label] || 0; } const total = normalTotal + histTotal; chartTotal += total; const isToday = period === 'hoy' || (typeof buckets[label] === 'object' && buckets[label].date === todayStr()); return { label, total, isToday }; }); // Update labels const titles = { hoy: 'Ventas de Hoy', semana: 'Ventas Semanales', mes: 'Ventas del Mes', año: 'Ventas del Año' }; const legends = { hoy: 'Total del día', semana: 'Ventas brutas (7 días)', mes: 'Ventas brutas (4 semanas)', año: 'Ventas brutas (12 meses)' }; if (titleEl) titleEl.textContent = titles[period] || 'Ventas'; if (totalEl) { const periodLabel = period === 'hoy' ? 'Total día' : period === 'semana' ? 'Total semana' : period === 'mes' ? 'Total mes' : 'Total año'; totalEl.innerHTML = `${periodLabel}: ${fmt(chartTotal)}`; } if (legendEl) { legendEl.innerHTML = `
${legends[period]}
`; } const maxVal = Math.max(...dayData.map(d => d.total), 1); chartEl.innerHTML = dayData.map(d => { const heightPct = Math.max(Math.round((d.total / maxVal) * 100), 2); const todayClass = d.isToday ? ' today' : ''; const todayLabelStyle = d.isToday ? ' style="color:var(--color-primary);font-weight:700;"' : ''; const tooltip = d.isToday ? `${fmt(d.total)} ← Hoy` : fmt(d.total); return `
${d.label}
`; }).join(''); } // ------------------------------------------------------------------------- // 6. Recent sales table // ------------------------------------------------------------------------- async function loadRecentSales() { const today = todayStr(); const data = await apiFetch(`/pos/api/sales?date_from=${today}&date_to=${today}&per_page=10`); const tbody = document.getElementById('recent-sales-tbody'); if (!tbody) return; if (!data || !data.data || data.data.length === 0) { tbody.innerHTML = '' + renderEmptyState({ icon: '', title: 'Sin ventas hoy', subtitle: 'Aún no hay transacciones registradas.', action: 'Nueva venta' }) + ''; return; } // Fetch items for first 5 sales const salesToShow = data.data.slice(0, 5); const details = await Promise.all( salesToShow.map(s => apiFetch(`/pos/api/sales/${s.id}`)) ); tbody.innerHTML = salesToShow.map((sale, idx) => { const detail = details[idx]; const time = sale.created_at ? sale.created_at.slice(11, 16) : '--:--'; const client = sale.customer_name || 'Publico General'; const total = sale.total || 0; const method = sale.payment_method || 'efectivo'; // Build products summary from detail items let productsSummary = ''; if (detail && detail.items && detail.items.length > 0) { productsSummary = detail.items.slice(0, 3).map(it => `${escHtml(it.name)}${it.quantity > 1 ? ' (x' + it.quantity + ')' : ''}` ).join(', '); if (detail.items.length > 3) productsSummary += '...'; } const methodClass = getPaymentBadgeClass(method); const methodLabel = capitalize(method); const statusTag = sale.status === 'cancelled' ? ' [CANCELADA]' : ''; return ` ${time} ${escHtml(client)}${statusTag} ${productsSummary} ${fmt(total)} ${methodLabel} `; }).join(''); } function getPaymentBadgeClass(method) { const m = (method || '').toLowerCase(); if (m.includes('efectivo') || m === 'cash') return 'efectivo'; if (m.includes('tarjeta') || m === 'card') return 'tarjeta'; if (m.includes('transferencia') || m === 'transfer') return 'transferencia'; if (m.includes('credito') || m === 'credit') return 'credito'; return 'efectivo'; } // ------------------------------------------------------------------------- // Init // ------------------------------------------------------------------------- function init() { if (!checkAuth()) return; // Restore theme try { const saved = localStorage.getItem('pos_theme'); if (saved === 'industrial' || saved === 'modern') { setTheme(saved); } } catch(e) {} setGreeting(); // Load all data in parallel loadDailySummary(); loadHistoricalSummary(); loadAlerts(); loadTopProducts(); loadChart('semana'); loadRecentSales(); // Auto-refresh every 2 minutes setInterval(() => { loadDailySummary(); loadRecentSales(); }, 120000); } // Register Cmd+K items if (typeof registerCmdKItem === 'function') { registerCmdKItem({ group: 'Principal', label: 'Dashboard', href: '/pos/dashboard', icon: '📊' }); registerCmdKItem({ group: 'Principal', label: 'POS Ventas', href: '/pos/sale', icon: '🛒' }); registerCmdKItem({ group: 'Principal', label: 'Catálogo', href: '/pos/catalog', icon: '📁' }); registerCmdKItem({ group: 'Principal', label: 'Clientes', href: '/pos/customers', icon: '👤' }); registerCmdKItem({ group: 'Principal', label: 'Facturación', href: '/pos/invoicing', icon: '📄' }); registerCmdKItem({ group: 'Principal', label: 'Reportes', href: '/pos/reports', icon: '📈' }); registerCmdKItem({ group: 'Principal', label: 'Configuración', href: '/pos/config', icon: '⚙️' }); } document.addEventListener('DOMContentLoaded', init); return { init, setTheme }; })();