fix: offer name in client detail, retainer forecast, LTV with offers, offers-sold chart

- Client detail offers: show offer macro name ("Mantenimento") instead of tier letter
- Forecast: retainers project the monthly fee across every month from start_date
  (no longer capped by duration_months); una_tantum unchanged
- Clients list LTV: per project max(accepted_total, sum of assigned offers) so a
  retainer with unset quote still counts
- Dashboard: new "Offerte vendute" chart (count by offer + tier)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 14:14:13 +02:00
parent ae355c33a6
commit 9abe1fe4bb
5 changed files with 153 additions and 22 deletions
+56 -10
View File
@@ -1,6 +1,6 @@
import { db } from "@/db";
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
import { eq } from "drizzle-orm";
import { eq, sql, desc } from "drizzle-orm";
export type ForecastMonth = {
year: number;
@@ -9,6 +9,42 @@ export type ForecastMonth = {
total: number;
};
export type OffersSoldRow = {
offer_name: string; // offer_macros.internal_name
tier_letter: string | null;
count: number;
};
export type OffersSoldBreakdown = {
rows: OffersSoldRow[];
total: number;
};
// Quante offerte sono state vendute (assegnate a un progetto), per offerta + tier.
export async function getOffersSoldBreakdown(): Promise<OffersSoldBreakdown> {
const rows = await db
.select({
offer_name: offer_macros.internal_name,
tier_letter: offer_micros.tier_letter,
count: sql<number>`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<ForecastMonth[]> {
// 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<ForecastMonth[]> {
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;
}
}
}