diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts index 66fc3f7..e77c318 100644 --- a/src/lib/client-view.ts +++ b/src/lib/client-view.ts @@ -1,6 +1,6 @@ -import { eq, inArray, asc } from "drizzle-orm"; +import { eq, inArray, asc, sql } from "drizzle-orm"; import { db } from "@/db"; -import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments } from "@/db/schema"; +import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services } from "@/db/schema"; /** * ClientView: Legacy shape used by ClientDashboard component. @@ -52,6 +52,12 @@ export interface ClientView { created_at: string; }>; global_progress_pct: number; + activeOffers?: Array<{ + id: string; + public_name: string; + cumulative_price: string; + accepted_total: string | null; + }>; } export interface ProjectView { @@ -109,6 +115,12 @@ export interface ProjectView { created_at: Date; }>; global_progress_pct: number; + activeOffers?: Array<{ + id: string; + public_name: string; // offer_micros.public_name — NEVER internal_name + cumulative_price: string; // sum of assigned offer_services.price + accepted_total: string | null; + }>; } export interface ClientProjectSummary { @@ -263,6 +275,39 @@ export async function getProjectView(projectId: string): Promise o.micro_id); + const cumulativePriceRows = microIds.length === 0 ? [] : await db + .select({ + micro_id: offer_micro_services.micro_id, + cumulative_price: sql`coalesce(sum(${offer_services.price}::numeric), 0)`, + }) + .from(offer_micro_services) + .innerJoin(offer_services, eq(offer_micro_services.service_id, offer_services.id)) + .where(inArray(offer_micro_services.micro_id, microIds)) + .groupBy(offer_micro_services.micro_id); + + const cumulMap = new Map(cumulativePriceRows.map((r) => [r.micro_id, r.cumulative_price])); + + const activeOffers = projectOfferRows.map((o) => ({ + id: o.id, + public_name: o.public_name, + cumulative_price: cumulMap.get(o.micro_id) ?? "0", + accepted_total: o.accepted_total ? String(o.accepted_total) : null, + })); + // Comments scoped to tasks and deliverables of this project const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)]; const commentsRows = @@ -306,5 +351,6 @@ export async function getProjectView(projectId: string): Promise 0 ? activeOffers : undefined, }; } \ No newline at end of file diff --git a/src/lib/forecast-queries.ts b/src/lib/forecast-queries.ts new file mode 100644 index 0000000..22d8fad --- /dev/null +++ b/src/lib/forecast-queries.ts @@ -0,0 +1,57 @@ +import { db } from "@/db"; +import { project_offers, offer_micros } from "@/db/schema"; +import { eq } from "drizzle-orm"; + +export type ForecastMonth = { + year: number; + month: number; // 1-12 + label: string; // "giu 2026" + total: number; +}; + +export async function getRevenueForecast12Months(): Promise { + // Load all project offers with micro duration info + const offers = await db + .select({ + start_date: project_offers.start_date, + duration_months: offer_micros.duration_months, + accepted_total: project_offers.accepted_total, + }) + .from(project_offers) + .innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id)); + + // 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) { + // Skip offers with no accepted_total — they contribute 0 to forecast + if (!offer.accepted_total) continue; + const total = parseFloat(String(offer.accepted_total)); + if (isNaN(total) || total <= 0) continue; + + const perMonth = total / offer.duration_months; + const start = new Date(offer.start_date); + + for (let m = 0; m < offer.duration_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; +}