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:
+1
-45
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtEur0(n: number) {
|
||||
return n.toLocaleString("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export default async function AdminDashboard({
|
||||
searchParams,
|
||||
}: {
|
||||
|
||||
@@ -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({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProjectSummary
|
||||
acceptedTotal={project.accepted_total ?? "0"}
|
||||
payments={payments}
|
||||
phases={phases}
|
||||
totalTrackedSeconds={totalTrackedSeconds}
|
||||
targetHourlyRate={targetHourlyRate}
|
||||
/>
|
||||
|
||||
<Tabs defaultValue="phases" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user