diff --git a/src/app/admin/clients/[id]/page.tsx b/src/app/admin/clients/[id]/page.tsx index c69b636..b6a697e 100644 --- a/src/app/admin/clients/[id]/page.tsx +++ b/src/app/admin/clients/[id]/page.tsx @@ -142,7 +142,7 @@ export default async function ClientDetailPage({
-

{offer.public_name}

+

{offer.macro_name}

{categoryLabel && ( {categoryLabel} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b7f225e..dc86ca3 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -8,9 +8,10 @@ import { getTimeByClient, getTotalTrackedHours, } from "@/lib/analytics-queries"; -import { getRevenueForecast12Months } from "@/lib/forecast-queries"; +import { getRevenueForecast12Months, getOffersSoldBreakdown } from "@/lib/forecast-queries"; import { YearSelector, MonthlyChart } from "@/components/admin/YearSelector"; import { ForecastChart } from "@/components/admin/ForecastChart"; +import { OffersSoldChart } from "@/components/admin/OffersSoldChart"; export const revalidate = 0; @@ -77,14 +78,16 @@ export default async function AdminDashboard({ const { kpi } = await getDashboardStats(); - const [data, monthly, availableYears, timeByClient, totalHours, forecast] = await Promise.all([ - getAnalyticsByYear(year), - getMonthlyCollected(year), - getAvailableYears(), - getTimeByClient(year), - getTotalTrackedHours(year), - getRevenueForecast12Months(), - ]); + const [data, monthly, availableYears, timeByClient, totalHours, forecast, offersSold] = + await Promise.all([ + getAnalyticsByYear(year), + getMonthlyCollected(year), + getAvailableYears(), + getTimeByClient(year), + getTotalTrackedHours(year), + getRevenueForecast12Months(), + getOffersSoldBreakdown(), + ]); const collectedPct = data.contracted > 0 ? Math.round((data.collected / data.contracted) * 100) : 0; @@ -137,6 +140,14 @@ export default async function AdminDashboard({

+ {/* Offerte vendute */} +
+

+ Offerte vendute +

+ +
+ {/* Sezione economica */}

Fatturato

diff --git a/src/components/admin/OffersSoldChart.tsx b/src/components/admin/OffersSoldChart.tsx new file mode 100644 index 0000000..3f49bca --- /dev/null +++ b/src/components/admin/OffersSoldChart.tsx @@ -0,0 +1,50 @@ +import type { OffersSoldBreakdown } from "@/lib/forecast-queries"; + +export function OffersSoldChart({ data }: { data: OffersSoldBreakdown }) { + const max = Math.max(...data.rows.map((r) => r.count), 1); + + if (data.rows.length === 0) { + return ( +
+

Nessuna offerta venduta finora.

+
+ ); + } + + return ( +
+
+

Offerte vendute

+ + {data.total} + +
+
+ {data.rows.map((row, i) => { + const pct = Math.round((row.count / max) * 100); + return ( +
+
+ + {row.offer_name} + {row.tier_letter && ( + + Tier {row.tier_letter} + + )} + + {row.count} +
+
+
+
+
+ ); + })} +
+
+ ); +} diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 7e78a82..6090bf9 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -118,7 +118,7 @@ export async function getAllClientsWithPayments( })); } - const [allPayments, activeEntries, totals] = await Promise.all([ + const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([ db.select().from(payments).where(inArray(payments.project_id, projectIds)), db @@ -138,8 +138,23 @@ export async function getAllClientsWithPayments( .from(time_entries) .where(inArray(time_entries.project_id, projectIds)) .groupBy(time_entries.project_id), + + db + .select({ + project_id: project_offers.project_id, + total: sql`coalesce(sum(${project_offers.accepted_total}), 0)`, + }) + .from(project_offers) + .where(inArray(project_offers.project_id, projectIds)) + .groupBy(project_offers.project_id), ]); + // Somma offerte accettate per progetto (per arricchire l'LTV) + const projectOffersTotalMap = new Map(); + for (const row of offerTotals) { + projectOffersTotalMap.set(row.project_id, parseFloat(row.total ?? "0") || 0); + } + // Aggregate project-scoped data back to client level const clientPaymentsMap = new Map(); for (const payment of allPayments) { @@ -170,8 +185,14 @@ export async function getAllClientsWithPayments( const projectBrands = clientProjectsList .filter((p) => !p.archived) .map((p) => p.name); + // LTV per progetto = max(totale preventivo impostato, somma offerte assegnate). + // Copre il caso "preventivo non impostato + offerta attiva" senza doppi conteggi. const ltv = clientProjectsList - .reduce((sum, p) => sum + parseFloat(p.accepted_total ?? "0"), 0) + .reduce((sum, p) => { + const acceptedTotal = parseFloat(p.accepted_total ?? "0") || 0; + const offersTotal = projectOffersTotalMap.get(p.id) ?? 0; + return sum + Math.max(acceptedTotal, offersTotal); + }, 0) .toFixed(2); return { id: c.id, @@ -836,6 +857,7 @@ export type ClientActiveOfferSummary = { project_id: string; project_name: string; public_name: string; // offer_micros.public_name — NEVER internal_name + macro_name: string; // offer_macros.internal_name — admin-only offer name ("Mantenimento") tier_letter: string | null; // A | B | C category: string | null; // Entry | Signature | Retainer (offer_macros.category) offer_type: string; // una_tantum | retainer (fallback for category) @@ -859,6 +881,7 @@ export async function getClientActiveOffers(clientId: string): Promise { + const rows = await db + .select({ + offer_name: offer_macros.internal_name, + tier_letter: offer_micros.tier_letter, + count: sql`count(*)::int`, + }) + .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)) + .groupBy(offer_macros.internal_name, offer_micros.tier_letter) + .orderBy(desc(sql`count(*)`)); + + const total = rows.reduce((sum, r) => sum + Number(r.count), 0); + return { + rows: rows.map((r) => ({ + offer_name: r.offer_name, + tier_letter: r.tier_letter, + count: Number(r.count), + })), + total, + }; +} + export async function getRevenueForecast12Months(): Promise { // Active offers only (non-archived projects). offer_type drives the math: // - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo @@ -43,17 +79,27 @@ export async function getRevenueForecast12Months(): Promise { const total = parseFloat(String(offer.accepted_total)); if (isNaN(total) || total <= 0) continue; - const months = offer.duration_months > 0 ? offer.duration_months : 1; - // Retainer: canone mensile pieno; una_tantum: prezzo totale ripartito sui mesi. - const perMonth = offer.offer_type === "retainer" ? total : total / months; const start = new Date(offer.start_date); + const startMonthKey = start.getFullYear() * 12 + start.getMonth(); - for (let m = 0; m < months; m++) { - const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1); - const bucket = buckets.find( - (b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1 - ); - if (bucket) bucket.total += perMonth; + if (offer.offer_type === "retainer") { + // Canone ricorrente: contribuisce ad OGNI mese dell'orizzonte da start in poi, + // senza limite di duration_months (un retainer è continuativo). + for (const b of buckets) { + const bucketMonthKey = b.year * 12 + (b.month - 1); + if (bucketMonthKey >= startMonthKey) b.total += total; + } + } else { + // Una tantum: prezzo totale ripartito su duration_months a partire da start. + const months = offer.duration_months > 0 ? offer.duration_months : 1; + const perMonth = total / months; + for (let m = 0; m < months; m++) { + const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1); + const bucket = buckets.find( + (b) => b.year === offerMonth.getFullYear() && b.month === offerMonth.getMonth() + 1 + ); + if (bucket) bucket.total += perMonth; + } } }