feat: Dashboard admin redesign to Quiet Luxury + client profitability widget
Replica il mock design-reference/pagina-dashboard: layout condensato a schermata singola (4 KPI + griglia 2/3-1/3), token semantici per dual-theme. - Nuova query getClientProfitability(year): valore orario reale per cliente (contrattualizzato / ore tracciate) con badge margine, target 100 €/h - Nuovo widget ClientProfitability "Analisi Oraria & Redditività Clienti" - Rewrite /admin: KPI strip + Cashflow + Redditività | Follow-up + Offerte Più Richieste. Rimossi chart mensile, card extra, barre ore/cliente - Tokenizzati ForecastChart, OffersSoldChart, FollowUpWidget, YearSelector (ora pill interattiva); rimosso MonthlyChart e prop availableYears inutile - DESIGN-SYSTEM.md: inventario + note dashboard (applied 2026-07-11) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -148,6 +148,8 @@ across all future admin pages.
|
||||
| `LeadsViewToggle` | `src/components/admin/leads/LeadsViewToggle.tsx` | Wires `SearchInput` + `SegmentedToggle` together with the table/kanban views. |
|
||||
| `MilestoneStepper` | `src/components/client/MilestoneStepper.tsx` | Client-portal horizontal milestone stepper: one node per real phase, connecting line filled by `global_progress_pct`. Done = filled `bg-primary` ✓, active = `border-primary` outline, upcoming = `bg-muted`. |
|
||||
| `LeadDetail` | `src/components/admin/leads/LeadDetail.tsx` | Lead detail page (`/admin/pipeline/[id]`): bespoke header (name + inline `StatusBadge` + action bar), asymmetric `lg:grid-cols-3` layout (1/3 profile card w/ icon-chip rows + tags, 2/3 Note/Follow-up + unified timeline). |
|
||||
| `ClientProfitability` | `src/components/admin/dashboard/ClientProfitability.tsx` | Dashboard widget: valore orario reale per cliente (`getClientProfitability` → contrattualizzato ÷ ore tracciate) con badge margine (Ottimo `bg-primary/10 text-primary` / In Linea `bg-muted`). |
|
||||
| `YearSelector` | `src/components/admin/YearSelector.tsx` | Selettore anno restilizzato a pill (`rounded-full border-border bg-card shadow-card`), frecce ←/→, valore `font-mono`. Guida i dati year-scoped della dashboard via `?year=`. |
|
||||
|
||||
**Unified timeline pattern**: activities + transcripts merged into one
|
||||
reverse-chronological "Storico delle Interazioni". Vertical connector = absolute
|
||||
@@ -157,6 +159,24 @@ expandable (`line-clamp-2` → "Mostra tutto/meno"); activity nodes are muted
|
||||
(`bg-muted border-border`). Dates are `font-mono` — activities relative
|
||||
(`formatDistanceToNow`), transcripts absolute (`d MMMM yyyy`).
|
||||
|
||||
### Admin Dashboard restyle notes
|
||||
|
||||
The `/admin` dashboard was migrated to "Quiet Luxury" (applied 2026-07-11),
|
||||
replicating `design-reference/pagina-dashboard`. It is a condensed single-screen
|
||||
layout: no in-`<main>` `PageHeader` (sidebar + shell header carry the chrome).
|
||||
|
||||
- **KPI strip**: 4 `bg-card` metric cards (Clienti Attivi · Contrattualizzato ·
|
||||
Incassato Reale · Ore Tracciate). Value `text-2xl font-bold font-mono`, label
|
||||
`text-[10px] uppercase text-muted-foreground`. Positive delta uses
|
||||
`text-emerald-600 dark:text-emerald-400`.
|
||||
- **Griglia `lg:grid-cols-3`**: 2/3 col = `ForecastChart` (istogramma flussi 12
|
||||
mesi, mese prossimo evidenziato `bg-primary`) + `ClientProfitability`; 1/3 col =
|
||||
`FollowUpWidget` (count pill amber) + `OffersSoldChart` ("Offerte Più Richieste").
|
||||
- **Year selector** interattivo (`YearSelector`) in una toolbar top-right; governa
|
||||
i KPI year-scoped + redditività. Il forecast è rolling-12-mesi (non year-scoped).
|
||||
- Widget rimossi dal legacy: chart incassi mensili, card "Da incassare"/"Clienti
|
||||
acquisiti", barre "Ore per cliente" (sostituite dalla redditività oraria).
|
||||
|
||||
### Client Portal restyle notes
|
||||
|
||||
The `/client/[token]` portal was migrated to the same "Quiet Luxury" tokens
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Dashboard — Luxury Admin CRM</title>
|
||||
<!-- Google Fonts: Plus Jakarta Sans -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<!-- Tailwind CSS CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ['Plus Jakarta Sans', 'sans-serif'],
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
dark: '#1A463C', /* Verde scuro lusso */
|
||||
darkHover: '#13342D',
|
||||
active: 'rgba(255, 255, 255, 0.08)',
|
||||
bg: '#F8F9FA',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.sidebar-transition {
|
||||
transition: width 0.35s cubic-bezier(0.4, 0, 0.2, 1), transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-brand-bg font-sans text-slate-800 antialiased min-h-screen flex overflow-x-hidden">
|
||||
|
||||
<!-- SIDEBAR -->
|
||||
<aside id="sidebar" class="w-64 bg-brand-dark text-white flex flex-col justify-between p-6 border-r border-emerald-950/20 shrink-0 sidebar-transition relative z-10">
|
||||
<div class="overflow-hidden">
|
||||
<!-- Logo e Intestazione -->
|
||||
<div class="mb-10 px-2 flex items-center justify-between">
|
||||
<span id="sidebar-logo-text" class="text-lg font-bold tracking-wider text-emerald-50 whitespace-nowrap transition-opacity duration-300">iamcavalli</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigazione Principale -->
|
||||
<nav class="space-y-1">
|
||||
<!-- Stato Attivo su Dashboard -->
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-white bg-brand-active font-medium transition-all duration-200 shadow-sm">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M4 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2H6a2 2 0 01-2-2v-4zM14 16a2 2 0 012-2h2a2 2 0 012 2v4a2 2 0 01-2 2h-2a2 2 0 01-2-2v-4z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Dashboard</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M13 10V3L4 14h7v7l9-11h-7z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Lead</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Clienti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Progetti</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Preventivi</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M7 7h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Offerte</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Catalogo</span>
|
||||
</a>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-emerald-200/70 hover:text-white hover:bg-white/5 transition-all duration-200">
|
||||
<svg class="w-4 h-4 shrink-0 text-emerald-400" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/><path d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/></svg>
|
||||
<span class="sidebar-text whitespace-nowrap transition-opacity duration-300">Impostazioni</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Area Inferiore Sidebar -->
|
||||
<div class="border-t border-white/10 pt-4 space-y-3 overflow-hidden">
|
||||
<div class="flex items-center justify-between px-3 py-1 text-xs text-emerald-200/50 whitespace-nowrap">
|
||||
<span class="sidebar-text transition-opacity duration-300">Tema</span>
|
||||
<button class="p-1 rounded-full bg-white/5 hover:bg-white/10 text-emerald-200 hover:text-white transition-all duration-200">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364-6.364l-.707.707M6.343 17.657l-.707.707m12.728 0l-.707-.707M6.343 6.343l-.707-.707M14 12a2 2 0 11-4 0 2 2 0 014 0z"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<a href="#" class="flex items-center gap-3 px-3 py-2 rounded-lg text-sm text-red-300/80 hover:text-red-200 hover:bg-red-950/20 transition-all duration-200 whitespace-nowrap">
|
||||
<svg class="w-4 h-4 shrink-0" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24"><path d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/></svg>
|
||||
<span class="sidebar-text transition-opacity duration-300">Esci</span>
|
||||
</a>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- SEZIONE PRINCIPALE -->
|
||||
<div class="flex-1 flex flex-col min-w-0">
|
||||
|
||||
<!-- BARRA DI INTESTAZIONE SUPERIORE -->
|
||||
<header class="h-16 border-b border-slate-100 bg-white px-8 flex items-center justify-between shrink-0">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- Pulsante Apri/Chiudi Sidebar -->
|
||||
<button id="sidebar-toggle" class="p-2 -ml-2 rounded-lg text-slate-500 hover:bg-slate-50 hover:text-slate-800 transition-colors focus:outline-none" title="Espandi/Comprimi Sidebar">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M4 6h16M4 12h12M4 18h16"/></svg>
|
||||
</button>
|
||||
<span class="text-xs text-slate-400 font-medium">Panoramica Aziendale</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-xs font-semibold text-slate-700 bg-slate-100 px-2.5 py-1 rounded-full">Admin Panel</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- AREA DEL CONTENUTO DASHBOARD -->
|
||||
<main class="flex-1 p-8 lg:p-10 max-w-[1400px] w-full mx-auto flex flex-col gap-8 overflow-y-auto">
|
||||
|
||||
<!-- STRISCIA METRICHE CHIAVE (KPI STRIP) -->
|
||||
<section class="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
<!-- Metric 1 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Clienti Attivi</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900">4</span>
|
||||
<span class="text-xs text-emerald-600 font-medium">+1 questo mese</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric 2 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Contrattualizzato</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900">19.000€</span>
|
||||
<span class="text-xs text-slate-400">Totale anno 2026</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric 3 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Incassato Reale</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900 text-emerald-800">5.300€</span>
|
||||
<span class="text-xs text-slate-400">28% del totale</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metric 4 -->
|
||||
<div class="bg-white p-5 rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)]">
|
||||
<span class="text-[10px] uppercase font-bold text-slate-400 tracking-wider">Ore Tracciate</span>
|
||||
<div class="flex items-baseline gap-2 mt-1">
|
||||
<span class="text-2xl font-bold text-slate-900">31.5h</span>
|
||||
<span class="text-xs text-slate-400">Su progetti attivi</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- SEZIONE CENTRALE: GRIGLIA CONTENUTI -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
<!-- COLONNA COMPLESSA (2/3): Grafico Cashflow & Analisi Profitto -->
|
||||
<div class="lg:col-span-2 flex flex-col gap-8">
|
||||
|
||||
<!-- Grafico Cashflow Previsionale (12 Mesi) -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400">Previsione Flussi di Cassa (12 Mesi)</h3>
|
||||
<p class="text-[11px] text-slate-400 mt-0.5">Stima basata su retainer attivi e saldi dilazionati</p>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-slate-800 bg-slate-50 px-2.5 py-1 rounded border border-slate-100 font-mono">2026</span>
|
||||
</div>
|
||||
|
||||
<!-- Istogramma Semplificato (Rappresentazione SVG pulita per stabilità di rendering) -->
|
||||
<div class="h-48 flex items-end justify-between gap-2 pt-4 border-b border-slate-100">
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Lug: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Lug</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-brand-dark rounded-t h-40 hover:bg-brand-dark transition-all" title="Ago: €1.200"></div>
|
||||
<span class="text-[10px] text-slate-900 font-bold">Ago</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-24 hover:bg-brand-dark transition-all" title="Set: €400"></div>
|
||||
<span class="text-[10px] text-slate-400">Set</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Ott: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Ott</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Nov: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Nov</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Dic: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Dic</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Gen: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Gen</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-24 hover:bg-brand-dark transition-all" title="Feb: €450"></div>
|
||||
<span class="text-[10px] text-slate-400">Feb</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Mar: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Mar</span>
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col items-center gap-2">
|
||||
<div class="w-full bg-slate-200/80 rounded-t h-20 hover:bg-brand-dark transition-all" title="Apr: €200"></div>
|
||||
<span class="text-[10px] text-slate-400">Apr</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analisi di Profitto Orario Effettivo (Nuovo Upgrade Strategico) -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-6">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Analisi Oraria & Redditività Clienti</h3>
|
||||
<div class="space-y-4">
|
||||
<!-- Item 1 -->
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-slate-100 bg-slate-50/30">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-slate-800">Mario Rossi (Web Domination)</h4>
|
||||
<p class="text-[10px] text-slate-400">Tracciate: 30.0h — Valore Orario Target: €100/h</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs font-bold text-emerald-700 bg-emerald-50 px-2 py-0.5 rounded border border-emerald-100">€233,33 /h Reali</span>
|
||||
<p class="text-[9px] text-slate-400 mt-1">Margine Ottimo</p>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Item 2 -->
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-slate-100 bg-slate-50/30">
|
||||
<div>
|
||||
<h4 class="text-xs font-semibold text-slate-800">Gianfranco Barban (Consulenza)</h4>
|
||||
<p class="text-[10px] text-slate-400">Tracciate: 1.5h — Valore Orario Target: €100/h</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-xs font-bold text-slate-700 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">€133,33 /h Reali</span>
|
||||
<p class="text-[9px] text-slate-400 mt-1">In Linea</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COLONNA COMPATTA (1/3): Follow-up, Offerte più vendute -->
|
||||
<div class="lg:col-span-1 flex flex-col gap-8">
|
||||
|
||||
<!-- Box Operativo: Follow-up -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-5">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400">Follow-up Attivi</h3>
|
||||
<span class="text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 px-1.5 py-0.5 rounded-full">2</span>
|
||||
</div>
|
||||
|
||||
<!-- Lista Contatti da Richiamare -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 border border-slate-100 rounded-lg hover:border-slate-200 transition-colors">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-slate-800">Nome Cognome</h4>
|
||||
<p class="text-[10px] text-slate-400">Azienda Srl — Contattare</p>
|
||||
</div>
|
||||
<a href="#" class="text-[11px] font-bold text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-0.5">
|
||||
Apri ➔
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-3 border border-slate-100 rounded-lg hover:border-slate-200 transition-colors">
|
||||
<div>
|
||||
<h4 class="text-xs font-bold text-slate-800">Mario Rossi</h4>
|
||||
<p class="text-[10px] text-slate-400">Fissare Kickoff</p>
|
||||
</div>
|
||||
<a href="#" class="text-[11px] font-bold text-brand-dark hover:text-brand-darkHover inline-flex items-center gap-0.5">
|
||||
Apri ➔
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Distrubuzione Offerte Vendute -->
|
||||
<div class="bg-white rounded-xl border border-slate-100 shadow-[0_4px_20px_rgba(0,0,0,0.01)] p-5">
|
||||
<h3 class="text-xs font-bold uppercase tracking-wider text-slate-400 mb-4">Offerte Più Richieste</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-600 mb-1">
|
||||
<span class="font-semibold text-slate-800">Web Domination (Tier B)</span>
|
||||
<span class="font-mono">1</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-brand-dark h-full" style="width: 50%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-xs text-slate-600 mb-1">
|
||||
<span class="font-semibold text-slate-800">Mantenimento (Tier A)</span>
|
||||
<span class="font-mono">1</span>
|
||||
</div>
|
||||
<div class="w-full bg-slate-100 h-1.5 rounded-full overflow-hidden">
|
||||
<div class="bg-brand-dark h-full" style="width: 50%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- SCRIPT DI LOGICA (Gestione Sidebar) -->
|
||||
<script>
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const toggleButton = document.getElementById('sidebar-toggle');
|
||||
const logoText = document.getElementById('sidebar-logo-text');
|
||||
const sidebarTexts = document.querySelectorAll('.sidebar-text');
|
||||
|
||||
toggleButton.addEventListener('click', () => {
|
||||
const isCollapsed = sidebar.classList.contains('w-20');
|
||||
|
||||
if (isCollapsed) {
|
||||
sidebar.classList.remove('w-20');
|
||||
sidebar.classList.add('w-64');
|
||||
setTimeout(() => {
|
||||
logoText.classList.remove('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.remove('opacity-0'));
|
||||
}, 150);
|
||||
} else {
|
||||
logoText.classList.add('opacity-0');
|
||||
sidebarTexts.forEach(text => text.classList.add('opacity-0'));
|
||||
sidebar.classList.remove('w-64');
|
||||
sidebar.classList.add('w-20');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+68
-173
@@ -3,16 +3,14 @@ import { getDashboardStats } from "@/lib/dashboard-queries";
|
||||
import { FollowUpWidget } from "@/components/admin/dashboard/FollowUpWidget";
|
||||
import {
|
||||
getAnalyticsByYear,
|
||||
getMonthlyCollected,
|
||||
getAvailableYears,
|
||||
getTimeByClient,
|
||||
getTotalTrackedHours,
|
||||
getClientProfitability,
|
||||
} from "@/lib/analytics-queries";
|
||||
import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/forecast-queries";
|
||||
import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
||||
import { YearSelector } from "@/components/admin/YearSelector";
|
||||
import { ForecastChart } from "@/components/admin/ForecastChart";
|
||||
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
||||
import { PageHeader } from "@/components/admin/PageHeader";
|
||||
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
@@ -20,55 +18,45 @@ function MetricCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
positive,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
sub?: string;
|
||||
accent?: boolean;
|
||||
positive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`rounded-xl border p-5 ${
|
||||
accent ? "bg-[#1A463C] border-[#1A463C] text-white" : "bg-white border-[#e5e7eb]"
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-xs font-bold uppercase tracking-wider mb-2 ${
|
||||
accent ? "text-white/60" : "text-[#71717a]"
|
||||
}`}
|
||||
>
|
||||
<div className="bg-card p-5 rounded-xl border border-border shadow-card">
|
||||
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
|
||||
{label}
|
||||
</p>
|
||||
<p
|
||||
className={`text-2xl font-bold tracking-tight ${
|
||||
accent ? "text-white" : "text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
{sub && (
|
||||
<p className={`text-xs mt-1 ${accent ? "text-white/60" : "text-[#71717a]"}`}>{sub}</p>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-2 mt-1">
|
||||
<span className="text-2xl font-bold text-foreground font-mono">{value}</span>
|
||||
{sub && (
|
||||
<span
|
||||
className={`text-xs ${
|
||||
positive
|
||||
? "font-medium text-emerald-600 dark:text-emerald-400"
|
||||
: "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{sub}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtEur(n: number) {
|
||||
function fmtEur0(n: number) {
|
||||
return n.toLocaleString("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function fmtSeconds(s: number): string {
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
return `${m}m`;
|
||||
}
|
||||
|
||||
export default async function AdminDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -79,155 +67,62 @@ export default async function AdminDashboard({
|
||||
|
||||
const { kpi } = await getDashboardStats();
|
||||
|
||||
const [data, monthly, availableYears, timeByClient, totalHours, forecast, offersSold] =
|
||||
await Promise.all([
|
||||
getAnalyticsByYear(year),
|
||||
getMonthlyCollected(year),
|
||||
getAvailableYears(),
|
||||
getTimeByClient(year),
|
||||
getTotalTrackedHours(year),
|
||||
getRevenueForecast12Months(),
|
||||
getOffersSoldBreakdown(),
|
||||
]);
|
||||
const [data, totalHours, profitability, forecast, offersSold] = await Promise.all([
|
||||
getAnalyticsByYear(year),
|
||||
getTotalTrackedHours(year),
|
||||
getClientProfitability(year),
|
||||
getRevenueForecast12Months(),
|
||||
getOffersSoldBreakdown(),
|
||||
]);
|
||||
|
||||
const collectedPct =
|
||||
data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0;
|
||||
|
||||
const maxClientSeconds = timeByClient[0]?.totalSeconds ?? 1;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="Dashboard" />
|
||||
|
||||
{/* Follow-up Widget */}
|
||||
<div className="mb-8">
|
||||
<Suspense fallback={<div className="animate-pulse h-40 bg-gray-200 rounded-lg" />}>
|
||||
<FollowUpWidget />
|
||||
</Suspense>
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Toolbar: selettore anno (governa i KPI year-scoped + redditività) */}
|
||||
<div className="flex items-center justify-end">
|
||||
<YearSelector currentYear={year} />
|
||||
</div>
|
||||
|
||||
{/* ── SEZIONE STATISTICHE ── */}
|
||||
<div className="space-y-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-gray-900">Statistiche</h2>
|
||||
<p className="text-sm text-gray-400 mt-0.5">Panoramica per anno</p>
|
||||
</div>
|
||||
<YearSelector currentYear={year} availableYears={availableYears} />
|
||||
</div>
|
||||
{/* KPI strip */}
|
||||
<section className="grid grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
<MetricCard label="Clienti Attivi" value={String(kpi.clientiAttivi)} sub="Totale corrente" />
|
||||
<MetricCard
|
||||
label="Contrattualizzato"
|
||||
value={fmtEur0(data.contracted)}
|
||||
sub={`Totale anno ${year}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Incassato Reale"
|
||||
value={fmtEur0(data.collected)}
|
||||
sub={`${collectedPct}% del totale`}
|
||||
positive={data.collected > 0}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Ore Tracciate"
|
||||
value={`${totalHours}h`}
|
||||
sub="Su progetti attivi"
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Panoramica corrente (non per-anno) */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Panoramica</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard label="Clienti attivi" value={String(kpi.clientiAttivi)} sub="Totale corrente" />
|
||||
<MetricCard
|
||||
label="Progetti in corso"
|
||||
value={String(kpi.progettiInCorso)}
|
||||
sub="Non archiviati"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Previsione incassi */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
|
||||
Previsione incassi (12 mesi)
|
||||
</h3>
|
||||
{/* Griglia contenuti: 2/3 + 1/3 */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
{/* Colonna 2/3 */}
|
||||
<div className="lg:col-span-2 flex flex-col gap-8">
|
||||
<ForecastChart data={forecast} />
|
||||
<p className="text-xs text-[#a1a1aa] italic">
|
||||
Stima dalle offerte attive: i retainer contribuiscono il canone mensile, le una-tantum
|
||||
ripartite sulla durata.
|
||||
</p>
|
||||
<ClientProfitability data={profitability} />
|
||||
</div>
|
||||
|
||||
{/* Offerte vendute */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
|
||||
Offerte vendute
|
||||
</h3>
|
||||
{/* Colonna 1/3 */}
|
||||
<div className="lg:col-span-1 flex flex-col gap-8">
|
||||
<Suspense
|
||||
fallback={<div className="animate-pulse h-40 bg-muted rounded-xl" />}
|
||||
>
|
||||
<FollowUpWidget />
|
||||
</Suspense>
|
||||
<OffersSoldChart data={offersSold} />
|
||||
</div>
|
||||
|
||||
{/* Sezione economica */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard
|
||||
label="Contrattualizzato"
|
||||
value={fmtEur(data.contracted)}
|
||||
sub={`${data.clientsAcquired} client${data.clientsAcquired === 1 ? "e" : "i"}`}
|
||||
accent
|
||||
/>
|
||||
<MetricCard
|
||||
label="Incassato"
|
||||
value={fmtEur(data.collected)}
|
||||
sub={`${collectedPct}% del contrattualizzato`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Da incassare"
|
||||
value={fmtEur(data.pending)}
|
||||
sub="Tutti gli anni"
|
||||
/>
|
||||
<MetricCard
|
||||
label="Clienti acquisiti"
|
||||
value={String(data.clientsAcquired)}
|
||||
sub={`Anno ${year}`}
|
||||
/>
|
||||
</div>
|
||||
<MonthlyChart data={monthly} year={year} />
|
||||
{data.contracted === 0 && (
|
||||
<p className="text-sm text-[#71717a] italic text-center py-2">
|
||||
Nessun cliente registrato nel {year}.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sezione time tracking */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
|
||||
Tempo tracciato
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<MetricCard label="Ore totali" value={`${totalHours}h`} sub={`Anno ${year}`} accent />
|
||||
</div>
|
||||
|
||||
{timeByClient.length === 0 ? (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
|
||||
<p className="text-sm text-[#71717a] italic">
|
||||
Nessuna sessione registrata nel {year}. Usa il timer ▶ nella lista clienti per
|
||||
iniziare.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
|
||||
<h3 className="text-sm font-bold text-[#1a1a1a]">Ore per cliente — {year}</h3>
|
||||
<div className="space-y-3">
|
||||
{timeByClient.map((row) => {
|
||||
const pct = Math.round((row.totalSeconds / maxClientSeconds) * 100);
|
||||
return (
|
||||
<div key={row.clientId}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-[#1a1a1a]">
|
||||
{row.clientName}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-[#71717a]">
|
||||
{fmtSeconds(row.totalSeconds)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#1A463C] transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -15,22 +15,29 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
|
||||
const nextMonth = data[1];
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-5">
|
||||
{nextMonth && (
|
||||
<div className="flex items-baseline justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-[#71717a]">
|
||||
Previsto il mese prossimo
|
||||
</p>
|
||||
<p className="text-xs text-[#a1a1aa] mt-0.5 capitalize">{nextMonth.label}</p>
|
||||
</div>
|
||||
<p className="text-2xl font-bold tracking-tight text-[#1A463C]">
|
||||
{fmtEur(nextMonth.total)}
|
||||
<div className="bg-card rounded-xl border border-border shadow-card p-6 space-y-6">
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Previsione Flussi di Cassa (12 Mesi)
|
||||
</h3>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">
|
||||
Stima basata su retainer attivi e saldi dilazionati
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{nextMonth && (
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold tracking-tight text-primary font-mono">
|
||||
{fmtEur(nextMonth.total)}
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5 capitalize">
|
||||
Previsto {nextMonth.label}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2 h-44 border-t border-[#f4f4f5] pt-4">
|
||||
<div className="flex items-end gap-2 h-44 border-t border-border pt-4">
|
||||
{data.map((m, i) => {
|
||||
const pct = Math.round((m.total / max) * 100);
|
||||
const isNext = i === 1;
|
||||
@@ -39,7 +46,7 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
|
||||
<div
|
||||
className={`w-full rounded-t-md transition-all ${
|
||||
isNext ? "bg-[#1A463C]" : "bg-[#1A463C]/35"
|
||||
isNext ? "bg-primary" : "bg-primary/35"
|
||||
}`}
|
||||
style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }}
|
||||
title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`}
|
||||
@@ -47,7 +54,7 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
|
||||
</div>
|
||||
<span
|
||||
className={`text-[10px] capitalize ${
|
||||
isNext ? "font-bold text-[#1A463C]" : "text-[#71717a]"
|
||||
isNext ? "font-bold text-primary" : "text-muted-foreground"
|
||||
}`}
|
||||
>
|
||||
{m.label.split(" ")[0]}
|
||||
|
||||
@@ -5,39 +5,41 @@ export function OffersSoldChart({ data }: { data: OffersSoldBreakdown }) {
|
||||
|
||||
if (data.rows.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
|
||||
<p className="text-sm text-[#71717a] italic">Nessuna offerta venduta finora.</p>
|
||||
<div className="bg-card rounded-xl border border-border shadow-card p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground italic">Nessuna offerta venduta finora.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
|
||||
<div className="bg-card rounded-xl border border-border shadow-card p-5 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-bold text-[#1a1a1a]">Offerte vendute</h3>
|
||||
<span className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-[#1A463C] text-white text-xs font-bold">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Offerte Più Richieste
|
||||
</h3>
|
||||
<span className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-primary text-primary-foreground text-xs font-bold font-mono">
|
||||
{data.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-4">
|
||||
{data.rows.map((row, i) => {
|
||||
const pct = Math.round((row.count / max) * 100);
|
||||
return (
|
||||
<div key={`${row.offer_name}-${row.tier_letter ?? "—"}-${i}`}>
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<span className="text-sm font-medium text-[#1a1a1a] truncate">
|
||||
<span className="text-sm font-medium text-foreground truncate">
|
||||
{row.offer_name}
|
||||
{row.tier_letter && (
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
|
||||
<span className="ml-2 inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
|
||||
Tier {row.tier_letter}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-sm tabular-nums text-[#71717a] shrink-0">{row.count}</span>
|
||||
<span className="text-sm font-mono text-muted-foreground shrink-0">{row.count}</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-[#1A463C] transition-all"
|
||||
className="h-full rounded-full bg-primary transition-all"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -2,15 +2,7 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const MONTHS = ["Gen", "Feb", "Mar", "Apr", "Mag", "Giu", "Lug", "Ago", "Set", "Ott", "Nov", "Dic"];
|
||||
|
||||
export function YearSelector({
|
||||
currentYear,
|
||||
availableYears,
|
||||
}: {
|
||||
currentYear: number;
|
||||
availableYears: number[];
|
||||
}) {
|
||||
export function YearSelector({ currentYear }: { currentYear: number }) {
|
||||
const router = useRouter();
|
||||
const thisYear = new Date().getFullYear();
|
||||
|
||||
@@ -19,21 +11,21 @@ export function YearSelector({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex items-center gap-1 rounded-full border border-border bg-card px-1 py-1 shadow-card">
|
||||
<button
|
||||
onClick={() => go(currentYear - 1)}
|
||||
className="w-8 h-8 rounded-lg border border-[#e5e7eb] flex items-center justify-center text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C] transition-colors"
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-primary transition-colors"
|
||||
aria-label="Anno precedente"
|
||||
>
|
||||
←
|
||||
</button>
|
||||
<span className="text-lg font-bold text-[#1a1a1a] tabular-nums w-14 text-center">
|
||||
<span className="text-xs font-bold text-foreground font-mono w-10 text-center">
|
||||
{currentYear}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => go(currentYear + 1)}
|
||||
disabled={currentYear >= thisYear}
|
||||
className="w-8 h-8 rounded-lg border border-[#e5e7eb] flex items-center justify-center text-[#71717a] hover:border-[#1A463C] hover:text-[#1A463C] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
className="w-6 h-6 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
aria-label="Anno successivo"
|
||||
>
|
||||
→
|
||||
@@ -41,30 +33,3 @@ export function YearSelector({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonthlyChart({ data, year }: { data: number[]; year: number }) {
|
||||
const max = Math.max(...data, 1);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||
<h3 className="text-sm font-bold text-[#1a1a1a] mb-6">Incassato mese per mese — {year}</h3>
|
||||
<div className="flex items-end gap-2 h-40">
|
||||
{data.map((val, i) => {
|
||||
const pct = Math.round((val / max) * 100);
|
||||
return (
|
||||
<div key={i} className="flex-1 flex flex-col items-center gap-1">
|
||||
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}>
|
||||
<div
|
||||
className="w-full rounded-t-md bg-[#1A463C] transition-all"
|
||||
style={{ height: `${pct}%`, minHeight: val > 0 ? "4px" : "0" }}
|
||||
title={`€ ${val.toLocaleString("it-IT", { minimumFractionDigits: 2 })}`}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-[#71717a]">{MONTHS[i]}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { ClientProfitabilityRow } from "@/lib/analytics-queries";
|
||||
|
||||
function fmtRate(n: number) {
|
||||
return n.toLocaleString("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const MARGIN_LABEL: Record<ClientProfitabilityRow["margin"], string> = {
|
||||
ottimo: "Margine Ottimo",
|
||||
in_linea: "In Linea",
|
||||
sotto: "Sotto Target",
|
||||
};
|
||||
|
||||
export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }) {
|
||||
return (
|
||||
<div className="bg-card rounded-xl border border-border shadow-card p-6">
|
||||
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4">
|
||||
Analisi Oraria & Redditività Clienti
|
||||
</h3>
|
||||
|
||||
{data.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground italic text-center py-4">
|
||||
Nessuna sessione tracciata per calcolare la redditività.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{data.map((row) => {
|
||||
const isOttimo = row.margin === "ottimo";
|
||||
return (
|
||||
<div
|
||||
key={row.clientId}
|
||||
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border bg-muted/30"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-xs font-semibold text-foreground truncate">
|
||||
{row.clientName}
|
||||
</h4>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
Tracciate:{" "}
|
||||
<span className="font-mono">{row.hours.toLocaleString("it-IT")}h</span> — Valore
|
||||
Orario Target:{" "}
|
||||
<span className="font-mono">{row.target}€/h</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span
|
||||
className={`text-xs font-bold font-mono px-2 py-0.5 rounded border ${
|
||||
isOttimo
|
||||
? "text-primary bg-primary/10 border-primary/20"
|
||||
: "text-foreground bg-muted border-border"
|
||||
}`}
|
||||
>
|
||||
{fmtRate(row.realRate)} /h Reali
|
||||
</span>
|
||||
<p className="text-[9px] text-muted-foreground mt-1">{MARGIN_LABEL[row.margin]}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,11 +25,13 @@ export async function FollowUpWidget() {
|
||||
|
||||
if (leads.length === 0) {
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] px-5 py-4">
|
||||
<div className="bg-card rounded-xl border border-border shadow-card px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Follow-up Attivi
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-sm text-[#71717a] mt-2">Tutti i lead sono aggiornati ✓</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">Tutti i lead sono aggiornati ✓</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -37,37 +39,39 @@ export async function FollowUpWidget() {
|
||||
const visible = leads.slice(0, MAX_ROWS);
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
|
||||
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-[#f4f4f5]">
|
||||
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="text-sm font-bold text-[#1a1a1a]">Follow-up</h2>
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-[#1A463C] text-white text-[11px] font-bold">
|
||||
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
|
||||
Follow-up Attivi
|
||||
</h2>
|
||||
<span className="inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 rounded-full bg-amber-50 text-amber-700 border border-amber-100 text-[11px] font-bold dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20">
|
||||
{leads.length}
|
||||
</span>
|
||||
</div>
|
||||
<Link
|
||||
href="/admin/pipeline"
|
||||
className="text-xs font-medium text-[#71717a] hover:text-[#1A463C] transition-colors"
|
||||
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
|
||||
>
|
||||
Vedi tutti →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Lead rows */}
|
||||
<ul className="divide-y divide-[#f4f4f5]">
|
||||
<ul className="divide-y divide-border">
|
||||
{visible.map((lead) => (
|
||||
<li key={lead.id}>
|
||||
<Link
|
||||
href={`/admin/pipeline/${lead.id}`}
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-[#f9f9f9] transition-colors group"
|
||||
className="flex items-center gap-3 px-5 py-3 hover:bg-muted/40 transition-colors group"
|
||||
>
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-[#1A463C]/10 text-[#1A463C] text-xs font-bold">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-bold">
|
||||
{initials(lead.name)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-[#1a1a1a] truncate">{lead.name}</p>
|
||||
<p className="text-xs text-[#71717a] truncate">
|
||||
<p className="text-sm font-semibold text-foreground truncate">{lead.name}</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{lead.next_action
|
||||
? lead.next_action
|
||||
: [lead.company, relativeDays(lead.last_contact_date)]
|
||||
@@ -75,7 +79,7 @@ export async function FollowUpWidget() {
|
||||
.join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-[#71717a] group-hover:text-[#1A463C] transition-colors shrink-0">
|
||||
<span className="text-xs font-medium text-muted-foreground group-hover:text-primary transition-colors shrink-0">
|
||||
Contatta →
|
||||
</span>
|
||||
</Link>
|
||||
@@ -86,7 +90,7 @@ export async function FollowUpWidget() {
|
||||
{leads.length > MAX_ROWS && (
|
||||
<Link
|
||||
href="/admin/pipeline"
|
||||
className="block px-5 py-2.5 text-center text-xs font-medium text-[#71717a] hover:text-[#1A463C] border-t border-[#f4f4f5] transition-colors"
|
||||
className="block px-5 py-2.5 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border transition-colors"
|
||||
>
|
||||
Vedi tutti i {leads.length} lead
|
||||
</Link>
|
||||
|
||||
@@ -132,3 +132,63 @@ export async function getTotalTrackedHours(year: number): Promise<number> {
|
||||
);
|
||||
return Math.round(parseInt(row?.total ?? "0") / 3600 * 10) / 10;
|
||||
}
|
||||
|
||||
// ── Redditività oraria per cliente ──────────────────────────────────────────
|
||||
|
||||
/** Valore orario "target" di riferimento (€/h) — costante di consulenza. */
|
||||
export const TARGET_HOURLY = 100;
|
||||
|
||||
export type ProfitabilityMargin = "ottimo" | "in_linea" | "sotto";
|
||||
|
||||
export type ClientProfitabilityRow = {
|
||||
clientId: string;
|
||||
clientName: string;
|
||||
hours: number; // ore tracciate (anno), 1 decimale
|
||||
contracted: number; // totale contrattualizzato del cliente (accepted_total dei progetti)
|
||||
realRate: number; // contracted / hours, arrotondato a 2 decimali
|
||||
target: number; // TARGET_HOURLY
|
||||
margin: ProfitabilityMargin;
|
||||
};
|
||||
|
||||
/**
|
||||
* Redditività oraria reale per cliente: contrattualizzato ÷ ore tracciate (anno).
|
||||
* Solo clienti con ore tracciate > 0 (serve il denominatore). Ordinati per realRate desc.
|
||||
*/
|
||||
export async function getClientProfitability(year: number): Promise<ClientProfitabilityRow[]> {
|
||||
const timeRows = await getTimeByClient(year);
|
||||
if (timeRows.length === 0) return [];
|
||||
|
||||
const clientIds = timeRows.map((r) => r.clientId);
|
||||
|
||||
// Contrattualizzato per cliente = somma accepted_total dei suoi progetti.
|
||||
const contractRows = await db
|
||||
.select({
|
||||
client_id: projects.client_id,
|
||||
total: sql<string>`coalesce(sum(${projects.accepted_total}::numeric), 0)`,
|
||||
})
|
||||
.from(projects)
|
||||
.where(inArray(projects.client_id, clientIds))
|
||||
.groupBy(projects.client_id);
|
||||
const contractedMap = new Map(
|
||||
contractRows.map((r) => [r.client_id, parseFloat(r.total)])
|
||||
);
|
||||
|
||||
return timeRows
|
||||
.map((row) => {
|
||||
const hours = Math.round((row.totalSeconds / 3600) * 10) / 10;
|
||||
const contracted = contractedMap.get(row.clientId) ?? 0;
|
||||
const realRate = hours > 0 ? Math.round((contracted / hours) * 100) / 100 : 0;
|
||||
const margin: ProfitabilityMargin =
|
||||
realRate >= TARGET_HOURLY * 2 ? "ottimo" : realRate >= TARGET_HOURLY ? "in_linea" : "sotto";
|
||||
return {
|
||||
clientId: row.clientId,
|
||||
clientName: row.clientName,
|
||||
hours,
|
||||
contracted,
|
||||
realRate,
|
||||
target: TARGET_HOURLY,
|
||||
margin,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.realRate - a.realRate);
|
||||
}
|
||||
Reference in New Issue
Block a user