From 8f2b3255abd2e6008d82f6bff205855c91aaef55 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Wed, 19 Aug 2026 22:45:23 +0200 Subject: [PATCH] feat(dashboard): analytics per linea di prodotto, con il residuo in chiaro MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/admin/page.tsx | 6 +- .../admin/dashboard/ProductBreakdown.tsx | 115 ++++++++++++ src/lib/product-analytics.ts | 171 ++++++++++++++++++ 3 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 src/components/admin/dashboard/ProductBreakdown.tsx create mode 100644 src/lib/product-analytics.ts diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index f9b93d5..7c7b91c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -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 */}
} /> +
diff --git a/src/components/admin/dashboard/ProductBreakdown.tsx b/src/components/admin/dashboard/ProductBreakdown.tsx new file mode 100644 index 0000000..8e2ef23 --- /dev/null +++ b/src/components/admin/dashboard/ProductBreakdown.tsx @@ -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 ( +
+
+

+ Linee di prodotto +

+

+ Offerte partite e denaro incassato nel {year} +

+
+ +
+ + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + ))} + + + + + + + + + + +
CategoriaQuesto meseAnnoContrattualizzatoIncassato
+ + {row.category} + + {row.unattributed && ( +

+ Progetti a cui non è assegnata nessuna offerta +

+ )} +
+ {row.startedThisMonth > 0 ? ( + row.startedThisMonth + ) : ( + + )} + + {row.startedThisYear > 0 ? ( + row.startedThisYear + ) : ( + + )} + + {fmtEur0(row.contractedThisYear)} + + 0 + ? "text-emerald-700 dark:text-emerald-400" + : "text-muted-foreground/40" + } + > + {fmtEur0(row.collectedThisYear)} + +
+ Totale + + {totals.startedThisMonth} + + {totals.startedThisYear} + + {fmtEur0(totals.contractedThisYear)} + + {fmtEur0(totals.collectedThisYear)} +
+
+
+ ); +} diff --git a/src/lib/product-analytics.ts b/src/lib/product-analytics.ts new file mode 100644 index 0000000..e51f7c5 --- /dev/null +++ b/src/lib/product-analytics.ts @@ -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 { + 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`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(); + 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(); + 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 + ); +}