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) };