Add Chart.js integration and redesign dashboard layout with new KPIs
This commit is contained in:
parent
23c79cc7e4
commit
e9b4231409
|
|
@ -7,6 +7,9 @@
|
||||||
"": {
|
"": {
|
||||||
"name": "doli-front",
|
"name": "doli-front",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
}
|
}
|
||||||
|
|
@ -453,6 +456,12 @@
|
||||||
"node": ">=18"
|
"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": {
|
"node_modules/@rollup/rollup-android-arm-eabi": {
|
||||||
"version": "4.55.1",
|
"version": "4.55.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.55.1.tgz",
|
||||||
|
|
@ -810,6 +819,18 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.27.2",
|
"version": "0.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz",
|
||||||
|
|
|
||||||
|
|
@ -10,5 +10,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"vite": "^7.2.4"
|
"vite": "^7.2.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"chart.js": "^4.5.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,223 @@
|
||||||
import { auth } from '../services/auth.js';
|
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 `<span class="badge ${s.cls}">${s.label}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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');
|
const container = document.createElement('div');
|
||||||
container.className = 'dashboard';
|
container.className = 'dashboard';
|
||||||
|
|
||||||
container.innerHTML = /*html*/`
|
container.innerHTML = /*html*/`
|
||||||
<div class="dashboard-header">
|
<div class="db-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>Dashboard</h1>
|
<h1 class="db-greeting">Buenos dias, ${userName}</h1>
|
||||||
<p class="dashboard-subtitle">${user?.email || 'Usuario'}</p>
|
<p class="db-subtitle">Resumen de facturacion · ${year}</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="logout-button" class="logout-button">Cerrar sesión</button>
|
<button id="logout-button" class="logout-button">Cerrar sesion</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-stats">
|
|
||||||
<a href="#invoices" class="dashboard-card">
|
<div class="db-kpi-row">
|
||||||
<div class="dashboard-card-icon">
|
<div class="db-kpi-card">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>
|
<div class="db-kpi-top">
|
||||||
|
<span class="db-kpi-label">Facturas emitidas</span>
|
||||||
|
<div class="db-kpi-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
<line x1="16" y1="13" x2="8" y2="13"/>
|
||||||
|
<line x1="16" y1="17" x2="8" y2="17"/>
|
||||||
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<span class="dashboard-card-label">Facturas</span>
|
|
||||||
<span class="dashboard-card-desc">Gestionar facturas</span>
|
|
||||||
</a>
|
|
||||||
<a href="#clients" class="dashboard-card">
|
|
||||||
<div class="dashboard-card-icon">
|
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
|
|
||||||
</div>
|
</div>
|
||||||
<span class="dashboard-card-label">Clientes</span>
|
<div class="db-kpi-value" id="kpi-total"><span class="db-kpi-skeleton"></span></div>
|
||||||
<span class="dashboard-card-desc">Ver directorio</span>
|
<div class="db-kpi-sub" id="kpi-total-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
|
||||||
</a>
|
</div>
|
||||||
<a href="#create-invoice" class="dashboard-card">
|
|
||||||
<div class="dashboard-card-icon">
|
<div class="db-kpi-card">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<div class="db-kpi-top">
|
||||||
|
<span class="db-kpi-label">Total facturado</span>
|
||||||
|
<div class="db-kpi-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<line x1="12" y1="1" x2="12" y2="23"/>
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="db-kpi-value" id="kpi-billed"><span class="db-kpi-skeleton"></span></div>
|
||||||
|
<div class="db-kpi-sub" id="kpi-billed-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-kpi-card">
|
||||||
|
<div class="db-kpi-top">
|
||||||
|
<span class="db-kpi-label">Tasa de cobro</span>
|
||||||
|
<div class="db-kpi-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
|
||||||
|
<polyline points="22 4 12 14.01 9 11.01"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="db-kpi-value" id="kpi-rate"><span class="db-kpi-skeleton"></span></div>
|
||||||
|
<div class="db-kpi-sub" id="kpi-rate-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-kpi-card">
|
||||||
|
<div class="db-kpi-top">
|
||||||
|
<span class="db-kpi-label">Pendiente de cobro</span>
|
||||||
|
<div class="db-kpi-icon">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<polyline points="12 6 12 12 16 14"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="db-kpi-value" id="kpi-pending"><span class="db-kpi-skeleton"></span></div>
|
||||||
|
<div class="db-kpi-sub" id="kpi-pending-sub"><span class="db-kpi-skeleton db-kpi-skeleton-sm"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-mid-row">
|
||||||
|
<div class="db-chart-card">
|
||||||
|
<div class="db-card-header">
|
||||||
|
<span class="db-card-title">Facturacion trimestral</span>
|
||||||
|
<span class="db-card-year">${year}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-chart-body">
|
||||||
|
<canvas id="db-quarterly-chart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-recent-card">
|
||||||
|
<div class="db-card-header">
|
||||||
|
<span class="db-card-title">Actividad reciente</span>
|
||||||
|
<a href="#invoices" class="db-see-all">Ver todas</a>
|
||||||
|
</div>
|
||||||
|
<div class="db-recent-list" id="db-recent-list">
|
||||||
|
${[...Array(6)].map(() => `
|
||||||
|
<div class="db-recent-item db-recent-skeleton">
|
||||||
|
<div class="db-skel-icon"></div>
|
||||||
|
<div class="db-skel-lines"><span></span><span></span></div>
|
||||||
|
<div class="db-skel-right"><span></span><span></span></div>
|
||||||
|
</div>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="db-table-card">
|
||||||
|
<div class="db-card-header">
|
||||||
|
<span class="db-card-title">Ultimas facturas</span>
|
||||||
|
<a href="#invoices" class="db-see-all">Ver todas</a>
|
||||||
|
</div>
|
||||||
|
<div class="db-table-wrap">
|
||||||
|
<table class="db-monitoring-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Numero</th>
|
||||||
|
<th>Cliente</th>
|
||||||
|
<th>Estado</th>
|
||||||
|
<th>Fecha</th>
|
||||||
|
<th>Vencimiento</th>
|
||||||
|
<th class="db-th-right">Total</th>
|
||||||
|
<th class="db-th-right">Pendiente</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="db-table-body">
|
||||||
|
<tr><td colspan="7" class="db-table-loading">Cargando...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<span class="dashboard-card-label">Nueva factura</span>
|
|
||||||
<span class="dashboard-card-desc">Crear borrador</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
|
@ -43,5 +226,162 @@ export function renderDashboard() {
|
||||||
window.location.hash = '#login';
|
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 = '<p class="db-error">Error al cargar datos.</p>';
|
||||||
|
});
|
||||||
|
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 =
|
||||||
|
`<span class="db-sub-green">${paid} pagadas</span> · ` +
|
||||||
|
`<span class="db-sub-amber">${unpaid} pendientes</span> · ` +
|
||||||
|
`<span class="db-sub-muted">${draft} borrador</span>`;
|
||||||
|
|
||||||
|
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
|
||||||
|
? `<span class="db-sub-red">${unpaid} factura${unpaid !== 1 ? 's' : ''} sin cobrar</span>`
|
||||||
|
: '<span class="db-sub-green">Sin importes pendientes</span>';
|
||||||
|
|
||||||
|
// 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 = '<p class="db-no-data">No hay facturas recientes.</p>';
|
||||||
|
} else {
|
||||||
|
recentList.innerHTML = recent.map(inv => `
|
||||||
|
<div class="db-recent-item" data-id="${inv.id}" role="button" tabindex="0">
|
||||||
|
<div class="db-recent-dot" style="${statusDotStyle(inv.status)}">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
|
||||||
|
<polyline points="14 2 14 8 20 8"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="db-recent-info">
|
||||||
|
<span class="db-recent-num">${inv.number || `#${inv.id}`}</span>
|
||||||
|
<span class="db-recent-client">${inv.clientName || '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-recent-right">
|
||||||
|
<span class="db-recent-amount">${formatCurrency(inv.total)}</span>
|
||||||
|
<span class="db-recent-date">${formatDate(inv.date)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="db-recent-arrow">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).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 = '<tr><td colspan="7" class="db-table-loading">No hay facturas</td></tr>';
|
||||||
|
} else {
|
||||||
|
tbody.innerHTML = tableRows.map(inv => `
|
||||||
|
<tr class="db-table-row" data-id="${inv.id}" role="button" tabindex="0">
|
||||||
|
<td class="db-cell-num">${inv.number || `#${inv.id}`}</td>
|
||||||
|
<td class="db-cell-client">${inv.clientName || '—'}</td>
|
||||||
|
<td>${getStatusBadge(inv.status)}</td>
|
||||||
|
<td>${formatDate(inv.date)}</td>
|
||||||
|
<td>${formatDate(inv.expireDate)}</td>
|
||||||
|
<td class="db-cell-right db-cell-num">${formatCurrency(inv.total)}</td>
|
||||||
|
<td class="db-cell-right db-cell-num ${(inv.remainToPay || 0) > 0 ? 'db-remain-due' : 'db-remain-ok'}">${formatCurrency(inv.remainToPay)}</td>
|
||||||
|
</tr>
|
||||||
|
`).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;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
455
src/style.css
455
src/style.css
|
|
@ -1657,56 +1657,417 @@ button:disabled {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================
|
/* ============================================
|
||||||
Dashboard Cards
|
Dashboard — New Analytics Layout
|
||||||
============================================ */
|
============================================ */
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI row */
|
||||||
|
.db-kpi-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: var(--space-4);
|
||||||
|
margin-bottom: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-kpi-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
padding: var(--space-5);
|
||||||
|
box-shadow: var(--shadow-xs);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
transition: box-shadow var(--transition-fast);
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-kpi-card:hover {
|
||||||
|
box-shadow: var(--shadow-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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-100);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.db-kpi-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.dashboard-subtitle {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
margin: var(--space-1) 0 0 0;
|
margin: var(--space-1) 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-stats {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-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);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
transition: border-color var(--transition-fast);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card:hover {
|
|
||||||
border-color: var(--gray-300);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card-icon {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
background: var(--gray-50);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
margin-bottom: var(--space-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card-label {
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-card-desc {
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue