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:
+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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user