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:
@@ -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 key={offer.offer_id} className="flex items-center justify-between px-4 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<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 && (
|
{categoryLabel && (
|
||||||
<span className="inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
|
<span className="inline-flex items-center rounded-full bg-[#1A463C]/10 px-2 py-0.5 text-[11px] font-semibold text-[#1A463C]">
|
||||||
{categoryLabel}
|
{categoryLabel}
|
||||||
|
|||||||
+13
-2
@@ -8,9 +8,10 @@ import {
|
|||||||
getTimeByClient,
|
getTimeByClient,
|
||||||
getTotalTrackedHours,
|
getTotalTrackedHours,
|
||||||
} from "@/lib/analytics-queries";
|
} 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 { YearSelector, MonthlyChart } from "@/components/admin/YearSelector";
|
||||||
import { ForecastChart } from "@/components/admin/ForecastChart";
|
import { ForecastChart } from "@/components/admin/ForecastChart";
|
||||||
|
import { OffersSoldChart } from "@/components/admin/OffersSoldChart";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
@@ -77,13 +78,15 @@ export default async function AdminDashboard({
|
|||||||
|
|
||||||
const { kpi } = await getDashboardStats();
|
const { kpi } = await getDashboardStats();
|
||||||
|
|
||||||
const [data, monthly, availableYears, timeByClient, totalHours, forecast] = await Promise.all([
|
const [data, monthly, availableYears, timeByClient, totalHours, forecast, offersSold] =
|
||||||
|
await Promise.all([
|
||||||
getAnalyticsByYear(year),
|
getAnalyticsByYear(year),
|
||||||
getMonthlyCollected(year),
|
getMonthlyCollected(year),
|
||||||
getAvailableYears(),
|
getAvailableYears(),
|
||||||
getTimeByClient(year),
|
getTimeByClient(year),
|
||||||
getTotalTrackedHours(year),
|
getTotalTrackedHours(year),
|
||||||
getRevenueForecast12Months(),
|
getRevenueForecast12Months(),
|
||||||
|
getOffersSoldBreakdown(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const collectedPct =
|
const collectedPct =
|
||||||
@@ -137,6 +140,14 @@ export default async function AdminDashboard({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Sezione economica */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
|
<h3 className="text-sm font-bold text-[#71717a] uppercase tracking-wider">Fatturato</h3>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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.select().from(payments).where(inArray(payments.project_id, projectIds)),
|
||||||
|
|
||||||
db
|
db
|
||||||
@@ -138,8 +138,23 @@ export async function getAllClientsWithPayments(
|
|||||||
.from(time_entries)
|
.from(time_entries)
|
||||||
.where(inArray(time_entries.project_id, projectIds))
|
.where(inArray(time_entries.project_id, projectIds))
|
||||||
.groupBy(time_entries.project_id),
|
.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
|
// Aggregate project-scoped data back to client level
|
||||||
const clientPaymentsMap = new Map<string, typeof allPayments>();
|
const clientPaymentsMap = new Map<string, typeof allPayments>();
|
||||||
for (const payment of allPayments) {
|
for (const payment of allPayments) {
|
||||||
@@ -170,8 +185,14 @@ export async function getAllClientsWithPayments(
|
|||||||
const projectBrands = clientProjectsList
|
const projectBrands = clientProjectsList
|
||||||
.filter((p) => !p.archived)
|
.filter((p) => !p.archived)
|
||||||
.map((p) => p.name);
|
.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
|
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);
|
.toFixed(2);
|
||||||
return {
|
return {
|
||||||
id: c.id,
|
id: c.id,
|
||||||
@@ -836,6 +857,7 @@ export type ClientActiveOfferSummary = {
|
|||||||
project_id: string;
|
project_id: string;
|
||||||
project_name: string;
|
project_name: string;
|
||||||
public_name: string; // offer_micros.public_name — NEVER internal_name
|
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
|
tier_letter: string | null; // A | B | C
|
||||||
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
|
category: string | null; // Entry | Signature | Retainer (offer_macros.category)
|
||||||
offer_type: string; // una_tantum | retainer (fallback for 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,
|
offer_id: project_offers.id,
|
||||||
project_id: project_offers.project_id,
|
project_id: project_offers.project_id,
|
||||||
public_name: offer_micros.public_name,
|
public_name: offer_micros.public_name,
|
||||||
|
macro_name: offer_macros.internal_name,
|
||||||
tier_letter: offer_micros.tier_letter,
|
tier_letter: offer_micros.tier_letter,
|
||||||
category: offer_macros.category,
|
category: offer_macros.category,
|
||||||
offer_type: offer_macros.offer_type,
|
offer_type: offer_macros.offer_type,
|
||||||
@@ -875,6 +898,7 @@ export async function getClientActiveOffers(clientId: string): Promise<ClientAct
|
|||||||
project_id: r.project_id,
|
project_id: r.project_id,
|
||||||
project_name: projectNameMap.get(r.project_id) ?? "—",
|
project_name: projectNameMap.get(r.project_id) ?? "—",
|
||||||
public_name: r.public_name,
|
public_name: r.public_name,
|
||||||
|
macro_name: r.macro_name,
|
||||||
tier_letter: r.tier_letter,
|
tier_letter: r.tier_letter,
|
||||||
category: r.category,
|
category: r.category,
|
||||||
offer_type: r.offer_type,
|
offer_type: r.offer_type,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { project_offers, offer_micros, offer_macros, projects } from "@/db/schema";
|
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 = {
|
export type ForecastMonth = {
|
||||||
year: number;
|
year: number;
|
||||||
@@ -9,6 +9,42 @@ export type ForecastMonth = {
|
|||||||
total: number;
|
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[]> {
|
export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
||||||
// Active offers only (non-archived projects). offer_type drives the math:
|
// Active offers only (non-archived projects). offer_type drives the math:
|
||||||
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
|
// - retainer: accepted_total è il CANONE MENSILE → ogni mese coperto riceve l'intero importo
|
||||||
@@ -43,11 +79,20 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
|||||||
const total = parseFloat(String(offer.accepted_total));
|
const total = parseFloat(String(offer.accepted_total));
|
||||||
if (isNaN(total) || total <= 0) continue;
|
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 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++) {
|
for (let m = 0; m < months; m++) {
|
||||||
const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1);
|
const offerMonth = new Date(start.getFullYear(), start.getMonth() + m, 1);
|
||||||
const bucket = buckets.find(
|
const bucket = buckets.find(
|
||||||
@@ -56,6 +101,7 @@ export async function getRevenueForecast12Months(): Promise<ForecastMonth[]> {
|
|||||||
if (bucket) bucket.total += perMonth;
|
if (bucket) bucket.total += perMonth;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Round totals to 2 decimal places
|
// Round totals to 2 decimal places
|
||||||
buckets.forEach((b) => { b.total = Math.round(b.total * 100) / 100; });
|
buckets.forEach((b) => { b.total = Math.round(b.total * 100) / 100; });
|
||||||
|
|||||||
Reference in New Issue
Block a user