feat(progetti): riepilogo soldi e avanzamento in testa al progetto

Per sapere a che punto era un progetto bisognava aprire tre tab e sommare a
mente. Ora la risposta e' sopra i tab: contrattualizzato, incassato, da
incassare, redditivita' oraria, e una barra con i task fatti sul totale.

Nessuna query nuova: sono tutti dati che getProjectFullDetail restituisce gia'
per i tab sottostanti. E' aritmetica su quello che c'e'.

Una scelta: l'incassato si legge dai `payments`, non da `accepted_total`. Il
contratto dice quanto vale il progetto, le rate dicono quanto e' entrato
davvero, e quando i due non tornano e' un'informazione — non un errore di
calcolo da nascondere pareggiando i conti.

MetricCard esce da admin/page.tsx e diventa un componente condiviso: due copie
della stessa card avrebbero iniziato a divergere alla prima modifica.

Numeri verificati contro il DB di produzione, progetto per progetto. Rossi Inc:
7.000 EUR su 30h tracciate = 233 EUR/h sopra il target di 100, 3 task su 28,
1 fase su 4. Teckell, che ha ore ma nessun contratto, mostra "—" e non uno
zero travestito da dato.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:40:48 +02:00
parent a9358da96f
commit d6e95ef66a
4 changed files with 185 additions and 45 deletions
+53
View File
@@ -0,0 +1,53 @@
/**
* Riquadro di una metrica. Estratto dalla dashboard admin quando è servito anche
* al riepilogo del singolo progetto: due copie della stessa card avrebbero
* iniziato a divergere alla prima modifica.
*
* `delta` e `sub` occupano lo stesso posto: `delta` vince quando c'è, perché una
* variazione è più interessante di una didascalia.
*/
export function MetricCard({
label,
value,
valueAccent,
delta,
sub,
}: {
label: string;
value: string;
valueAccent?: boolean;
delta?: string;
sub?: string;
}) {
return (
<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">
{label}
</span>
<div className="flex items-baseline gap-2 mt-1">
<span
className={`text-2xl font-bold font-mono ${
valueAccent ? "text-emerald-700 dark:text-emerald-400" : "text-foreground"
}`}
>
{value}
</span>
{delta ? (
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">{delta}</span>
) : sub ? (
<span className="text-xs text-muted-foreground">{sub}</span>
) : null}
</div>
</div>
);
}
/** Formatta in euro senza decimali — per i totali, dove i centesimi sono rumore. */
export function fmtEur0(n: number) {
return n.toLocaleString("it-IT", {
style: "currency",
currency: "EUR",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
}
+122
View File
@@ -0,0 +1,122 @@
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
import type { ProjectFullDetail } from "@/lib/admin-queries";
type Props = {
acceptedTotal: string;
payments: ProjectFullDetail["payments"];
phases: ProjectFullDetail["phases"];
totalTrackedSeconds: number;
targetHourlyRate: number;
};
/**
* Riepilogo in testa al progetto: i soldi e l'avanzamento, prima dei tab.
*
* Nessuna query nuova — sono tutti dati che `getProjectFullDetail` restituisce
* già per i tab sottostanti. È aritmetica su quello che c'è.
*
* L'incassato si legge dai `payments`, non da `accepted_total`: il contratto
* dice quanto vale il progetto, le rate dicono quanto è entrato davvero, e
* quando i due non tornano è un'informazione, non un errore di calcolo.
*/
export function ProjectSummary({
acceptedTotal,
payments,
phases,
totalTrackedSeconds,
targetHourlyRate,
}: Props) {
const contracted = parseFloat(acceptedTotal) || 0;
const collected = payments
.filter((p) => p.status === "saldato")
.reduce((sum, p) => sum + (parseFloat(String(p.amount)) || 0), 0);
const outstanding = payments
.filter((p) => p.status === "da_saldare" || p.status === "inviata")
.reduce((sum, p) => sum + (parseFloat(String(p.amount)) || 0), 0);
const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0;
const allTasks = phases.flatMap((p) => p.tasks);
const doneTasks = allTasks.filter((t) => t.status === "done").length;
const progressPct =
allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0;
const donePhases = phases.filter((p) => p.status === "done").length;
const hours = totalTrackedSeconds / 3600;
const realRate = hours > 0 ? contracted / hours : null;
return (
<section className="flex flex-col gap-4 mb-8">
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard
label="Contrattualizzato"
value={fmtEur0(contracted)}
sub={payments.length > 0 ? `${payments.length} rate` : "Nessuna rata"}
/>
<MetricCard
label="Incassato"
value={fmtEur0(collected)}
valueAccent={collected > 0}
sub={contracted > 0 ? `${collectedPct}% del totale` : undefined}
/>
<MetricCard
label="Da incassare"
value={fmtEur0(outstanding)}
sub={outstanding > 0 ? "Rate aperte" : "Tutto saldato"}
/>
<MetricCard
label="Redditività"
value={realRate === null ? "—" : `${fmtEur0(realRate)}/h`}
valueAccent={realRate !== null && realRate >= targetHourlyRate}
sub={
realRate === null
? "Nessuna ora tracciata"
: `${hours.toFixed(1)}h · target ${fmtEur0(targetHourlyRate)}/h`
}
/>
</div>
{/* Avanzamento */}
<div className="bg-card p-5 rounded-xl border border-border-light shadow-card">
<div className="flex items-baseline justify-between gap-3 flex-wrap">
<span className="text-[10px] uppercase font-bold text-muted-foreground tracking-wider">
Avanzamento
</span>
<span className="text-xs text-muted-foreground">
{allTasks.length === 0 ? (
"Nessun task"
) : (
<>
<span className="font-mono text-foreground font-semibold">
{doneTasks}/{allTasks.length}
</span>{" "}
task · {donePhases}/{phases.length} fasi complete
</>
)}
</span>
</div>
<div className="flex items-center gap-3 mt-3">
<div
className="h-2 flex-1 rounded-full bg-muted overflow-hidden"
role="progressbar"
aria-valuenow={progressPct}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Avanzamento del progetto"
>
<div
className="h-full rounded-full bg-emerald-500 transition-[width]"
style={{ width: `${progressPct}%` }}
/>
</div>
<span className="text-sm font-mono font-bold text-foreground tabular-nums shrink-0">
{progressPct}%
</span>
</div>
</div>
</section>
);
}