style: rifinitura Quiet Luxury dashboard — bordi soft, badge, colori

Allinea la dashboard alla rifinitura del mock (finezza bordi/colori/badge):

- Card su border-border-light (slate-100) per il bordo sottile
- KPI: valore verde su Incassato Reale, delta verde "+N questo mese" su
  Clienti Attivi (nuova metrica getDashboardStats.clientiNuoviMese)
- Redditività: badge "Ottimo" con tint emerald soft (non brand primary)
- Follow-up: righe come box bordati (no avatar), link "Apri →" primary
- Forecast: selettore anno (pill interattiva) spostato nell'header della card,
  barre non-picco grigie (bg-border), rimosso il numero "mese prossimo"
- Offerte Più Richieste: rimossa la pill totale nell'header

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-11 12:18:10 +02:00
parent 08aadc1d97
commit a20a9de2d7
7 changed files with 95 additions and 129 deletions
+26 -28
View File
@@ -17,32 +17,34 @@ export const revalidate = 0;
function MetricCard({ function MetricCard({
label, label,
value, value,
valueAccent,
delta,
sub, sub,
positive,
}: { }: {
label: string; label: string;
value: string; value: string;
valueAccent?: boolean;
delta?: string;
sub?: string; sub?: string;
positive?: boolean;
}) { }) {
return ( return (
<div className="bg-card p-5 rounded-xl border border-border shadow-card"> <div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider"> <span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
{label} {label}
</span> </span>
<div className="flex items-baseline gap-2 mt-1"> <div className="flex items-baseline gap-2 mt-1">
<span className="text-2xl font-bold text-foreground font-mono">{value}</span> <span
{sub && ( className={`text-2xl font-bold font-mono ${
<span valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
className={`text-xs ${ }`}
positive >
? "font-medium text-emerald-600 dark:text-emerald-400" {value}
: "text-muted-foreground" </span>
}`} {delta ? (
> <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
{sub} ) : sub ? (
</span> <span className="text-xs text-muted-foreground">{sub}</span>
)} ) : null}
</div> </div>
</div> </div>
); );
@@ -80,14 +82,14 @@ export default async function AdminDashboard({
return ( return (
<div className="flex flex-col gap-8"> <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>
{/* KPI strip */} {/* KPI strip */}
<section className="grid grid-cols-2 lg:grid-cols-4 gap-6"> <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="Clienti Attivi"
value={String(kpi.clientiAttivi)}
delta={kpi.clientiNuoviMese > 0 ? `+${kpi.clientiNuoviMese} questo mese` : undefined}
sub="Totale corrente"
/>
<MetricCard <MetricCard
label="Contrattualizzato" label="Contrattualizzato"
value={fmtEur0(data.contracted)} value={fmtEur0(data.contracted)}
@@ -96,21 +98,17 @@ export default async function AdminDashboard({
<MetricCard <MetricCard
label="Incassato Reale" label="Incassato Reale"
value={fmtEur0(data.collected)} value={fmtEur0(data.collected)}
valueAccent={data.collected > 0}
sub={`${collectedPct}% del totale`} sub={`${collectedPct}% del totale`}
positive={data.collected > 0}
/>
<MetricCard
label="Ore Tracciate"
value={`${totalHours}h`}
sub="Su progetti attivi"
/> />
<MetricCard label="Ore Tracciate" value={`${totalHours}h`} sub="Su progetti attivi" />
</section> </section>
{/* Griglia contenuti: 2/3 + 1/3 */} {/* Griglia contenuti: 2/3 + 1/3 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Colonna 2/3 */} {/* Colonna 2/3 */}
<div className="lg:col-span-2 flex flex-col gap-8"> <div className="lg:col-span-2 flex flex-col gap-8">
<ForecastChart data={forecast} /> <ForecastChart data={forecast} headerAction={<YearSelector currentYear={year} />} />
<ClientProfitability data={profitability} /> <ClientProfitability data={profitability} />
</div> </div>
+18 -30
View File
@@ -1,22 +1,19 @@
import type { ReactNode } from "react";
import type { ForecastMonth } from "@/lib/forecast-queries"; import type { ForecastMonth } from "@/lib/forecast-queries";
function fmtEur(n: number) { export function ForecastChart({
return n.toLocaleString("it-IT", { data,
style: "currency", headerAction,
currency: "EUR", }: {
minimumFractionDigits: 0, data: ForecastMonth[];
maximumFractionDigits: 0, headerAction?: ReactNode;
}); }) {
}
export function ForecastChart({ data }: { data: ForecastMonth[] }) {
const max = Math.max(...data.map((d) => d.total), 1); const max = Math.max(...data.map((d) => d.total), 1);
// index 0 = mese corrente, index 1 = mese prossimo (evidenziato) // index 1 = mese prossimo (evidenziato)
const nextMonth = data[1];
return ( return (
<div className="bg-card rounded-xl border border-border shadow-card p-6 space-y-6"> <div className="bg-card rounded-xl border border-border-light shadow-card p-6">
<div className="flex justify-between items-start"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Previsione Flussi di Cassa (12 Mesi) Previsione Flussi di Cassa (12 Mesi)
@@ -25,28 +22,19 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
Stima basata su retainer attivi e saldi dilazionati Stima basata su retainer attivi e saldi dilazionati
</p> </p>
</div> </div>
{nextMonth && ( {headerAction}
<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>
<div className="flex items-end gap-2 h-44 border-t border-border pt-4"> <div className="flex items-end gap-2 h-48 border-b border-border-light pt-4">
{data.map((m, i) => { {data.map((m, i) => {
const pct = Math.round((m.total / max) * 100); const pct = Math.round((m.total / max) * 100);
const isNext = i === 1; const isNext = i === 1;
return ( return (
<div key={`${m.year}-${m.month}`} className="flex-1 flex flex-col items-center gap-1"> <div key={`${m.year}-${m.month}`} className="flex-1 flex flex-col items-center gap-2">
<div className="w-full flex flex-col justify-end" style={{ height: "120px" }}> <div className="w-full flex flex-col justify-end" style={{ height: "150px" }}>
<div <div
className={`w-full rounded-t-md transition-all ${ className={`w-full rounded-t transition-all ${
isNext ? "bg-primary" : "bg-primary/35" isNext ? "bg-primary" : "bg-border hover:bg-primary/50"
}`} }`}
style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }} style={{ height: `${pct}%`, minHeight: m.total > 0 ? "4px" : "0" }}
title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`} title={`${m.label}: ${m.total.toLocaleString("it-IT", { style: "currency", currency: "EUR", minimumFractionDigits: 2 })}`}
@@ -54,7 +42,7 @@ export function ForecastChart({ data }: { data: ForecastMonth[] }) {
</div> </div>
<span <span
className={`text-[10px] capitalize ${ className={`text-[10px] capitalize ${
isNext ? "font-bold text-primary" : "text-muted-foreground" isNext ? "font-bold text-foreground" : "text-muted-foreground"
}`} }`}
> >
{m.label.split(" ")[0]} {m.label.split(" ")[0]}
+5 -10
View File
@@ -5,22 +5,17 @@ export function OffersSoldChart({ data }: { data: OffersSoldBreakdown }) {
if (data.rows.length === 0) { if (data.rows.length === 0) {
return ( return (
<div className="bg-card rounded-xl border border-border shadow-card p-8 text-center"> <div className="bg-card rounded-xl border border-border-light shadow-card p-8 text-center">
<p className="text-sm text-muted-foreground italic">Nessuna offerta venduta finora.</p> <p className="text-sm text-muted-foreground italic">Nessuna offerta venduta finora.</p>
</div> </div>
); );
} }
return ( return (
<div className="bg-card rounded-xl border border-border shadow-card p-5 space-y-4"> <div className="bg-card rounded-xl border border-border-light shadow-card p-5 space-y-4">
<div className="flex items-center justify-between"> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> Offerte Più Richieste
Offerte Più Richieste </h3>
</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-4"> <div className="space-y-4">
{data.rows.map((row, i) => { {data.rows.map((row, i) => {
const pct = Math.round((row.count / max) * 100); const pct = Math.round((row.count / max) * 100);
+3 -3
View File
@@ -11,10 +11,10 @@ export function YearSelector({ currentYear }: { currentYear: number }) {
} }
return ( return (
<div className="inline-flex items-center gap-1 rounded-full border border-border bg-card px-1 py-1 shadow-card"> <div className="inline-flex items-center gap-0.5 rounded-lg border border-border-light bg-muted/50 px-1 py-0.5">
<button <button
onClick={() => go(currentYear - 1)} onClick={() => go(currentYear - 1)}
className="w-6 h-6 rounded-full flex items-center justify-center text-muted-foreground hover:bg-muted hover:text-primary transition-colors" className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-primary transition-colors"
aria-label="Anno precedente" aria-label="Anno precedente"
> >
@@ -25,7 +25,7 @@ export function YearSelector({ currentYear }: { currentYear: number }) {
<button <button
onClick={() => go(currentYear + 1)} onClick={() => go(currentYear + 1)}
disabled={currentYear >= thisYear} disabled={currentYear >= thisYear}
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" className="w-5 h-5 rounded flex items-center justify-center text-muted-foreground hover:text-primary transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
aria-label="Anno successivo" aria-label="Anno successivo"
> >
@@ -17,7 +17,7 @@ const MARGIN_LABEL: Record<ClientProfitabilityRow["margin"], string> = {
export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }) { export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }) {
return ( return (
<div className="bg-card rounded-xl border border-border shadow-card p-6"> <div className="bg-card rounded-xl border border-border-light shadow-card p-6">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4"> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground mb-4">
Analisi Oraria &amp; Redditività Clienti Analisi Oraria &amp; Redditività Clienti
</h3> </h3>
@@ -33,7 +33,7 @@ export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }
return ( return (
<div <div
key={row.clientId} key={row.clientId}
className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border bg-muted/30" className="flex items-center justify-between gap-4 p-3 rounded-lg border border-border-light bg-muted/30"
> >
<div className="min-w-0"> <div className="min-w-0">
<h4 className="text-xs font-semibold text-foreground truncate"> <h4 className="text-xs font-semibold text-foreground truncate">
@@ -50,7 +50,7 @@ export function ClientProfitability({ data }: { data: ClientProfitabilityRow[] }
<span <span
className={`text-xs font-bold font-mono px-2 py-0.5 rounded border ${ className={`text-xs font-bold font-mono px-2 py-0.5 rounded border ${
isOttimo isOttimo
? "text-primary bg-primary/10 border-primary/20" ? "text-emerald-700 bg-emerald-50 border-emerald-100 dark:text-emerald-400 dark:bg-emerald-500/10 dark:border-emerald-500/20"
: "text-foreground bg-muted border-border" : "text-foreground bg-muted border-border"
}`} }`}
> >
@@ -3,13 +3,6 @@ import Link from "next/link";
const MAX_ROWS = 4; const MAX_ROWS = 4;
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "?";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
function relativeDays(date: Date | string | null | undefined): string | null { function relativeDays(date: Date | string | null | undefined): string | null {
if (!date) return "Mai contattato"; if (!date) return "Mai contattato";
const d = date instanceof Date ? date : new Date(date); const d = date instanceof Date ? date : new Date(date);
@@ -25,13 +18,11 @@ export async function FollowUpWidget() {
if (leads.length === 0) { if (leads.length === 0) {
return ( return (
<div className="bg-card rounded-xl border border-border shadow-card px-5 py-4"> <div className="bg-card rounded-xl border border-border-light shadow-card p-5">
<div className="flex items-center gap-2"> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> Follow-up Attivi
Follow-up Attivi </h3>
</h2> <p className="text-sm text-muted-foreground mt-3">Tutti i lead sono aggiornati </p>
</div>
<p className="text-sm text-muted-foreground mt-2">Tutti i lead sono aggiornati </p>
</div> </div>
); );
} }
@@ -39,58 +30,43 @@ export async function FollowUpWidget() {
const visible = leads.slice(0, MAX_ROWS); const visible = leads.slice(0, MAX_ROWS);
return ( return (
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden"> <div className="bg-card rounded-xl border border-border-light shadow-card p-5">
{/* Header */} <div className="flex items-center justify-between mb-4">
<div className="flex items-center justify-between px-5 py-3.5 border-b border-border"> <h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
<div className="flex items-center gap-2.5"> Follow-up Attivi
<h2 className="text-xs font-bold uppercase tracking-wider text-muted-foreground"> </h3>
Follow-up Attivi <span className="text-[10px] font-bold bg-amber-50 text-amber-700 border border-amber-100 px-1.5 py-0.5 rounded-full dark:bg-amber-500/10 dark:text-amber-400 dark:border-amber-500/20">
</h2> {leads.length}
<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"> </span>
{leads.length}
</span>
</div>
<Link
href="/admin/pipeline"
className="text-xs font-medium text-muted-foreground hover:text-primary transition-colors"
>
Vedi tutti
</Link>
</div> </div>
{/* Lead rows */} <div className="space-y-3">
<ul className="divide-y divide-border"> {visible.map((lead) => {
{visible.map((lead) => ( const subtitle = lead.next_action
<li key={lead.id}> ? lead.next_action
: [lead.company, relativeDays(lead.last_contact_date)].filter(Boolean).join(" — ");
return (
<Link <Link
key={lead.id}
href={`/admin/pipeline/${lead.id}`} href={`/admin/pipeline/${lead.id}`}
className="flex items-center gap-3 px-5 py-3 hover:bg-muted/40 transition-colors group" className="flex items-center justify-between gap-3 p-3 border border-border-light rounded-lg hover:border-border transition-colors group"
> >
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary text-xs font-bold"> <div className="min-w-0">
{initials(lead.name)} <h4 className="text-xs font-bold text-foreground truncate">{lead.name}</h4>
</span> <p className="text-[10px] text-muted-foreground truncate">{subtitle}</p>
<div className="min-w-0 flex-1">
<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)]
.filter(Boolean)
.join(" · ")}
</p>
</div> </div>
<span className="text-xs font-medium text-muted-foreground group-hover:text-primary transition-colors shrink-0"> <span className="text-[11px] font-bold text-primary group-hover:opacity-70 transition-opacity shrink-0 inline-flex items-center gap-0.5">
Contatta Apri
</span> </span>
</Link> </Link>
</li> );
))} })}
</ul> </div>
{leads.length > MAX_ROWS && ( {leads.length > MAX_ROWS && (
<Link <Link
href="/admin/pipeline" href="/admin/pipeline"
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" className="block mt-3 pt-3 text-center text-xs font-medium text-muted-foreground hover:text-primary border-t border-border-light transition-colors"
> >
Vedi tutti i {leads.length} lead Vedi tutti i {leads.length} lead
</Link> </Link>
+10 -1
View File
@@ -1,9 +1,10 @@
import { db } from "@/db"; import { db } from "@/db";
import { clients, projects, payments } from "@/db/schema"; import { clients, projects, payments } from "@/db/schema";
import { eq, sum, count } from "drizzle-orm"; import { eq, sum, count, sql } from "drizzle-orm";
export type KpiStats = { export type KpiStats = {
clientiAttivi: number; clientiAttivi: number;
clientiNuoviMese: number; // clients created in the current calendar month
revenueTotale: string; // sum of projects.accepted_total (non-archived) revenueTotale: string; // sum of projects.accepted_total (non-archived)
pagamentiInSospeso: string; // sum of payments.amount where status = 'da_saldare' or 'inviata' pagamentiInSospeso: string; // sum of payments.amount where status = 'da_saldare' or 'inviata'
progettiInCorso: number; // projects not archived progettiInCorso: number; // projects not archived
@@ -20,6 +21,13 @@ export async function getDashboardStats(): Promise<DashboardStats> {
.from(clients) .from(clients)
.where(eq(clients.archived, false)); .where(eq(clients.archived, false));
const [clientiNuoviMeseRow] = await db
.select({ count: count() })
.from(clients)
.where(
sql`date_trunc('month', ${clients.created_at}) = date_trunc('month', now())`
);
const [revenueRow] = await db const [revenueRow] = await db
.select({ total: sum(projects.accepted_total) }) .select({ total: sum(projects.accepted_total) })
.from(projects) .from(projects)
@@ -46,6 +54,7 @@ export async function getDashboardStats(): Promise<DashboardStats> {
const kpi: KpiStats = { const kpi: KpiStats = {
clientiAttivi: clientiAttiviRow?.count ?? 0, clientiAttivi: clientiAttiviRow?.count ?? 0,
clientiNuoviMese: clientiNuoviMeseRow?.count ?? 0,
revenueTotale: revenueRow?.total ?? "0", revenueTotale: revenueRow?.total ?? "0",
pagamentiInSospeso: pagamentiSospeso.toFixed(2), pagamentiInSospeso: pagamentiSospeso.toFixed(2),
progettiInCorso: progettiRow?.count ?? 0, progettiInCorso: progettiRow?.count ?? 0,