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 { ForecastChart } from "@/components/admin/ForecastChart";
|
||||||
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
||||||
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
|
import { ClientProfitability } from "@/components/admin/dashboard/ClientProfitability";
|
||||||
|
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
|
||||||
|
|
||||||
export const revalidate = 0;
|
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({
|
export default async function AdminDashboard({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab";
|
|||||||
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
import { TimerTab } from "@/components/admin/tabs/TimerTab";
|
||||||
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
import { OffersTab } from "@/components/admin/tabs/OffersTab";
|
||||||
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle";
|
||||||
|
import { ProjectSummary } from "@/components/admin/ProjectSummary";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
@@ -68,6 +69,14 @@ export default async function ProjectDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ProjectSummary
|
||||||
|
acceptedTotal={project.accepted_total ?? "0"}
|
||||||
|
payments={payments}
|
||||||
|
phases={phases}
|
||||||
|
totalTrackedSeconds={totalTrackedSeconds}
|
||||||
|
targetHourlyRate={targetHourlyRate}
|
||||||
|
/>
|
||||||
|
|
||||||
<Tabs defaultValue="phases" className="w-full">
|
<Tabs defaultValue="phases" className="w-full">
|
||||||
<TabsList className="mb-6">
|
<TabsList className="mb-6">
|
||||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
<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