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:
2026-05-30 16:41:43 +02:00
parent 1001320b36
commit c398d6de34
2 changed files with 105 additions and 2 deletions
+48 -2
View File
@@ -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<ProjectView | n
.where(eq(notes.project_id, projectId))
.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
const allEntityIds = [...taskIds, ...deliverablesRows.map((d) => d.id)];
const commentsRows =
@@ -306,5 +351,6 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
notes: notesRows,
comments: commentsRows,
global_progress_pct,
activeOffers: activeOffers.length > 0 ? activeOffers : undefined,
};
}