Files
Autoparts-DB/pos/static/js/dashboard.js

730 lines
34 KiB
JavaScript

// /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
? `<span class="kpi-meta-text">${methods}</span>`
: '<span class="kpi-meta-text">Sin ventas aun</span>';
}
// 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
? `<span class="kpi-tag kpi-tag--neutral">${cancelled} cancelada${cancelled > 1 ? 's' : ''}</span><span class="kpi-meta-text">de ${data.total_sales_count + cancelled} totales</span>`
: `<span class="kpi-meta-text">${data.total_sales_count} completada${data.total_sales_count !== 1 ? 's' : ''}</span>`;
}
// 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 = `<span class="kpi-meta-text">sobre ${fmtInt(data.total_sales_count)} ventas</span>`;
}
// 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 = `<span class="kpi-tag kpi-tag--up">${activeRegs.length} abierta${activeRegs.length !== 1 ? 's' : ''}</span><span class="kpi-meta-text">${closedRegs.length} cerrada${closedRegs.length !== 1 ? 's' : ''}</span>`;
}
// 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 = `<span class="kpi-meta-text">${fmtInt(totalRecords)} tickets importados</span>`;
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 = `<span class="kpi-meta-text">${monthRows.length} tickets este mes</span>`;
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 = `<span class="kpi-meta-text">Registros históricos</span>`;
} 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 = '<span style="color:var(--color-error)">--</span>';
if (m) m.innerHTML = '<span class="kpi-meta-text" style="color:var(--color-error)">Error al cargar</span>';
}
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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin cajas hoy',
subtitle: 'Ninguna caja ha sido abierta el día de hoy.',
action: '<a href="/pos/sale" class="btn btn--primary btn--sm">Abrir POS</a>'
});
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 `
<div class="rank-item">
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
<div class="rank-item__info">
<div class="rank-item__name">Caja ${r.register_number || r.id}</div>
<div class="rank-item__sub">${r.employee_name || 'Sin cajero'} &nbsp;&middot;&nbsp; ${r.sale_count || 0} ventas &nbsp;<span class="kpi-tag ${statusClass}" style="font-size:9px;padding:1px 6px;">${statusLabel}</span></div>
<div class="rank-item__bar-bg">
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
</div>
</div>
<div class="rank-item__value">${fmt(r.sale_total)}</div>
</div>`;
}).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 = `
<div class="alert-item" style="border-left:4px solid var(--color-success);justify-content:center;">
<div class="alert-icon-wrap" style="background-color:rgba(34,197,94,0.1);color:var(--color-success);">
<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><circle cx="9" cy="9" r="7" stroke="currentColor" stroke-width="1.5"/><path d="M6 9l2 2 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<div class="alert-content">
<span class="alert-title">Sin alertas</span>
<div class="alert-desc">Todo el inventario dentro de parametros normales.</div>
</div>
</div>`;
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}: <strong>${a.stock} uds</strong>`).join(' &middot; ');
html += buildAlertCard('error', 'Stock Agotado', `${zero.length} items`, items,
'<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M9 3L16.5 15H1.5L9 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9 8v3M9 12.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>');
}
if (low.length > 0) {
const items = low.slice(0, 5).map(a => `${a.name}: <strong>${a.stock} uds</strong> (min ${a.min_stock})`).join(' &middot; ');
html += buildAlertCard('warning', 'Stock Bajo', `${low.length} items`, items,
'<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><path d="M9 3L16.5 15H1.5L9 3z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/><path d="M9 8v3M9 12.5v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>');
}
if (over.length > 0) {
const items = over.slice(0, 5).map(a => `${a.name}: <strong>${a.stock} uds</strong> (max ${a.max_stock})`).join(' &middot; ');
html += buildAlertCard('orange', 'Sobrestock', `${over.length} items`, items,
'<svg width="18" height="18" viewBox="0 0 18 18" fill="none"><rect x="2" y="2" width="14" height="14" rx="2" stroke="currentColor" stroke-width="1.5"/><path d="M9 6v3M9 11v.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></svg>');
}
container.innerHTML = html || '<div class="alert-item" style="border-left:none;justify-content:center;color:var(--color-text-muted);">Sin alertas</div>';
}
function buildAlertCard(severity, title, badge, desc, iconSvg) {
return `
<div class="alert-item alert-item--${severity}">
<div class="alert-icon-wrap alert-icon-wrap--${severity}">${iconSvg}</div>
<div class="alert-content">
<div style="display:flex;align-items:center;gap:var(--space-2);margin-bottom:3px;">
<span class="alert-title">${title}</span>
<span class="alert-badge alert-badge--${severity}">${badge}</span>
</div>
<div class="alert-desc">${desc}</div>
</div>
<a href="/pos/inventory#alertas" class="alert-action" style="text-decoration:none;">Ver</a>
</div>`;
}
// -------------------------------------------------------------------------
// 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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
subtitle: 'Aún no hay transacciones registradas el día de hoy.',
action: '<a href="/pos/sale" class="btn btn--primary btn--sm">Nueva venta</a>'
});
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: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2L3 6v14a2 2 0 002 2h14a2 2 0 002-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 01-8 0"/></svg>',
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 `
<div class="rank-item">
<div class="rank-num ${i === 0 ? 'rank-num--1' : i === 1 ? 'rank-num--2' : ''}">${i + 1}</div>
<div class="rank-item__info">
<div class="rank-item__name">${escHtml(p.name)}</div>
<div class="rank-item__sub">${escHtml(p.part_number)} &nbsp;&middot;&nbsp; ${p.qty} pzas vendidas</div>
<div class="rank-item__bar-bg">
<div class="rank-item__bar-fill" style="width:${pct}%"></div>
</div>
</div>
<div class="rank-item__value">${fmt(p.revenue)}</div>
</div>`;
}).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}: <strong style="color:var(--color-primary);font-family:var(--font-mono);">${fmt(chartTotal)}</strong>`;
}
if (legendEl) {
legendEl.innerHTML = `<div class="legend-item"><div class="legend-dot"></div>${legends[period]}</div>`;
}
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 `
<div class="bar-chart__col">
<div class="bar-chart__bar-wrap">
<div class="bar-chart__bar${todayClass}" style="height:${heightPct}%" data-value="${tooltip}"></div>
</div>
<span class="bar-chart__label"${todayLabelStyle}>${d.label}</span>
</div>`;
}).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 = '<tr><td colspan="5">' + renderEmptyState({
icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><path d="M8 21h8M12 17v4"/></svg>',
title: 'Sin ventas hoy',
subtitle: 'Aún no hay transacciones registradas.',
action: '<a href="/pos/sale" class="btn btn--primary btn--sm">Nueva venta</a>'
}) + '</td></tr>';
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'
? ' <span style="color:var(--color-error);font-size:10px;font-weight:700;">[CANCELADA]</span>' : '';
return `
<tr>
<td><span class="td-time">${time}</span></td>
<td><span class="td-client">${escHtml(client)}</span>${statusTag}</td>
<td class="td-products">${productsSummary}</td>
<td><span class="td-mono">${fmt(total)}</span></td>
<td><span class="pago-badge pago-badge--${methodClass}">${methodLabel}</span></td>
</tr>`;
}).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 };
})();