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 + ); +}