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:
@@ -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<string>`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<string, number>();
|
||||
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<string, typeof allPayments>();
|
||||
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<ClientAct
|
||||
offer_id: project_offers.id,
|
||||
project_id: project_offers.project_id,
|
||||
public_name: offer_micros.public_name,
|
||||
macro_name: offer_macros.internal_name,
|
||||
tier_letter: offer_micros.tier_letter,
|
||||
category: offer_macros.category,
|
||||
offer_type: offer_macros.offer_type,
|
||||
@@ -875,6 +898,7 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
||||
project_id: r.project_id,
|
||||
project_name: projectNameMap.get(r.project_id) ?? "—",
|
||||
public_name: r.public_name,
|
||||
macro_name: r.macro_name,
|
||||
tier_letter: r.tier_letter,
|
||||
category: r.category,
|
||||
offer_type: r.offer_type,
|
||||
|
||||
+56
-10
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user