From 07b12e98b4fb4ea088c2f163498255f634f2552d Mon Sep 17 00:00:00 2001 From: DEls Date: Tue, 20 Oct 2026 12:58:02 +0200 Subject: [PATCH] feat(management): list every planned project on Retention Awarded (not started) and non-calculated projects now appear on the Retention table, labelled, blank until values are entered. Entered values count towards totals and the company balance, and the retention save endpoint accepts any project in the plan. Co-Authored-By: Claude Opus 5.5 --- .../management/plans/[id]/retention/route.test.ts | 6 ++++++ src/app/api/management/plans/[id]/retention/route.ts | 2 +- .../dashboard/management/retention-comparison.tsx | 8 ++++---- src/lib/management/retention-series.test.ts | 12 ++++++++++++ src/lib/management/retention-series.ts | 11 ++++++++--- 5 files changed, 31 insertions(+), 8 deletions(-) diff --git a/src/app/api/management/plans/[id]/retention/route.test.ts b/src/app/api/management/plans/[id]/retention/route.test.ts index 6f594d1..326e399 100644 --- a/src/app/api/management/plans/[id]/retention/route.test.ts +++ b/src/app/api/management/plans/[id]/retention/route.test.ts @@ -55,6 +55,12 @@ describe("project monthly retention edits", () => { }) })); expect(m.revalidate).toHaveBeenCalledWith("/dashboard/management/retention"); }); + it("saves retention for a planned project that is awarded or outside the retention calculation", async () => { + expect((await call({ ...body, projectId: "sammy-marks" })).status).toBe(200); + expect(m.update).toHaveBeenCalledWith(id, expect.objectContaining({ history: expect.objectContaining({ + retentionEdits: { "sammy-marks": { "7": { forecast: 123456.78, actual: 120000 } } }, + }) })); + }); it("rejects edits to a project outside this plan", async () => { expect((await call({ ...body, projectId: "unknown" })).status).toBe(400); expect(m.update).not.toHaveBeenCalled(); diff --git a/src/app/api/management/plans/[id]/retention/route.ts b/src/app/api/management/plans/[id]/retention/route.ts index f0bcc7d..4cd931d 100644 --- a/src/app/api/management/plans/[id]/retention/route.ts +++ b/src/app/api/management/plans/[id]/retention/route.ts @@ -62,7 +62,7 @@ export async function PATCH(request: Request, ctx: { params: Promise<{ id: strin const snapshot = rowsToSnapshot(rows); const { projectId, month, forecast, actual } = parsed.data; const project = snapshot.projects.find((p) => p.id === projectId); - if (!project || !project.retentionEligible || project.excludedFromCashflow || project.status === "awarded") return NextResponse.json({ error: "invalid_project" }, { status: 400 }); + if (!project) return NextResponse.json({ error: "invalid_project" }, { status: 400 }); const previous = retentionCell(project, month, plan.history.retentionEdits); const value = { forecast: forecast ?? actual, actual }; const retentionEdits = { ...plan.history.retentionEdits, [projectId]: { ...plan.history.retentionEdits?.[projectId], [month]: value } }; diff --git a/src/app/dashboard/management/retention-comparison.tsx b/src/app/dashboard/management/retention-comparison.tsx index 8a47a9d..5067db8 100644 --- a/src/app/dashboard/management/retention-comparison.tsx +++ b/src/app/dashboard/management/retention-comparison.tsx @@ -3,7 +3,7 @@ import { useState } from "react"; import { useRouter } from "next/navigation"; import type { ManagementBoard, PlanningProject, RetentionEdits } from "@/lib/management/model"; -import { retentionCell, retentionComparison } from "@/lib/management/retention-series"; +import { retentionCell, retentionComparison, retentionManualOnly } from "@/lib/management/retention-series"; import { IncomeCellDialog } from "./income-cell-dialog"; import { RetentionStartDialog } from "./retention-start-dialog"; import { RetentionPayoutDialog } from "./retention-payout-dialog"; @@ -89,7 +89,7 @@ export function RetentionComparison({ board }: { board: ManagementBoard }) { - +
{ if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openStart(); } }}> @@ -105,11 +105,11 @@ export function RetentionComparison({ board }: { board: ManagementBoard }) {
{months.map((m) => )} - {data.jobs.map(({ project, cells, forecast, actual, variance }) => {cells.map((cell) => )})} + {data.jobs.map(({ project, cells, forecast, actual, variance }) => {cells.map((cell) => )})}{data.months.map((m) => )}{data.months.map((m) => )}{data.months.map((m) => )}
Forecast and actual retention held by project and month, in rand
Project{m.label}Forecast totalActual totalVariance
{project.name}{cellMoney(forecast)}{cellMoney(actual)}{cellMoney(variance)}
{project.name}{retentionManualOnly(project) && {project.excludedFromCashflow || project.status === "awarded" ? "Awarded, not started" : "Not in retention calculation"} · enter values manually}{cellMoney(forecast)}{cellMoney(actual)}{cellMoney(variance)}
Monthly totalsFcst {cellMoney(m.forecast)}Actual {cellMoney(m.actual)}{cellMoney(data.forecast)}{cellMoney(data.actual)}{cellMoney(data.variance)}
Retention payout · company{cellMoney(m.payout)}{cellMoney(data.payout)}
Retention balance · company{cellMoney(m.retentionBalance)}{cellMoney(data.months.at(-1)?.retentionBalance ?? null)}
{data.jobs.length === 0 &&

No retention projects match these filters.

} -

Starting figures use each project’s forecast or invoiced income multiplied by its retention rate. Saved monthly values override that calculation. A missing forecast starts at the actual value. Blank actual means not supplied; zero is a recorded amount. Variance compares only cells containing both forecast and actual values. These figures are retention held, before releases.

+

Starting figures use each project’s forecast or invoiced income multiplied by its retention rate. Saved monthly values override that calculation. Awarded and non-calculated projects stay blank until values are entered. A missing forecast starts at the actual value. Blank actual means not supplied; zero is a recorded amount. Variance compares only cells containing both forecast and actual values. These figures are retention held, before releases.

{editing && selected && m.index === editing.index)!.name} retention`} description="Enter forecast and actual retention held in rand for this project and month. A blank forecast copies the actual value. Leave actual blank for none. This does not change income or scheduled releases." forecast={selected.forecast} actual={selected.actual} saving={saving} closeOnSave={false} error={error} onSave={(forecast, actual) => { void save(forecast, actual); }} onOpenChange={(open) => { if (!open && !saving) setEditing(null); }} />} {editingStart && { void saveStart(value); }} onOpenChange={(open) => { if (!open && !saving) setEditingStart(false); }} />} diff --git a/src/lib/management/retention-series.test.ts b/src/lib/management/retention-series.test.ts index 496fa7c..f9716ae 100644 --- a/src/lib/management/retention-series.test.ts +++ b/src/lib/management/retention-series.test.ts @@ -36,6 +36,18 @@ describe("retention actual and forecast comparison", () => { expect(retentionCell(project, 0)).toEqual({ forecast: 8, actual: 9.6 }); expect(retentionCell({ ...project, excludedFromCashflow: true }, 0)).toEqual({ forecast: null, actual: null }); }); + it("lists every planned project, keeping awarded and non-eligible ones blank until values are entered", () => { + const board = buildManagementBoard({ ...snapshot, retentionStart: 0, manualRetentionPayouts: Array(12).fill(null), + projects: [job({ forecastIncome: values(100) }), job({ id: "awarded", name: "Awarded", status: "awarded", excludedFromCashflow: true, retentionEligible: false, forecastIncome: values(900) }), job({ id: "outside", name: "Outside", retentionEligible: false, forecastIncome: values(500) })], + retentionEdits: { outside: { "0": { forecast: 7, actual: null } } }, + }); + const data = retentionComparison(board, [0]); + expect(data.jobs.map((j) => j.project.id)).toEqual(["job", "awarded", "outside"]); + expect(data.jobs[1].cells[0]).toMatchObject({ forecast: null, actual: null }); + expect(data.jobs[2].cells[0]).toMatchObject({ forecast: 7, actual: null }); + expect(data.forecast).toBe(17); + expect(data.months[0].retentionBalance).toBe(17); + }); it("seeds a blank forecast from an actual zero, leaving other months calculated from income", () => { const project = job({ forecastIncome: Array(12).fill(100) }); const edits = { job: { "0": { forecast: null, actual: 0 } } }; diff --git a/src/lib/management/retention-series.ts b/src/lib/management/retention-series.ts index 875e282..a790f40 100644 --- a/src/lib/management/retention-series.ts +++ b/src/lib/management/retention-series.ts @@ -1,9 +1,13 @@ import type { ManagementBoard, PlanningProject, RetentionEdits } from "./model"; +/** Projects that are not calculated from income: awarded (not started) or outside the retention calculation. */ +export const retentionManualOnly = (project: PlanningProject) => !project.retentionEligible || project.excludedFromCashflow === true || project.status === "awarded"; + export function retentionCell(project: PlanningProject, index: number, edits: RetentionEdits = {}) { - if (!project.retentionEligible || project.excludedFromCashflow || project.status === "awarded") return { forecast: null, actual: null }; const saved = edits[project.id]?.[String(index)]; if (saved) return { ...saved, forecast: saved.forecast ?? saved.actual }; + // Not-yet-active projects are listed for planning but hold nothing until a value is entered. + if (retentionManualOnly(project)) return { forecast: null, actual: null }; const rate = project.retentionRates?.[index] ?? .1; const amount = (value: number | null | undefined) => value == null ? null : Math.round(value * rate * 100) / 100; return { forecast: amount(project.forecastBaseline?.[index] ?? project.forecastIncome?.[index] ?? project.actualIncome?.[index]), actual: amount(project.actualIncome?.[index]) }; @@ -15,7 +19,8 @@ const varianceFor = (cells: { forecast: number | null; actual: number | null }[] return matched.length ? Math.round(matched.reduce((s, c) => s + c.actual! - c.forecast!, 0) * 100) / 100 : null; }; export function retentionComparison(board: ManagementBoard, indexes: number[], query = "") { - const companyProjects = board.projects.filter((p) => p.retentionEligible && !p.excludedFromCashflow && p.status !== "awarded"); + // Every planned project is listed; awarded and non-eligible ones only add what is entered for them. + const companyProjects = board.projects; // Carry the balance through the full financial year before applying the view's month filter. // Opening retention and payouts are company totals, so this line always includes all projects. let runningBalance = board.retentionStart; @@ -28,7 +33,7 @@ export function retentionComparison(board: ManagementBoard, indexes: number[], q if (runningBalance !== null) runningBalance = Math.round((runningBalance + added - (board.manualRetentionPayouts[month.index] ?? 0)) * 100) / 100; balances.set(month.index, runningBalance); } - const projects = board.projects.filter((p) => p.retentionEligible && !p.excludedFromCashflow && p.status !== "awarded" && `${p.name} ${p.client ?? ""}`.toLowerCase().includes(query.toLowerCase())); + const projects = board.projects.filter((p) => `${p.name} ${p.client ?? ""}`.toLowerCase().includes(query.toLowerCase())); const jobs = projects.map((project) => { const cells = indexes.map((index) => ({ index, ...retentionCell(project, index, board.retentionEdits) })); return { project, cells, forecast: sum(cells.map((c) => c.forecast)), actual: sum(cells.map((c) => c.actual)), variance: varianceFor(cells) };