feat(dashboard): analytics per linea di prodotto, con il residuo in chiaro

Entry (l'Audit), Signature e Retainer a confronto: quante offerte sono partite
questo mese, quante nell'anno, quanto valgono e quanto e' stato incassato. Le
categorie si leggono da offer_macros.category, cioe' dalla stessa tassonomia
che si edita da /admin/impostazioni: aggiungerne una la fa comparire, senza
toccare il codice. Le categorie configurate compaiono sempre, anche a zero —
"questo mese non e' partito nessun audit" e' una risposta, una riga mancante no.

Il punto delicato e' l'attribuzione dell'incassato. I pagamenti stanno sul
PROGETTO, non sull'offerta: per dire quanto ha incassato una linea di prodotto
bisogna ridiscendere dal progetto alle sue offerte. Un'offerta sola prende
tutto; piu' offerte si spartiscono in proporzione all'accepted_total; nessuna
offerta finisce in una riga "Senza offerta", separata e visibile.

Quella riga separata non e' prudenza teorica. Sui dati di oggi vale il 100%
dell'incassato: 5.300 EUR su 5.300. I due progetti che hanno incassato (Caruso
Speaker, Protocollo Estetico) non hanno nessuna offerta assegnata; i due che
l'hanno (Rossi Inc, Teckell) non hanno ancora incassato. Spalmando quei soldi
sulle tre categorie la dashboard avrebbe mostrato numeri inventati con un
totale che quadra. Cosi' invece si vede che c'e' da assegnare le offerte.

Lo stato dell'offerta non filtra niente: un retainer disdetto oggi ha comunque
incassato quello che ha incassato. Stessa ragione gia' scritta in
getOffersSoldBreakdown — il ciclo di vita riguarda il forecast, non lo storico.

Attesi per il 2026, verificati a mano sul DB: Entry 0, Retainer 1 offerta /
200 EUR, Signature 1 / 7.000 EUR, Senza offerta 5.300 EUR incassati. Nessuna
partenza ad agosto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-19 22:45:23 +02:00
parent 1115bb2265
commit 8f2b3255ab
3 changed files with 291 additions and 1 deletions
@@ -0,0 +1,115 @@
import type { ProductRow } from "@/lib/product-analytics";
import { fmtEur0 } from "@/components/admin/MetricCard";
/**
* Le linee di prodotto a confronto: quante ne sono partite e quanto hanno
* incassato. Una tabella e non un grafico — sono tre o quattro righe con cinque
* numeri ciascuna, e un grafico le renderebbe più difficili da leggere, non meno.
*/
export function ProductBreakdown({ rows, year }: { rows: ProductRow[]; year: number }) {
const totals = rows.reduce(
(acc, r) => ({
startedThisMonth: acc.startedThisMonth + r.startedThisMonth,
startedThisYear: acc.startedThisYear + r.startedThisYear,
contractedThisYear: acc.contractedThisYear + r.contractedThisYear,
collectedThisYear: acc.collectedThisYear + r.collectedThisYear,
}),
{ startedThisMonth: 0, startedThisYear: 0, contractedThisYear: 0, collectedThisYear: 0 }
);
return (
<div className="bg-card rounded-xl border border-border-light shadow-card overflow-hidden">
<div className="p-5 pb-3">
<h3 className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
Linee di prodotto
</h3>
<p className="text-xs text-muted-foreground mt-1">
Offerte partite e denaro incassato nel {year}
</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm border-collapse">
<thead>
<tr className="border-y border-border bg-muted/50 text-muted-foreground text-[10px] font-semibold uppercase tracking-wider">
<th className="py-3 px-5 text-left font-semibold">Categoria</th>
<th className="py-3 px-5 text-right font-semibold">Questo mese</th>
<th className="py-3 px-5 text-right font-semibold">Anno</th>
<th className="py-3 px-5 text-right font-semibold">Contrattualizzato</th>
<th className="py-3 px-5 text-right font-semibold">Incassato</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{rows.map((row) => (
<tr key={row.category} className="hover:bg-muted/40 transition-colors">
<td className="py-3 px-5">
<span
className={
row.unattributed
? "text-muted-foreground italic"
: "font-semibold text-foreground"
}
>
{row.category}
</span>
{row.unattributed && (
<p className="text-[10px] text-muted-foreground mt-0.5">
Progetti a cui non è assegnata nessuna offerta
</p>
)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums text-foreground">
{row.startedThisMonth > 0 ? (
row.startedThisMonth
) : (
<span className="text-muted-foreground/40"></span>
)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums text-foreground">
{row.startedThisYear > 0 ? (
row.startedThisYear
) : (
<span className="text-muted-foreground/40"></span>
)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums text-foreground">
{fmtEur0(row.contractedThisYear)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-semibold">
<span
className={
row.collectedThisYear > 0
? "text-emerald-700 dark:text-emerald-400"
: "text-muted-foreground/40"
}
>
{fmtEur0(row.collectedThisYear)}
</span>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-border bg-muted/30 text-foreground">
<td className="py-3 px-5 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">
Totale
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{totals.startedThisMonth}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{totals.startedThisYear}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{fmtEur0(totals.contractedThisYear)}
</td>
<td className="py-3 px-5 text-right font-mono tabular-nums font-bold">
{fmtEur0(totals.collectedThisYear)}
</td>
</tr>
</tfoot>
</table>
</div>
</div>
);
}