feat(05-04): extend client-view with activeOffers + create forecast-queries.ts
- Add activeOffers field to ProjectView and ClientView interfaces (public_name only — never internal_name) - Add offer queries to getProjectView(): project_offers JOIN offer_micros (explicit public_name selection), cumulative price via offer_micro_services JOIN offer_services - Create src/lib/forecast-queries.ts with ForecastMonth type and getRevenueForecast12Months() 12-bucket JS algorithm - Forecast spreads accepted_total evenly across duration_months from start_date
This commit is contained in:
+48
-2
@@ -1,6 +1,6 @@
|
|||||||
import { eq, inArray, asc } from "drizzle-orm";
|
import { eq, inArray, asc, sql } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
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.
|
* ClientView: Legacy shape used by ClientDashboard component.
|
||||||
@@ -52,6 +52,12 @@ export interface ClientView {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
}>;
|
}>;
|
||||||
global_progress_pct: number;
|
global_progress_pct: number;
|
||||||
|
activeOffers?: Array<{
|
||||||
|
id: string;
|
||||||
|
public_name: string;
|
||||||
|
cumulative_price: string;
|
||||||
|
accepted_total: string | null;
|
||||||
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectView {
|
export interface ProjectView {
|
||||||
@@ -109,6 +115,12 @@ export interface ProjectView {
|
|||||||
created_at: Date;
|
created_at: Date;
|
||||||
}>;
|
}>;
|
||||||
global_progress_pct: number;
|
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 {
|
export interface ClientProjectSummary {
|
||||||
@@ -263,6 +275,39 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
.where(eq(notes.project_id, projectId))
|
.where(eq(notes.project_id, projectId))
|
||||||
.orderBy(asc(notes.created_at));
|
.orderBy(asc(notes.created_at));
|
||||||
|
|
||||||
|
// Fetch active offers for this project (client-safe fields only — never internal_name)
|
||||||
|
const projectOfferRows = await db
|
||||||
|
.select({
|
||||||
|
id: project_offers.id,
|
||||||
|
public_name: offer_micros.public_name, // EXPLICITLY public_name, never internal_name
|
||||||
|
accepted_total: project_offers.accepted_total,
|
||||||
|
micro_id: project_offers.micro_id,
|
||||||
|
})
|
||||||
|
.from(project_offers)
|
||||||
|
.innerJoin(offer_micros, eq(project_offers.micro_id, offer_micros.id))
|
||||||
|
.where(eq(project_offers.project_id, projectId));
|
||||||
|
|
||||||
|
// Cumulative price per micro (sum of assigned services)
|
||||||
|
const microIds = projectOfferRows.map((o) => o.micro_id);
|
||||||
|
const cumulativePriceRows = microIds.length === 0 ? [] : await db
|
||||||
|
.select({
|
||||||
|
micro_id: offer_micro_services.micro_id,
|
||||||
|
cumulative_price: sql<string>`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
|
// Comments scoped to tasks and deliverables of this project
|
||||||
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
|
||||||
const commentsRows =
|
const commentsRows =
|
||||||
@@ -306,5 +351,6 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
notes: notesRows,
|
notes: notesRows,
|
||||||
comments: commentsRows,
|
comments: commentsRows,
|
||||||
global_progress_pct,
|
global_progress_pct,
|
||||||
|
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -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<ForecastMonth[]> {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user