Add Chart.js integration and redesign dashboard layout with new KPIs

This commit is contained in:
javiermengual 2026-02-19 16:44:08 +01:00
parent 23c79cc7e4
commit e9b4231409
4 changed files with 772 additions and 47 deletions

21
package-lock.json generated
View File

@ -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",

View File

@ -10,5 +10,8 @@
}, },
"devDependencies": { "devDependencies": {
"vite": "^7.2.4" "vite": "^7.2.4"
},
"dependencies": {
"chart.js": "^4.5.1"
} }
} }

386
src/pages/DashboardPage.js Executable file → Normal file
View File

@ -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>&nbsp;&middot;&nbsp;` +
`<span class="db-sub-amber">${unpaid} pendientes</span>&nbsp;&middot;&nbsp;` +
`<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;
} }

View File

@ -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);
}