Fatturato
diff --git a/src/components/admin/OffersSoldChart.tsx b/src/components/admin/OffersSoldChart.tsx
new file mode 100644
index 0000000..3f49bca
--- /dev/null
+++ b/src/components/admin/OffersSoldChart.tsx
@@ -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 (
+
+
Nessuna offerta venduta finora.
+
+ );
+ }
+
+ return (
+
+
+
Offerte vendute
+
+ {data.total}
+
+
+
+ {data.rows.map((row, i) => {
+ const pct = Math.round((row.count / max) * 100);
+ return (
+
+
+
+ {row.offer_name}
+ {row.tier_letter && (
+
+ Tier {row.tier_letter}
+
+ )}
+
+ {row.count}
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts
index 7e78a82..6090bf9 100644
--- a/src/lib/admin-queries.ts
+++ b/src/lib/admin-queries.ts
@@ -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
`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();
+ 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();
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 {
+ const rows = await db
+ .select({
+ offer_name: offer_macros.internal_name,
+ tier_letter: offer_micros.tier_letter,
+ count: sql`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 {
// 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 {
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;
+ }
}
}