From d6e95ef66aa1e2d364336cbeb197d67b2fadd60f Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Wed, 19 Aug 2026 22:40:48 +0200 Subject: [PATCH] feat(progetti): riepilogo soldi e avanzamento in testa al progetto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/admin/page.tsx | 46 +-------- src/app/admin/projects/[id]/page.tsx | 9 ++ src/components/admin/MetricCard.tsx | 53 ++++++++++ src/components/admin/ProjectSummary.tsx | 122 ++++++++++++++++++++++++ 4 files changed, 185 insertions(+), 45 deletions(-) create mode 100644 src/components/admin/MetricCard.tsx create mode 100644 src/components/admin/ProjectSummary.tsx diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 327c026..a1996af 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -12,54 +12,10 @@ import { YearSelector } from "@/components/admin/YearSelector"; import { ForecastChart } from "@/components/admin/ForecastChart"; import { OffersSoldChart } from "@/components/admin/OffersSoldChart"; import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability"; +import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard"; export const revalidate = 0; -function MetricCard({ - label, - value, - valueAccent, - delta, - sub, -}: { - label: string; - value: string; - valueAccent?: boolean; - delta?: string; - sub?: string; -}) { - return ( -
- - {label} - -
- - {value} - - {delta ? ( - {delta} - ) : sub ? ( - {sub} - ) : null} -
-
- ); -} - -function fmtEur0(n: number) { - return n.toLocaleString("it-IT", { - style: "currency", - currency: "EUR", - minimumFractionDigits: 0, - maximumFractionDigits: 0, - }); -} - export default async function AdminDashboard({ searchParams, }: { diff --git a/src/app/admin/projects/[id]/page.tsx b/src/app/admin/projects/[id]/page.tsx index 5ccab71..37e777d 100644 --- a/src/app/admin/projects/[id]/page.tsx +++ b/src/app/admin/projects/[id]/page.tsx @@ -8,6 +8,7 @@ import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; import { TimerTab } from "@/components/admin/tabs/TimerTab"; import { OffersTab } from "@/components/admin/tabs/OffersTab"; import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; +import { ProjectSummary } from "@/components/admin/ProjectSummary"; import Link from "next/link"; export const revalidate = 0; @@ -68,6 +69,14 @@ export default async function ProjectDetailPage({ + + Fasi & Task diff --git a/src/components/admin/MetricCard.tsx b/src/components/admin/MetricCard.tsx new file mode 100644 index 0000000..e167656 --- /dev/null +++ b/src/components/admin/MetricCard.tsx @@ -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 ( +
+ + {label} + +
+ + {value} + + {delta ? ( + {delta} + ) : sub ? ( + {sub} + ) : null} +
+
+ ); +} + +/** 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, + }); +} diff --git a/src/components/admin/ProjectSummary.tsx b/src/components/admin/ProjectSummary.tsx new file mode 100644 index 0000000..07dccef --- /dev/null +++ b/src/components/admin/ProjectSummary.tsx @@ -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 ( +
+
+ 0 ? `${payments.length} rate` : "Nessuna rata"} + /> + 0} + sub={contracted > 0 ? `${collectedPct}% del totale` : undefined} + /> + 0 ? "Rate aperte" : "Tutto saldato"} + /> + = targetHourlyRate} + sub={ + realRate === null + ? "Nessuna ora tracciata" + : `${hours.toFixed(1)}h · target ${fmtEur0(targetHourlyRate)}/h` + } + /> +
+ + {/* Avanzamento */} +
+
+ + Avanzamento + + + {allTasks.length === 0 ? ( + "Nessun task" + ) : ( + <> + + {doneTasks}/{allTasks.length} + {" "} + task · {donePhases}/{phases.length} fasi complete + + )} + +
+ +
+
+
+
+ + {progressPct}% + +
+
+
+ ); +}