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:
2026-07-11 11:57:10 +02:00
parent e5fa07bba3
commit 08aadc1d97
9 changed files with 622 additions and 254 deletions
+68 -173
View File
@@ -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 &#9654; 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>
);
+22 -15
View File
@@ -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]}
+13 -11
View File
@@ -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>
+5 -40
View File
@@ -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 &amp; 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>
+60
View File
@@ -131,4 +131,64 @@ export async function getTotalTrackedHours(year: number): Promise<number> {
and extract(year from ${time_entries.started_at}) = ${year}`
);
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);
}