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