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
+5 -1
View File
@@ -13,6 +13,8 @@ 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";
import { getProductAnalytics } from "@/lib/product-analytics";
import { ProductBreakdown } from "@/components/admin/dashboard/ProductBreakdown";
export const revalidate = 0;
@@ -26,12 +28,13 @@ export default async function AdminDashboard({
const { kpi } = await getDashboardStats();
const [data, totalHours, profitability, forecast, offersSold] = await Promise.all([
const [data, totalHours, profitability, forecast, offersSold, products] = await Promise.all([
getAnalyticsByYear(year),
getTotalTrackedHours(year),
getClientProfitability(year),
getRevenueForecast12Months(),
getOffersSoldBreakdown(),
getProductAnalytics(year),
]);
const collectedPct =
@@ -72,6 +75,7 @@ export default async function AdminDashboard({
{/* Colonna 2/3 */}
<div className="lg:col-span-2 flex flex-col gap-8">
<ForecastChart data={forecast} headerAction={<YearSelector currentYear={year} />} />
<ProductBreakdown rows={products} year={year} />
<ClientProfitability data={profitability} />
</div>
@@ -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>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { db } from "@/db";
import { project_offers, offer_micros, offer_macros, projects, payments } from "@/db/schema";
import { eq, and, sql } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
/** Riga sintetica per una linea di prodotto (Entry / Signature / Retainer). */
export type ProductRow = {
category: string;
/** Offerte partite nel mese corrente. */
startedThisMonth: number;
/** Offerte partite nell'anno richiesto. */
startedThisYear: number;
/** Somma degli accepted_total delle offerte partite nell'anno. */
contractedThisYear: number;
/** Pagamenti saldati nell'anno, attribuiti a questa categoria. */
collectedThisYear: number;
/** true per la riga "Senza offerta": non è una categoria, è un residuo. */
unattributed?: boolean;
};
export const UNATTRIBUTED_LABEL = "Senza offerta";
/**
* Analytics per linea di prodotto, lette da `offer_macros.category` — la stessa
* tassonomia che si edita da /admin/impostazioni. L'Audit è l'Entry Offer: non
* ha una sorgente separata, e non deve averla, altrimenti i tre numeri
* smetterebbero di essere confrontabili tra loro.
*
* ## Il problema dell'attribuzione, e come viene risolto
*
* I pagamenti stanno sul PROGETTO, non sull'offerta: `payments.project_id` è
* l'unico legame. Per dire quanto ha incassato una linea di prodotto bisogna
* quindi ridiscendere dal progetto alle sue offerte, e la regola è:
*
* 1. progetto con UNA offerta → tutto l'incasso va a quella categoria;
* 2. progetto con PIÙ offerte → ripartito in proporzione ai rispettivi
* `accepted_total` (se sono tutti a zero, in
* parti uguali: meglio equamente sbagliato che
* arbitrariamente attribuito a una sola);
* 3. progetto SENZA offerte → riga "Senza offerta", mostrata a video.
*
* Il terzo caso non va nascosto. Sommarlo di soppiatto a una categoria darebbe
* un totale che quadra e tre righe che mentono; tenerlo separato fa vedere
* quanti soldi non sono ancora collegati a un'offerta, che è una cosa da
* sistemare, non da mimetizzare.
*
* Lo stato dell'offerta NON filtra nulla qui: un retainer disdetto oggi ha
* comunque incassato quello che ha incassato. Vale la stessa ragione già
* scritta in `getOffersSoldBreakdown` — il ciclo di vita riguarda il forecast,
* non lo storico.
*/
export async function getProductAnalytics(year: number): Promise<ProductRow[]> {
const now = new Date();
const currentYear = now.getFullYear();
const currentMonth = now.getMonth();
const [offerRows, collectedRows, pool] = await Promise.all([
// Tutte le offerte assegnate, con la categoria della loro macro.
db
.select({
project_id: project_offers.project_id,
category: offer_macros.category,
accepted_total: project_offers.accepted_total,
start_date: project_offers.start_date,
})
.from(project_offers)
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
.innerJoin(offer_macros, eq(offer_micros.macro_id, offer_macros.id)),
// Incassato per progetto nell'anno.
db
.select({
project_id: payments.project_id,
total: sql<string>`coalesce(sum(${payments.amount}::numeric), 0)`,
})
.from(payments)
.innerJoin(projects, eq(payments.project_id, projects.id))
.where(
and(
eq(payments.status, "saldato"),
sql`${payments.paid_at} is not null and extract(year from ${payments.paid_at}) = ${year}`
)
)
.groupBy(payments.project_id),
getPool("offer_categoria"),
]);
// Le categorie configurate compaiono SEMPRE, anche a zero: "questo mese non
// è partito nessun audit" è una risposta, una riga mancante no.
const rows = new Map<string, ProductRow>();
const ensure = (category: string, unattributed = false): ProductRow => {
let row = rows.get(category);
if (!row) {
row = {
category,
startedThisMonth: 0,
startedThisYear: 0,
contractedThisYear: 0,
collectedThisYear: 0,
...(unattributed ? { unattributed: true } : {}),
};
rows.set(category, row);
}
return row;
};
for (const category of pool) ensure(category);
// ── Partenze e contrattualizzato ───────────────────────────────────────────
for (const offer of offerRows) {
const category = offer.category ?? UNATTRIBUTED_LABEL;
const row = ensure(category, category === UNATTRIBUTED_LABEL);
const start = new Date(offer.start_date);
if (start.getFullYear() !== year) continue;
row.startedThisYear += 1;
row.contractedThisYear += parseFloat(String(offer.accepted_total ?? "0")) || 0;
if (year === currentYear && start.getMonth() === currentMonth) {
row.startedThisMonth += 1;
}
}
// ── Incassato, ripartito sulle offerte del progetto ────────────────────────
const offersByProject = new Map<string, typeof offerRows>();
for (const offer of offerRows) {
const list = offersByProject.get(offer.project_id) ?? [];
list.push(offer);
offersByProject.set(offer.project_id, list);
}
for (const { project_id, total } of collectedRows) {
const collected = parseFloat(total) || 0;
if (collected === 0) continue;
const offers = offersByProject.get(project_id) ?? [];
if (offers.length === 0) {
ensure(UNATTRIBUTED_LABEL, true).collectedThisYear += collected;
continue;
}
const weights = offers.map((o) => parseFloat(String(o.accepted_total ?? "0")) || 0);
const weightSum = weights.reduce((a, b) => a + b, 0);
offers.forEach((offer, i) => {
const share = weightSum > 0 ? weights[i] / weightSum : 1 / offers.length;
const category = offer.category ?? UNATTRIBUTED_LABEL;
ensure(category, category === UNATTRIBUTED_LABEL).collectedThisYear += collected * share;
});
}
// Ordine: le categorie come le ha configurate l'utente, il residuo in fondo.
const ordered = [...rows.values()].sort((a, b) => {
if (a.unattributed) return 1;
if (b.unattributed) return -1;
const ai = pool.indexOf(a.category);
const bi = pool.indexOf(b.category);
if (ai === -1 && bi === -1) return a.category.localeCompare(b.category, "it");
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
// Il residuo si mostra solo se c'è davvero qualcosa dentro.
return ordered.filter(
(r) =>
!r.unattributed ||
r.collectedThisYear > 0 ||
r.startedThisYear > 0 ||
r.contractedThisYear > 0
);
}