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:
@@ -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,52 +11,25 @@ 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"
|
||||
>
|
||||
→
|
||||
</button>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user