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
+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);
}