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