9abe1fe4bb
- 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>
111 lines
3.9 KiB
TypeScript
111 lines
3.9 KiB
TypeScript
import { db } from "@/db";
|
|
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
|
|
import { eq, sql, desc } from "drizzle-orm";
|
|
|
|
export type ForecastMonth = {
|
|
year: number;
|
|
month: number; // 1-12
|
|
label: string; // "giu 2026"
|
|
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
|
|
// - una_tantum: accepted_total è il prezzo TOTALE → spalmato su duration_months
|
|
const offers = await db
|
|
.select({
|
|
start_date: project_offers.start_date,
|
|
duration_months: offer_micros.duration_months,
|
|
accepted_total: project_offers.accepted_total,
|
|
offer_type: offer_macros.offer_type,
|
|
})
|
|
.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))
|
|
.innerJoin(projects, eq(project_offers.project_id, projects.id))
|
|
.where(eq(projects.archived, false));
|
|
|
|
// Build 12-month bucket array starting from current month
|
|
const now = new Date();
|
|
const buckets: ForecastMonth[] = Array.from({ length: 12 }, (_, i) => {
|
|
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
|
|
return {
|
|
year: d.getFullYear(),
|
|
month: d.getMonth() + 1,
|
|
label: d.toLocaleDateString("it-IT", { month: "short", year: "numeric" }),
|
|
total: 0,
|
|
};
|
|
});
|
|
|
|
for (const offer of offers) {
|
|
if (!offer.accepted_total) continue;
|
|
const total = parseFloat(String(offer.accepted_total));
|
|
if (isNaN(total) || total <= 0) continue;
|
|
|
|
const start = new Date(offer.start_date);
|
|
const startMonthKey = start.getFullYear() * 12 + start.getMonth();
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Round totals to 2 decimal places
|
|
buckets.forEach((b) => { b.total = Math.round(b.total * 100) / 100; });
|
|
|
|
return buckets;
|
|
}
|