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 <noreply@anthropic.com>
This commit is contained in:
parent
cb4a1ce461
commit
07b12e98b4
5 changed files with 31 additions and 8 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 } };
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
<Stat label="Forecast retention held" value={compact(data.forecast)} accent={chartColors.forecast} note="Selected projects and months · purple" />
|
||||
<Stat label="Actual retention held" value={compact(data.actual)} accent={chartColors.payout} note="Selected projects and months · green" />
|
||||
<Stat label="Actual vs forecast variance" value={compact(data.variance)} note={`Actual − forecast · ${data.matchedCells} cells with both values`} />
|
||||
<Stat label="Retention projects" value={String(data.jobs.length)} note="Active, retention-eligible projects in view" />
|
||||
<Stat label="Retention projects" value={String(data.jobs.length)} note="All planned projects in view, including not-yet-active" />
|
||||
</div>
|
||||
<div className="mgmt-stat-grid">
|
||||
<div role="button" tabIndex={board.planId ? 0 : -1} aria-disabled={!board.planId || saving} aria-label="Edit RETENTION start" title="Double-click to set starting retention" className="cursor-pointer rounded-lg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary" onDoubleClick={openStart} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); openStart(); } }}>
|
||||
|
|
@ -105,11 +105,11 @@ export function RetentionComparison({ board }: { board: ManagementBoard }) {
|
|||
<div className="mgmt-table-scroll"><table className="mgmt-table mgmt-project-table mgmt-retention-table">
|
||||
<caption className="sr-only">Forecast and actual retention held by project and month, in rand</caption>
|
||||
<thead><tr><th className="mgmt-sticky" scope="col">Project</th>{months.map((m) => <th key={m.index} scope="col">{m.label}</th>)}<th scope="col">Forecast total</th><th scope="col">Actual total</th><th scope="col">Variance</th></tr></thead>
|
||||
<tbody>{data.jobs.map(({ project, cells, forecast, actual, variance }) => <tr key={project.id}><th className="mgmt-sticky" scope="row">{project.name}</th>{cells.map((cell) => <td key={cell.index}><button type="button" className="mgmt-cell-button" disabled={!board.planId || saving} aria-label={`Edit ${project.name} ${board.months.find((m) => m.index === cell.index)!.name} retention`} onClick={() => { setError(""); setEditing({ project, index: cell.index }); }}><span style={{ color: chartColors.forecast }}>Fcst {cellMoney(cell.forecast)}</span><span className="mgmt-cell-sub" style={{ color: chartColors.payout }}>Actual {cellMoney(cell.actual)}</span></button></td>)}<td style={{ color: chartColors.forecast }}>{cellMoney(forecast)}</td><td style={{ color: chartColors.payout }}>{cellMoney(actual)}</td><td>{cellMoney(variance)}</td></tr>)}</tbody>
|
||||
<tbody>{data.jobs.map(({ project, cells, forecast, actual, variance }) => <tr key={project.id}><th className="mgmt-sticky" scope="row" style={{ color: project.excludedFromCashflow ? "#dc2626" : undefined }}>{project.name}{retentionManualOnly(project) && <span className="mgmt-project-meta" style={{ color: project.excludedFromCashflow ? "#dc2626" : undefined }}>{project.excludedFromCashflow || project.status === "awarded" ? "Awarded, not started" : "Not in retention calculation"} · enter values manually</span>}</th>{cells.map((cell) => <td key={cell.index}><button type="button" className="mgmt-cell-button" disabled={!board.planId || saving} aria-label={`Edit ${project.name} ${board.months.find((m) => m.index === cell.index)!.name} retention`} onClick={() => { setError(""); setEditing({ project, index: cell.index }); }}><span style={{ color: chartColors.forecast }}>Fcst {cellMoney(cell.forecast)}</span><span className="mgmt-cell-sub" style={{ color: chartColors.payout }}>Actual {cellMoney(cell.actual)}</span></button></td>)}<td style={{ color: chartColors.forecast }}>{cellMoney(forecast)}</td><td style={{ color: chartColors.payout }}>{cellMoney(actual)}</td><td>{cellMoney(variance)}</td></tr>)}</tbody>
|
||||
<tfoot><tr><th className="mgmt-sticky" scope="row">Monthly totals</th>{data.months.map((m) => <td key={m.index}><span style={{ color: chartColors.forecast }}>Fcst {cellMoney(m.forecast)}</span><span className="mgmt-cell-sub" style={{ color: chartColors.payout }}>Actual {cellMoney(m.actual)}</span></td>)}<td>{cellMoney(data.forecast)}</td><td>{cellMoney(data.actual)}</td><td>{cellMoney(data.variance)}</td></tr><tr><th className="mgmt-sticky" scope="row">Retention payout · company</th>{data.months.map((m) => <td key={m.index} style={{ color: chartColors.retentionPayout }}>{cellMoney(m.payout)}</td>)}<td colSpan={3} style={{ color: chartColors.retentionPayout }}>{cellMoney(data.payout)}</td></tr><tr><th className="mgmt-sticky" scope="row">Retention balance · company</th>{data.months.map((m) => <td key={m.index}>{cellMoney(m.retentionBalance)}</td>)}<td colSpan={3}>{cellMoney(data.months.at(-1)?.retentionBalance ?? null)}</td></tr></tfoot>
|
||||
</table></div>
|
||||
{data.jobs.length === 0 && <p className="mgmt-empty">No retention projects match these filters.</p>}
|
||||
<p className="mgmt-source-note">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.</p>
|
||||
<p className="mgmt-source-note">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.</p>
|
||||
</Panel>
|
||||
{editing && selected && <IncomeCellDialog key={`${editing.project.id}-${editing.index}`} open title={`${editing.project.name} · ${board.months.find((m) => 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 && <RetentionStartDialog value={retentionStart} saving={saving} error={error} onSave={(value) => { void saveStart(value); }} onOpenChange={(open) => { if (!open && !saving) setEditingStart(false); }} />}
|
||||
|
|
|
|||
|
|
@ -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 } } };
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
Loading…
Reference in a new issue