From f55925fa6a5a2cb08016c83b8eba095263de9e68 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Sat, 4 Apr 2026 02:17:02 +0000 Subject: [PATCH] =?UTF-8?q?feat(pos):=20dashboard=20funcional=20con=20dato?= =?UTF-8?q?s=20reales=20=E2=80=94=20ventas,=20alertas,=20cajas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all hardcoded/demo data with live API calls: - KPIs from /pos/api/register/daily-summary (ventas, tickets, promedio, cajas) - Inventory alerts from /pos/api/inventory/alerts (zero, low, over stock) - Top products aggregated from /pos/api/sales detail items - Weekly bar chart from 7-day daily summaries - Recent sales table with real items from sale detail endpoint - Auto-refresh every 2 minutes, manual refresh button - Dynamic greeting with time-of-day and JWT user name Co-Authored-By: Claude Opus 4.6 (1M context) --- pos/static/js/dashboard.js | 439 ++++++++++++++++++++++++++++++++++- pos/templates/dashboard.html | 388 +++++-------------------------- 2 files changed, 484 insertions(+), 343 deletions(-) diff --git a/pos/static/js/dashboard.js b/pos/static/js/dashboard.js index 746b910..79611a9 100644 --- a/pos/static/js/dashboard.js +++ b/pos/static/js/dashboard.js @@ -1,5 +1,5 @@ // /home/Autopartes/pos/static/js/dashboard.js -// Dashboard module: KPIs, charts, summary data +// Dashboard module: fetches real data from POS APIs const Dashboard = (() => { function token() { @@ -19,9 +19,30 @@ const Dashboard = (() => { } function fmt(n) { - return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + 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 // ------------------------------------------------------------------------- @@ -63,7 +84,7 @@ const Dashboard = (() => { }); // ------------------------------------------------------------------------- - // Period selector + // Period selector (placeholder for future use) // ------------------------------------------------------------------------- function setPeriod(btn) { btn.closest('.period-selector').querySelectorAll('.period-btn').forEach(function(b) { @@ -74,18 +95,405 @@ const Dashboard = (() => { window.setPeriod = setPeriod; // ------------------------------------------------------------------------- - // Placeholder API calls + // Generic fetch helper // ------------------------------------------------------------------------- - async function loadSalesSummary() { - // TODO: call /pos/api/cashregister/summary or similar + 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; + } + + function setKpiError(valueId, metaId) { + const v = document.getElementById(valueId); + const m = document.getElementById(metaId); + if (v) v.textContent = '--'; + 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 = '
Sin cajas registradas hoy
'; + 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() { - // TODO: call /pos/api/inventory/products?sort=sold + 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 = '
Sin ventas hoy
'; + 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 = '
Sin productos vendidos
'; + 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; + } + + // ------------------------------------------------------------------------- + // 5. Weekly bar chart (last 7 days) + // ------------------------------------------------------------------------- + async function loadWeeklyChart() { + const chartEl = document.getElementById('bar-chart'); + const totalEl = document.getElementById('chart-week-total'); + if (!chartEl) return; + + // Fetch daily summary for each of last 7 days + const days = []; + for (let i = 6; i >= 0; i--) { + days.push(daysAgo(i)); + } + + const summaries = await Promise.all( + days.map(d => apiFetch(`/pos/api/register/daily-summary?date=${d}`)) + ); + + let weekTotal = 0; + const dayData = days.map((dateStr, i) => { + const s = summaries[i]; + const total = s ? (s.total_sales || 0) : 0; + weekTotal += total; + const d = new Date(dateStr + 'T12:00:00'); + return { + label: DAY_NAMES_SHORT[d.getDay()], + total: total, + isToday: dateStr === todayStr(), + }; + }); + + // Update week total + if (totalEl) { + totalEl.innerHTML = `Total semana: ${fmt(weekTotal)}`; + } + + 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() { - // TODO: call /pos/api/cashregister/recent + 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 = 'Sin ventas hoy'; + 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'; } // ------------------------------------------------------------------------- @@ -102,12 +510,23 @@ const Dashboard = (() => { } } catch(e) {} - loadSalesSummary(); + setGreeting(); + + // Load all data in parallel + loadDailySummary(); + loadAlerts(); loadTopProducts(); + loadWeeklyChart(); loadRecentSales(); + + // Auto-refresh every 2 minutes + setInterval(() => { + loadDailySummary(); + loadRecentSales(); + }, 120000); } document.addEventListener('DOMContentLoaded', init); - return { init, loadSalesSummary, loadTopProducts, loadRecentSales, setTheme }; + return { init, setTheme }; })(); diff --git a/pos/templates/dashboard.html b/pos/templates/dashboard.html index f28fe29..299bfd9 100644 --- a/pos/templates/dashboard.html +++ b/pos/templates/dashboard.html @@ -1422,8 +1422,8 @@
-
Buenos días, Carlos
-
Miércoles, 1 de abril de 2026  ·  Sucursal Centro
+
Buenos días
+
Cargando...
@@ -1440,7 +1440,7 @@ -