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}
+
+
+
+
+
+
+
+ | Categoria |
+ Questo mese |
+ Anno |
+ Contrattualizzato |
+ Incassato |
+
+
+
+ {rows.map((row) => (
+
+ |
+
+ {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
+ );
+}