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
+1 -1
View File
@@ -142,7 +142,7 @@ export default async function ClientDetailPage({
<div key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium text-[#1a1a1a]">{offer.public_name}</p>
<p className="text-sm font-medium text-[#1a1a1a]">{offer.macro_name}</p>
{categoryLabel && (
<span className="inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
{categoryLabel}
+20 -9
View File
@@ -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({
</p>
</div>
{/* Offerte vendute */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">
Offerte vendute
</h3>
<OffersSoldChart data={offersSold} />
</div>
{/* Sezione economica */}
<div className="space-y-4">
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
+50
View File
@@ -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 (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-8 text-center">
<p className="text-sm text-[#71717a] italic">Nessuna offerta venduta finora.</p>
</div>
);
}
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-[#1a1a1a]">Offerte vendute</h3>
<span className="inline-flex items-center justify-center min-w-[24px] h-6 px-2 rounded-full bg-[#1A463C] text-white text-xs font-bold">
{data.total}
</span>
</div>
<div className="space-y-3">
{data.rows.map((row, i) => {
const pct = Math.round((row.count / max) * 100);
return (
<div key={`${row.offer_name}-${row.tier_letter ?? "—"}-${i}`}>
<div className="flex items-center justify-between mb-1 gap-2">
<span className="text-sm font-medium text-[#1a1a1a] truncate">
{row.offer_name}
{row.tier_letter && (
<span className="ml-2 inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
Tier {row.tier_letter}
</span>
)}
</span>
<span className="text-sm tabular-nums text-[#71717a] shrink-0">{row.count}</span>
</div>
<div className="h-2 rounded-full bg-[#f4f4f5] overflow-hidden">
<div
className="h-full rounded-full bg-[#1A463C] transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
})}
</div>
</div>
);
}
+26 -2
View File
@@ -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
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;
}
}
}