diff --git a/package-lock.json b/package-lock.json index 3dc0ea2..3dbed70 100755 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,9 @@ "": { "name": "doli-front", "version": "0.0.0", + "dependencies": { + "chart.js": "^4.5.1" + }, "devDependencies": { "vite": "^7.2.4" } @@ -453,6 +456,12 @@ "node": ">=18" } }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.55.1", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz", @@ -810,6 +819,18 @@ "dev": true, "license": "MIT" }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", diff --git a/package.json b/package.json index 4ff63ae..3f7e936 100755 --- a/package.json +++ b/package.json @@ -10,5 +10,8 @@ }, "devDependencies": { "vite": "^7.2.4" + }, + "dependencies": { + "chart.js": "^4.5.1" } } diff --git a/src/pages/DashboardPage.js b/src/pages/DashboardPage.js old mode 100755 new mode 100644 index 7b86cb1..5ddebde --- a/src/pages/DashboardPage.js +++ b/src/pages/DashboardPage.js @@ -1,40 +1,223 @@ import { auth } from '../services/auth.js'; +import { InvoiceModal } from '../components/InvoiceModal.js'; +import { + Chart, + BarController, + BarElement, + CategoryScale, + LinearScale, + Tooltip, + Legend +} from 'chart.js'; + +Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend); + +const API_BASE_URL = import.meta.env.VITE_API_BASE_URL; + +function getAuthHeaders() { + const token = localStorage.getItem('token'); + return { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }; +} + +async function fetchAllInvoices() { + const response = await fetch(`${API_BASE_URL}/api/Invoices?limit=500&page=1`, { + headers: getAuthHeaders() + }); + if (!response.ok) throw new Error('Error al cargar facturas'); + const data = await response.json(); + return Array.isArray(data) ? data : data.data || data.invoices || []; +} + +function calcKPIs(invoices) { + const total = invoices.length; + const totalBilled = invoices.reduce((sum, inv) => sum + (inv.total || 0), 0); + const paid = invoices.filter(inv => inv.status === 'paid').length; + const unpaid = invoices.filter(inv => inv.status === 'unpaid').length; + const draft = invoices.filter(inv => inv.status === 'draft').length; + const paidRate = total > 0 ? ((paid / total) * 100).toFixed(1) : '0.0'; + const pendingAmount = invoices.reduce((s, inv) => s + (inv.remainToPay || 0), 0); + return { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount }; +} + +function calcQuarterly(invoices) { + const year = new Date().getFullYear(); + const q = [0, 0, 0, 0]; + invoices.forEach(inv => { + if (!inv.date || !inv.total) return; + const d = new Date(inv.date); + if (d.getFullYear() !== year) return; + const m = d.getMonth(); + const idx = m <= 2 ? 0 : m <= 5 ? 1 : m <= 8 ? 2 : 3; + q[idx] += inv.total; + }); + return q; +} + +function formatCurrency(amount) { + return new Intl.NumberFormat('es-ES', { + style: 'currency', currency: 'EUR', maximumFractionDigits: 0 + }).format(amount || 0); +} + +function formatDate(dateStr) { + if (!dateStr) return '—'; + return new Date(dateStr).toLocaleDateString('es-ES', { + day: '2-digit', month: '2-digit', year: 'numeric' + }); +} + +function getStatusBadge(status) { + const map = { + draft: { label: 'Borrador', cls: 'badge-draft' }, + unpaid: { label: 'Pte. Pago', cls: 'badge-unpaid' }, + paid: { label: 'Pagada', cls: 'badge-paid' }, + }; + const s = map[status] || { label: status || '—', cls: 'badge-draft' }; + return `${s.label}`; +} + +function statusDotStyle(status) { + const map = { + paid: { bg: '#f0fdf4', color: '#16a34a' }, + unpaid: { bg: '#fffbeb', color: '#b45309' }, + draft: { bg: '#f3f4f6', color: '#6b7280' }, + }; + const s = map[status] || map.draft; + return `background:${s.bg};color:${s.color}`; +} + +function openInvoiceModal(invoiceId) { + const modal = InvoiceModal(invoiceId, null, () => {}); + document.body.appendChild(modal); +} export function renderDashboard() { - const user = auth.getUser(); + const user = auth.getUser(); + const userName = user?.identifier || user?.email || user?.username || 'Usuario'; + const year = new Date().getFullYear(); + const container = document.createElement('div'); container.className = 'dashboard'; container.innerHTML = /*html*/` -
+
-

Dashboard

-

${user?.email || 'Usuario'}

+

Buenos dias, ${userName}

+

Resumen de facturacion · ${year}

- +
-
- -
- + +
+
+
+ Facturas emitidas +
+ + + + + + +
- Facturas - Gestionar facturas -
- -
- +
+
+
+ +
+ +
+
+
+ Facturacion trimestral + ${year} +
+
+ +
+
+ +
+
+ Actividad reciente + Ver todas +
+
+ ${[...Array(6)].map(() => ` +
+
+
+
+
`).join('')} +
+
+
+ +
+
+ Ultimas facturas + Ver todas +
+
+ + + + + + + + + + + + + + + +
NumeroClienteEstadoFechaVencimientoTotalPendiente
Cargando...
+
`; @@ -43,5 +226,162 @@ export function renderDashboard() { window.location.hash = '#login'; }); + (async () => { + let invoices = []; + try { + invoices = await fetchAllInvoices(); + } catch (err) { + console.error('Dashboard: error al cargar facturas', err); + ['#db-recent-list', '#db-table-body'].forEach(sel => { + container.querySelector(sel).innerHTML = '

Error al cargar datos.

'; + }); + return; + } + + const { total, totalBilled, paid, unpaid, draft, paidRate, pendingAmount } = calcKPIs(invoices); + + // KPIs + container.querySelector('#kpi-total').textContent = total.toLocaleString('es-ES'); + container.querySelector('#kpi-total-sub').innerHTML = + `${paid} pagadas · ` + + `${unpaid} pendientes · ` + + `${draft} borrador`; + + container.querySelector('#kpi-billed').textContent = formatCurrency(totalBilled); + container.querySelector('#kpi-billed-sub').textContent = `Acumulado ${year}`; + + container.querySelector('#kpi-rate').textContent = `${paidRate}%`; + container.querySelector('#kpi-rate-sub').textContent = `${paid} de ${total} cobradas`; + + container.querySelector('#kpi-pending').textContent = formatCurrency(pendingAmount); + container.querySelector('#kpi-pending-sub').innerHTML = + pendingAmount > 0 + ? `${unpaid} factura${unpaid !== 1 ? 's' : ''} sin cobrar` + : 'Sin importes pendientes'; + + // Chart.js — quarterly bar chart + const canvas = container.querySelector('#db-quarterly-chart'); + const q = calcQuarterly(invoices); + + new Chart(canvas, { + type: 'bar', + data: { + labels: ['Q1 Ene–Mar', 'Q2 Abr–Jun', 'Q3 Jul–Sep', 'Q4 Oct–Dic'], + datasets: [{ + label: 'Facturado', + data: q, + backgroundColor: 'rgba(37, 99, 235, 0.85)', + hoverBackgroundColor: 'rgba(29, 78, 216, 1)', + borderRadius: 4, + borderSkipped: false, + barPercentage: 0.5, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: { + callbacks: { label: ctx => ` ${formatCurrency(ctx.parsed.y)}` }, + backgroundColor: '#111827', + titleColor: '#f9fafb', + bodyColor: '#d1d5db', + padding: 10, + cornerRadius: 6, + displayColors: false, + } + }, + scales: { + x: { + grid: { display: false }, + border: { display: false }, + ticks: { + color: '#6b7280', + font: { size: 12, family: 'Inter, system-ui, sans-serif' } + } + }, + y: { + grid: { color: '#f3f4f6' }, + border: { display: false }, + ticks: { + color: '#9ca3af', + font: { size: 11, family: 'Inter, system-ui, sans-serif' }, + callback: v => v >= 1000 ? `${(v / 1000).toFixed(0)}k €` : `${v} €`, + maxTicksLimit: 5, + } + } + }, + animation: { duration: 500, easing: 'easeOutQuart' } + } + }); + + // Recent activity list + const recent = [...invoices] + .sort((a, b) => new Date(b.date) - new Date(a.date)) + .slice(0, 8); + + const recentList = container.querySelector('#db-recent-list'); + if (recent.length === 0) { + recentList.innerHTML = '

No hay facturas recientes.

'; + } else { + recentList.innerHTML = recent.map(inv => ` +
+
+ + + + +
+
+ ${inv.number || `#${inv.id}`} + ${inv.clientName || '—'} +
+
+ ${formatCurrency(inv.total)} + ${formatDate(inv.date)} +
+
+ +
+
+ `).join(''); + + recentList.querySelectorAll('.db-recent-item[data-id]').forEach(el => { + const id = parseInt(el.dataset.id); + el.addEventListener('click', () => openInvoiceModal(id)); + el.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openInvoiceModal(id); }); + }); + } + + // Monitoring table – last 15 + const tableRows = [...invoices] + .sort((a, b) => new Date(b.date) - new Date(a.date)) + .slice(0, 15); + + const tbody = container.querySelector('#db-table-body'); + if (tableRows.length === 0) { + tbody.innerHTML = 'No hay facturas'; + } else { + tbody.innerHTML = tableRows.map(inv => ` + + ${inv.number || `#${inv.id}`} + ${inv.clientName || '—'} + ${getStatusBadge(inv.status)} + ${formatDate(inv.date)} + ${formatDate(inv.expireDate)} + ${formatCurrency(inv.total)} + ${formatCurrency(inv.remainToPay)} + + `).join(''); + + tbody.querySelectorAll('.db-table-row[data-id]').forEach(row => { + const id = parseInt(row.dataset.id); + row.addEventListener('click', () => openInvoiceModal(id)); + row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') openInvoiceModal(id); }); + }); + } + })(); + return container; } diff --git a/src/style.css b/src/style.css index 9b68df8..3991e59 100755 --- a/src/style.css +++ b/src/style.css @@ -1657,56 +1657,417 @@ button:disabled { } /* ============================================ - Dashboard Cards + Dashboard — New Analytics Layout ============================================ */ -.dashboard-subtitle { - color: var(--text-secondary); + +/* Header */ +.db-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + margin-bottom: var(--space-6); + gap: var(--space-4); +} + +.db-greeting { + font-size: 1.5rem; + font-weight: 700; + color: var(--text-primary); + margin: 0 0 var(--space-1) 0; + letter-spacing: -0.02em; +} + +.db-subtitle { font-size: 13px; - margin: var(--space-1) 0 0 0; + color: var(--text-secondary); + margin: 0; } -.dashboard-stats { +/* KPI row */ +.db-kpi-row { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); - gap: var(--space-3); + grid-template-columns: repeat(4, 1fr); + gap: var(--space-4); + margin-bottom: var(--space-5); } -.dashboard-card { +.db-kpi-card { background: var(--card-bg); border: 1px solid var(--border-color); border-radius: var(--radius-lg); padding: var(--space-5); - text-decoration: none; - color: var(--text-primary); + box-shadow: var(--shadow-xs); display: flex; flex-direction: column; gap: var(--space-2); - transition: border-color var(--transition-fast); + transition: box-shadow var(--transition-fast); } -.dashboard-card:hover { - border-color: var(--gray-300); - color: var(--text-primary); +.db-kpi-card:hover { + box-shadow: var(--shadow-sm); } -.dashboard-card-icon { - width: 32px; - height: 32px; +/* KPI card top row */ +.db-kpi-top { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: var(--space-2); +} + +.db-kpi-icon { + width: 28px; + height: 28px; border-radius: var(--radius-md); - background: var(--gray-50); + background: var(--gray-100); display: flex; align-items: center; justify-content: center; color: var(--text-secondary); - margin-bottom: var(--space-1); + flex-shrink: 0; } -.dashboard-card-label { +.db-kpi-label { + font-size: 11px; font-weight: 500; - font-size: 14px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; } -.dashboard-card-desc { - font-size: 13px; +.db-kpi-value { + font-size: 1.65rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.03em; + line-height: 1.1; + min-height: 2rem; +} + +.db-kpi-sub { + font-size: 12px; color: var(--text-secondary); + min-height: 1rem; +} + +.db-sub-green { color: var(--green-600); font-weight: 500; } +.db-sub-amber { color: var(--amber-600); font-weight: 500; } +.db-sub-red { color: var(--red-600); font-weight: 500; } +.db-sub-muted { color: var(--text-secondary); } + +/* Skeleton loaders */ +.db-kpi-skeleton { + display: inline-block; + background: linear-gradient(90deg, var(--gray-100) 25%, var(--gray-200) 50%, var(--gray-100) 75%); + background-size: 200% 100%; + animation: db-shimmer 1.4s infinite; + border-radius: var(--radius-sm); + width: 80px; + height: 1.6rem; +} + +.db-kpi-skeleton-sm { + width: 120px; + height: 0.8rem; +} + +@keyframes db-shimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +/* Middle row: chart + recent */ +.db-mid-row { + display: grid; + grid-template-columns: 1fr 340px; + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +/* Shared card styles */ +.db-chart-card, +.db-recent-card, +.db-table-card { + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xs); + overflow: hidden; +} + +.db-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--space-4) var(--space-5); + border-bottom: 1px solid var(--border-color); +} + +.db-card-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.db-card-badge, +.db-card-year { + font-size: 12px; + font-weight: 500; + color: var(--text-secondary); + background: var(--gray-100); + padding: 2px 8px; + border-radius: 99px; +} + +.db-see-all { + font-size: 12px; + color: var(--blue-600); + text-decoration: none; + font-weight: 500; +} + +.db-see-all:hover { text-decoration: underline; } + +/* Chart body */ +.db-chart-body { + padding: var(--space-5); + height: 260px; + position: relative; +} + +.db-chart-body canvas { + width: 100% !important; + height: 100% !important; +} + +/* Recent list */ +.db-recent-list { + overflow-y: auto; + max-height: 320px; +} + +.db-recent-item { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: background var(--transition-fast); +} + +.db-recent-item:last-child { border-bottom: none; } + +.db-recent-item:hover { background: var(--gray-50); } + +.db-recent-dot { + width: 30px; + height: 30px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.db-recent-arrow { + color: var(--gray-300); + flex-shrink: 0; + display: flex; + align-items: center; + transition: color var(--transition-fast); +} + +.db-recent-item:hover .db-recent-arrow { color: var(--gray-500); } + +.db-recent-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.db-recent-num { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-recent-client { + font-size: 12px; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.db-recent-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + flex-shrink: 0; +} + +.db-recent-amount { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.db-recent-date { + font-size: 11px; + color: var(--text-secondary); +} + +/* Monitoring table */ +.db-table-card { margin-bottom: var(--space-4); } + +.db-table-wrap { overflow-x: auto; } + +.db-monitoring-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.db-monitoring-table thead th { + padding: var(--space-3) var(--space-4); + text-align: left; + font-size: 11px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + background: var(--gray-50); + border-bottom: 1px solid var(--border-color); + white-space: nowrap; +} + +.db-th-right { text-align: right !important; } + +.db-monitoring-table thead th:first-child { padding-left: var(--space-5); } +.db-monitoring-table thead th:last-child { padding-right: var(--space-5); } + +.db-table-row { + cursor: pointer; +} + +.db-table-row td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + vertical-align: middle; + transition: background var(--transition-fast); +} + +.db-table-row:last-child td { border-bottom: none; } +.db-table-row:hover td { background: var(--gray-50); } + +.db-table-row td:first-child { padding-left: var(--space-5); } +.db-table-row td:last-child { padding-right: var(--space-5); } + +.db-cell-num { font-variant-numeric: tabular-nums; font-weight: 500; white-space: nowrap; } +.db-cell-client { max-width: 180px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.db-cell-right { text-align: right; } +.db-remain-due { color: var(--red-600); font-weight: 600; } +.db-remain-ok { color: var(--green-600); } + +.db-table-loading { + text-align: center; + padding: var(--space-8) !important; + color: var(--text-secondary); + font-size: 13px; +} + +/* Misc states */ +.db-loading-inner, +.db-no-data, +.db-error { + text-align: center; + padding: var(--space-6); + color: var(--text-secondary); + font-size: 13px; + margin: 0; +} + +.db-error { color: var(--red-600); } +.db-td-center { text-align: center; } + +/* Recent skeleton */ +.db-recent-skeleton { + pointer-events: none; +} + +.db-skel-icon { + width: 30px; + height: 30px; + border-radius: var(--radius-md); + background: linear-gradient(90deg, var(--gray-100) 25%, var(--gray-200) 50%, var(--gray-100) 75%); + background-size: 200% 100%; + animation: db-shimmer 1.4s infinite; + flex-shrink: 0; +} + +.db-skel-lines { + flex: 1; + display: flex; + flex-direction: column; + gap: 5px; +} + +.db-skel-lines span { + display: block; + height: 10px; + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--gray-100) 25%, var(--gray-200) 50%, var(--gray-100) 75%); + background-size: 200% 100%; + animation: db-shimmer 1.4s infinite; +} + +.db-skel-lines span:first-child { width: 70%; } +.db-skel-lines span:last-child { width: 50%; } + +.db-skel-right { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 5px; +} + +.db-skel-right span { + display: block; + height: 10px; + border-radius: var(--radius-sm); + background: linear-gradient(90deg, var(--gray-100) 25%, var(--gray-200) 50%, var(--gray-100) 75%); + background-size: 200% 100%; + animation: db-shimmer 1.4s infinite; +} + +.db-skel-right span:first-child { width: 60px; } +.db-skel-right span:last-child { width: 44px; } + +/* Responsive */ +@media (max-width: 1100px) { + .db-kpi-row { grid-template-columns: repeat(2, 1fr); } +} + +@media (max-width: 900px) { + .db-mid-row { grid-template-columns: 1fr; } +} + +@media (max-width: 580px) { + .db-kpi-row { grid-template-columns: 1fr; } + .db-greeting { font-size: 1.1rem; } +} + +/* Keep legacy dashboard-subtitle for possible other uses */ +.dashboard-subtitle { + color: var(--text-secondary); + font-size: 13px; + margin: var(--space-1) 0 0 0; } \ No newline at end of file