From 9a6aed50a466d8e6357e32f4d9e207affc33459f Mon Sep 17 00:00:00 2001 From: DEls Date: Fri, 9 Oct 2026 10:16:50 +0200 Subject: [PATCH] feat(management): add admin planning dashboards Add turnover, project, retention, and year comparison pages from the supplied workbook, protected by named admin access. Include the local chart gallery, grouped monthly hover, smooth comparison toggle, and monthly income minus cost series. Require named PocketBase sign-in in every environment and remove the shared PIN bypass. Update access documentation and business-route test fixtures. --- .env.local.example | 15 +- README.md | 6 +- design-previews/management-chart-gallery.html | 106 ++ docs/setup/user-access.md | 29 +- src/app/api/admin/users/[id]/route.test.ts | 5 +- src/app/api/admin/users/route.test.ts | 5 +- src/app/api/chat/threads/[id]/route.test.ts | 1 + src/app/api/chat/threads/route.test.ts | 7 +- .../api/clock/employee-lookup/route.test.ts | 1 + .../clock/events/[id]/correct/route.test.ts | 6 +- src/app/api/clock/scan/route.test.ts | 1 + .../api/clock/vehicle-lookup/route.test.ts | 1 + src/app/api/clock/vehicle-scan/route.test.ts | 1 + .../[id]/retry/route.test.ts | 1 + .../[id]/status/route.test.ts | 1 + .../dn-batches/[id]/assembly/route.test.ts | 1 + .../api/dn-batches/[id]/dismiss/route.test.ts | 1 + .../[noteId]/correct-receipt/route.test.ts | 1 + .../[id]/notes/[noteId]/dismiss/route.test.ts | 1 + .../[id]/notes/[noteId]/post/route.test.ts | 1 + .../[noteId]/resolve-entities/route.test.ts | 1 + .../notes/[noteId]/resolve-item/route.test.ts | 1 + .../[id]/notes/[noteId]/route.test.ts | 1 + .../dn-batches/[id]/pages/[n]/route.test.ts | 1 + .../api/dn-batches/[id]/rerun/route.test.ts | 1 + src/app/api/plant-hours/[id]/route.test.ts | 5 +- .../[id]/items/[itemId]/route.test.ts | 1 + .../purchase-orders/[id]/items/route.test.ts | 1 + .../[id]/supplier/route.test.ts | 1 + .../api/sites/[id]/fixed-costs/route.test.ts | 1 + src/app/api/sites/[id]/route.test.ts | 1 + .../[id]/attention-dismiss/route.test.ts | 1 + src/app/api/vehicles/[id]/merge/route.test.ts | 1 + src/app/api/vehicles/[id]/route.test.ts | 1 + src/app/api/vehicles/route.test.ts | 1 + .../dashboard/management/comparison-view.tsx | 29 + .../dashboard/management/comparison/page.tsx | 6 + src/app/dashboard/management/layout.tsx | 5 + .../management/management-charts.tsx | 91 ++ .../dashboard/management/management-ui.tsx | 77 ++ src/app/dashboard/management/management.css | 125 ++ src/app/dashboard/management/page.tsx | 7 + .../dashboard/management/projects-view.tsx | 57 + .../dashboard/management/projects/page.tsx | 6 + .../dashboard/management/retention-view.tsx | 24 + .../dashboard/management/retention/page.tsx | 6 + .../dashboard/management/turnover-view.tsx | 40 + .../dashboard/management/turnover/page.tsx | 6 + src/app/layout.tsx | 5 +- src/app/login/page.tsx | 198 +-- src/components/layout/sidebar-access.test.tsx | 10 + src/components/layout/sidebar.tsx | 15 + src/lib/agent/context.test.ts | 83 +- src/lib/auth/access.test.ts | 8 + src/lib/auth/access.ts | 4 +- src/lib/auth/path-access.test.ts | 6 + src/lib/auth/path-access.ts | 1 + src/lib/auth/proxy-path.ts | 2 + src/lib/auth/session.test.ts | 6 +- src/lib/auth/session.ts | 13 +- src/lib/dn-pipeline/fencing.test.ts | 1 + src/lib/management/data.ts | 12 + src/lib/management/model.test.ts | 60 + src/lib/management/model.ts | 123 ++ src/lib/management/workbook-snapshot.json | 1174 +++++++++++++++++ src/proxy.test.ts | 11 + src/proxy.ts | 1 - src/test-utils/named-admin-session.ts | 15 + 68 files changed, 2143 insertions(+), 294 deletions(-) create mode 100644 design-previews/management-chart-gallery.html create mode 100644 src/app/dashboard/management/comparison-view.tsx create mode 100644 src/app/dashboard/management/comparison/page.tsx create mode 100644 src/app/dashboard/management/layout.tsx create mode 100644 src/app/dashboard/management/management-charts.tsx create mode 100644 src/app/dashboard/management/management-ui.tsx create mode 100644 src/app/dashboard/management/management.css create mode 100644 src/app/dashboard/management/page.tsx create mode 100644 src/app/dashboard/management/projects-view.tsx create mode 100644 src/app/dashboard/management/projects/page.tsx create mode 100644 src/app/dashboard/management/retention-view.tsx create mode 100644 src/app/dashboard/management/retention/page.tsx create mode 100644 src/app/dashboard/management/turnover-view.tsx create mode 100644 src/app/dashboard/management/turnover/page.tsx create mode 100644 src/lib/management/data.ts create mode 100644 src/lib/management/model.test.ts create mode 100644 src/lib/management/model.ts create mode 100644 src/lib/management/workbook-snapshot.json create mode 100644 src/test-utils/named-admin-session.ts diff --git a/.env.local.example b/.env.local.example index 30db1e7..cea932a 100644 --- a/.env.local.example +++ b/.env.local.example @@ -1,14 +1,7 @@ -# Set to users after applying the login migration and creating the first admin. -# pin keeps the current shared-PIN flow for a staged rollout. -AUTH_MODE=pin - -# PIN gate — used only while AUTH_MODE=pin. Exposed to the client because the -# lock screen validates it locally before talking to PocketBase. -NEXT_PUBLIC_APP_PIN=2468 -# Server-side copy of the same PIN. The On-site "Needs attention" missed -# scan-out correction (/api/clock/events/[id]/correct) checks it in pin mode and -# returns 503 when unset. Keep it equal to NEXT_PUBLIC_APP_PIN. -APP_PIN=2468 +# Named PocketBase accounts are required in every environment. +# Apply the login migration and create an admin before starting the app. +# Shared PIN sign-in is disabled, including when legacy PIN settings remain. +AUTH_MODE=users # Brand NEXT_PUBLIC_APP_NAME=MEGA INFRASTRUCTURE diff --git a/README.md b/README.md index 2b5f8d4..f7faf2f 100644 --- a/README.md +++ b/README.md @@ -11,10 +11,10 @@ charts, reads any file you attach, remembers lasting facts, changes data through confirm-first proposed actions with undo, and files its own PDF reports and app change requests — plus a read-only MCP server. -> Named PocketBase logins are available with `AUTH_MODE=users` after the +> Named PocketBase logins are required after the > [login migration](./scripts/migrations/2026-09-28-users-login.py). -> `AUTH_MODE=pin` remains the rollout fallback. The PIN has no data access -> in named-login mode. +> Shared PIN sign-in is disabled. Local development uses the same named +> sign-in as production. Management planning is available only to admins. --- diff --git a/design-previews/management-chart-gallery.html b/design-previews/management-chart-gallery.html new file mode 100644 index 0000000..0704e89 --- /dev/null +++ b/design-previews/management-chart-gallery.html @@ -0,0 +1,106 @@ + + + + + +Management · Chart Gallery + + + +
Management Chart direction / local preview
+
+

A collection for the management board

Find your
chart language.

Sixteen visual directions, from sharp two-year lines to dimensional ribbons. Hover to explore, switch themes, and shortlist the ones you like. Tell me the chart numbers and we can use them in the dashboard.

16chart concepts
02themes

Illustrative figures in R millions.
These are style previews.
Start with 01 or 02 for Year Comparison.

+
2024 / previous2025 / current
+
+

Your shortlist

Choose any combination. The chart number and name make each option easy to request.

No charts selected yet.
+
Management chart gallery · Standalone HTML · Works offlineIllustrative data only · Hover charts or focus them and use ← / → to inspect values
+
+ + + diff --git a/docs/setup/user-access.md b/docs/setup/user-access.md index 427eb2e..7de225b 100644 --- a/docs/setup/user-access.md +++ b/docs/setup/user-access.md @@ -1,15 +1,38 @@ # User access -Named account access requires `AUTH_MODE=users`. The legacy shared PIN mode -does not provide individual account restrictions. +Named PocketBase sign-in is required in every environment. Shared PIN sign-in +is disabled, even if an old environment file still contains `AUTH_MODE=pin`. | Account type | App access | Site scope | User settings | | --- | --- | --- | --- | -| Admin | Dashboard, Ask, Clock | All sites | Manage users | +| Admin | Dashboard, Management, Ask, Clock | All sites | Manage users | | Site manager | Operational dashboard and Clock | Assigned sites | Create and list clock accounts within assigned sites | | Clock user | Clock only: worker/fleet scans and document uploads | Assigned sites | None | | Office user | Selected dashboard sections with full actions | Assigned sites or All sites | None | +## Management planning + +Management sits below Overview in the navigation. Its Turnover Overview, +Project Planning, Retention and Year Comparison pages require a named admin +account. Operational Site manager accounts and Office accounts have no access, +including those with all sites or every operational section enabled. Future +pages under `/dashboard/management/` inherit this restriction. + +The initial data comes from `src/lib/management/workbook-snapshot.json`, +extracted from the supplied turnover workbook. `data.ts` checks access before +loading it; `model.ts` calculates results independently. Replace the provider's +source when connecting the planning database. Never import the snapshot into +a client component or put it in `public/`. + +The overview preserves the workbook's reported monthly income and cost +summaries, including its manual values. Project detail remains separate and +the reconciliation shows the differences. All amounts are ZAR. The main plan +follows March–February headings from the workbook titled 2025, without +inventing exact reporting years. Retention releases have their own supplied +November 2025–October 2026 dates. Year Comparison follows the source's actual +January–December 2024 and 2025 columns, including its estimates. There is no +live actuals feed or planning editor yet. + ## Office setup In Settings · Users, choose Office user, then an access preset (Workforce, diff --git a/src/app/api/admin/users/[id]/route.test.ts b/src/app/api/admin/users/[id]/route.test.ts index b1ae992..4eba042 100644 --- a/src/app/api/admin/users/[id]/route.test.ts +++ b/src/app/api/admin/users/[id]/route.test.ts @@ -108,9 +108,10 @@ describe("admin user deletion", () => { expect(getUser).not.toHaveBeenCalled(); expect(deleteUser).not.toHaveBeenCalled(); }); - it("is unavailable in PIN mode", async () => { + it("requires a named account when legacy PIN configuration is present", async () => { vi.stubEnv("AUTH_MODE", "pin"); - expect((await remove()).status).toBe(404); + actor = Response.json({ error: "Sign in required" }, { status: 401 }); + expect((await remove()).status).toBe(401); expect(deleteUser).not.toHaveBeenCalled(); }); it("rejects an invalid id", async () => { diff --git a/src/app/api/admin/users/route.test.ts b/src/app/api/admin/users/route.test.ts index 39d8081..8d07050 100644 --- a/src/app/api/admin/users/route.test.ts +++ b/src/app/api/admin/users/route.test.ts @@ -80,9 +80,10 @@ describe("admin user creation", () => { expect(await (await GET()).json()).toMatchObject({ users: [{ id: "visible" }], sites: [{ id: "site1" }] }); }); - it("does not expose user creation in PIN fallback mode", async () => { + it("requires a named account even when legacy PIN configuration is present", async () => { vi.stubEnv("AUTH_MODE", "pin"); - expect((await POST(request())).status).toBe(404); + actor = Response.json({ error: "Sign in required" }, { status: 401 }); + expect((await POST(request())).status).toBe(401); expect(createUser).not.toHaveBeenCalled(); }); }); diff --git a/src/app/api/chat/threads/[id]/route.test.ts b/src/app/api/chat/threads/[id]/route.test.ts index 2c341a4..54a3036 100644 --- a/src/app/api/chat/threads/[id]/route.test.ts +++ b/src/app/api/chat/threads/[id]/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const getFullListUsersMock = vi.fn(); diff --git a/src/app/api/chat/threads/route.test.ts b/src/app/api/chat/threads/route.test.ts index a45f639..d084cf0 100644 --- a/src/app/api/chat/threads/route.test.ts +++ b/src/app/api/chat/threads/route.test.ts @@ -1,3 +1,5 @@ +import "@/test-utils/named-admin-session"; +import { getCurrentUser } from "@/lib/auth/session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const getFullListUsersMock = vi.fn(); @@ -40,9 +42,10 @@ describe("/api/chat/threads", () => { getFullListUsersMock.mockResolvedValue([{ id: "u1", name: "Dashboard" }]); }); - it("GET requires x-ask-user", async () => { + it("GET requires a named admin session", async () => { + vi.mocked(getCurrentUser).mockResolvedValueOnce(null); const res = await GET(req("GET")); - expect(res.status).toBe(400); + expect(res.status).toBe(403); }); it("GET lists the actor's threads newest first", async () => { diff --git a/src/app/api/clock/employee-lookup/route.test.ts b/src/app/api/clock/employee-lookup/route.test.ts index 3a50d35..0571736 100644 --- a/src/app/api/clock/employee-lookup/route.test.ts +++ b/src/app/api/clock/employee-lookup/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const employeesGetFirst = vi.fn(); diff --git a/src/app/api/clock/events/[id]/correct/route.test.ts b/src/app/api/clock/events/[id]/correct/route.test.ts index 1495ab9..d0ab5ac 100644 --- a/src/app/api/clock/events/[id]/correct/route.test.ts +++ b/src/app/api/clock/events/[id]/correct/route.test.ts @@ -23,10 +23,12 @@ function request(pin = "2468", origin = "https://mega.example", body: unknown = } describe("manual clock-out route", () => { - beforeEach(() => { correct.mockReset(); currentUser.mockReset(); requireRecordSite.mockReset(); vi.stubEnv("APP_PIN", "2468"); vi.stubEnv("AUTH_MODE", "pin"); }); - it("rejects a missing or wrong PIN and cross-origin request before writing", async () => { + beforeEach(() => { correct.mockReset(); currentUser.mockReset(); requireRecordSite.mockReset(); vi.stubEnv("APP_PIN", "2468"); vi.stubEnv("AUTH_MODE", "pin"); currentUser.mockResolvedValue({ id: "user1", email: "site@example.test", role: "site_user", siteIds: ["site1"], mustChangePassword: false }); requireRecordSite.mockResolvedValue(null); }); + it("rejects anonymous and cross-origin writes even with the correct legacy PIN", async () => { + currentUser.mockResolvedValue(null); expect((await request("")).status).toBe(401); expect((await request("wrong")).status).toBe(401); + expect((await request("2468")).status).toBe(401); expect((await request("2468", "https://other.example")).status).toBe(403); expect(correct).not.toHaveBeenCalled(); }); diff --git a/src/app/api/clock/scan/route.test.ts b/src/app/api/clock/scan/route.test.ts index e3891b4..9ae25f1 100644 --- a/src/app/api/clock/scan/route.test.ts +++ b/src/app/api/clock/scan/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const employeesGetFirst = vi.fn(); diff --git a/src/app/api/clock/vehicle-lookup/route.test.ts b/src/app/api/clock/vehicle-lookup/route.test.ts index b230520..810bf35 100644 --- a/src/app/api/clock/vehicle-lookup/route.test.ts +++ b/src/app/api/clock/vehicle-lookup/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const vehiclesGetFirst = vi.fn(); diff --git a/src/app/api/clock/vehicle-scan/route.test.ts b/src/app/api/clock/vehicle-scan/route.test.ts index a9fba72..c8f259b 100644 --- a/src/app/api/clock/vehicle-scan/route.test.ts +++ b/src/app/api/clock/vehicle-scan/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const vehiclesGetFirst = vi.fn(); diff --git a/src/app/api/data-import-batches/[id]/retry/route.test.ts b/src/app/api/data-import-batches/[id]/retry/route.test.ts index b2bcb4f..1cc32ff 100644 --- a/src/app/api/data-import-batches/[id]/retry/route.test.ts +++ b/src/app/api/data-import-batches/[id]/retry/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; // Mock pbAdmin so the route doesn't hit a real PocketBase instance. diff --git a/src/app/api/data-import-batches/[id]/status/route.test.ts b/src/app/api/data-import-batches/[id]/status/route.test.ts index 00b617c..db03a50 100644 --- a/src/app/api/data-import-batches/[id]/status/route.test.ts +++ b/src/app/api/data-import-batches/[id]/status/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeAll, afterAll } from "vitest"; // Mock pbAdmin so the route doesn't hit a real PocketBase instance. diff --git a/src/app/api/dn-batches/[id]/assembly/route.test.ts b/src/app/api/dn-batches/[id]/assembly/route.test.ts index 1e8e9a5..ac8eb77 100644 --- a/src/app/api/dn-batches/[id]/assembly/route.test.ts +++ b/src/app/api/dn-batches/[id]/assembly/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { mockCreateBatch } from "@/lib/dn-pipeline/test-batch"; diff --git a/src/app/api/dn-batches/[id]/dismiss/route.test.ts b/src/app/api/dn-batches/[id]/dismiss/route.test.ts index 0d32f23..5ebd495 100644 --- a/src/app/api/dn-batches/[id]/dismiss/route.test.ts +++ b/src/app/api/dn-batches/[id]/dismiss/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; /** * Tests for POST /api/dn-batches/[id]/dismiss. * diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/correct-receipt/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/correct-receipt/route.test.ts index c718c25..a56a4ca 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/correct-receipt/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/correct-receipt/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { transactionalPb } from "@/lib/dn-pipeline/test-batch"; diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/dismiss/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/dismiss/route.test.ts index 45f9c06..07a89ad 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/dismiss/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/dismiss/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; /** * Tests for POST /api/dn-batches/[id]/notes/[noteId]/dismiss. */ diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts index 3120a34..a3ef04b 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/post/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { beforeEach, describe, expect, it, vi } from "vitest"; const BATCH = "batch0000000001"; diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.test.ts index 8c8cad3..26f4c6f 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-entities/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { beforeEach, describe, expect, it, vi } from "vitest"; const pbAdmin = vi.fn(); diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.test.ts index e0b9467..5cd00c4 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/resolve-item/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { beforeEach, describe, expect, it, vi } from "vitest"; const pbAdmin = vi.fn(); diff --git a/src/app/api/dn-batches/[id]/notes/[noteId]/route.test.ts b/src/app/api/dn-batches/[id]/notes/[noteId]/route.test.ts index b40b684..3e3086f 100644 --- a/src/app/api/dn-batches/[id]/notes/[noteId]/route.test.ts +++ b/src/app/api/dn-batches/[id]/notes/[noteId]/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; /** * Tests for PATCH /api/dn-batches/[id]/notes/[noteId]. * diff --git a/src/app/api/dn-batches/[id]/pages/[n]/route.test.ts b/src/app/api/dn-batches/[id]/pages/[n]/route.test.ts index 5fe7080..2312258 100644 --- a/src/app/api/dn-batches/[id]/pages/[n]/route.test.ts +++ b/src/app/api/dn-batches/[id]/pages/[n]/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; /** * Tests for GET /api/dn-batches/[id]/pages/[n]. * diff --git a/src/app/api/dn-batches/[id]/rerun/route.test.ts b/src/app/api/dn-batches/[id]/rerun/route.test.ts index a631483..ab2cde4 100644 --- a/src/app/api/dn-batches/[id]/rerun/route.test.ts +++ b/src/app/api/dn-batches/[id]/rerun/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; /** * Tests for POST /api/dn-batches/[id]/rerun. * diff --git a/src/app/api/plant-hours/[id]/route.test.ts b/src/app/api/plant-hours/[id]/route.test.ts index fda0aab..5436397 100644 --- a/src/app/api/plant-hours/[id]/route.test.ts +++ b/src/app/api/plant-hours/[id]/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi } from "vitest"; vi.mock("@/lib/pb", () => ({ @@ -14,11 +15,11 @@ import { DELETE } from "./route"; describe("DELETE /api/plant-hours/[id]", () => { it("deletes the row and returns 200", async () => { const res = await DELETE( - {} as unknown as Request, + new Request("http://localhost/api/plant-hours/row-1", { method: "DELETE" }), { params: Promise.resolve({ id: "row-1" }) }, ); expect(res.status).toBe(200); const json = await res.json(); expect(json.ok).toBe(true); }); -}); \ No newline at end of file +}); diff --git a/src/app/api/purchase-orders/[id]/items/[itemId]/route.test.ts b/src/app/api/purchase-orders/[id]/items/[itemId]/route.test.ts index 9959970..e51498b 100644 --- a/src/app/api/purchase-orders/[id]/items/[itemId]/route.test.ts +++ b/src/app/api/purchase-orders/[id]/items/[itemId]/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import type PocketBase from "pocketbase"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; diff --git a/src/app/api/purchase-orders/[id]/items/route.test.ts b/src/app/api/purchase-orders/[id]/items/route.test.ts index e6d3d95..616f90c 100644 --- a/src/app/api/purchase-orders/[id]/items/route.test.ts +++ b/src/app/api/purchase-orders/[id]/items/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import type PocketBase from "pocketbase"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; diff --git a/src/app/api/purchase-orders/[id]/supplier/route.test.ts b/src/app/api/purchase-orders/[id]/supplier/route.test.ts index 9dc800d..a4f98cc 100644 --- a/src/app/api/purchase-orders/[id]/supplier/route.test.ts +++ b/src/app/api/purchase-orders/[id]/supplier/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import type PocketBase from "pocketbase"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; diff --git a/src/app/api/sites/[id]/fixed-costs/route.test.ts b/src/app/api/sites/[id]/fixed-costs/route.test.ts index 6b14347..bd54a54 100644 --- a/src/app/api/sites/[id]/fixed-costs/route.test.ts +++ b/src/app/api/sites/[id]/fixed-costs/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const pbCreate = vi.fn(); diff --git a/src/app/api/sites/[id]/route.test.ts b/src/app/api/sites/[id]/route.test.ts index aa8f88a..11fff42 100644 --- a/src/app/api/sites/[id]/route.test.ts +++ b/src/app/api/sites/[id]/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeEach } from "vitest"; const pbUpdate = vi.fn(); diff --git a/src/app/api/vehicles/[id]/attention-dismiss/route.test.ts b/src/app/api/vehicles/[id]/attention-dismiss/route.test.ts index 31b2718..17558e0 100644 --- a/src/app/api/vehicles/[id]/attention-dismiss/route.test.ts +++ b/src/app/api/vehicles/[id]/attention-dismiss/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; const updateMock = vi.fn(); diff --git a/src/app/api/vehicles/[id]/merge/route.test.ts b/src/app/api/vehicles/[id]/merge/route.test.ts index 52c5979..a12f9b3 100644 --- a/src/app/api/vehicles/[id]/merge/route.test.ts +++ b/src/app/api/vehicles/[id]/merge/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { beforeEach, describe, expect, it, vi } from "vitest"; type Op = { collection: string; op: "update" | "delete"; id: string; data?: Record }; diff --git a/src/app/api/vehicles/[id]/route.test.ts b/src/app/api/vehicles/[id]/route.test.ts index 97acc4f..d40b009 100644 --- a/src/app/api/vehicles/[id]/route.test.ts +++ b/src/app/api/vehicles/[id]/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; const updateMock = vi.fn(); diff --git a/src/app/api/vehicles/route.test.ts b/src/app/api/vehicles/route.test.ts index 80389fc..851ea3e 100644 --- a/src/app/api/vehicles/route.test.ts +++ b/src/app/api/vehicles/route.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; const createMock = vi.fn(); diff --git a/src/app/dashboard/management/comparison-view.tsx b/src/app/dashboard/management/comparison-view.tsx new file mode 100644 index 0000000..86b78e8 --- /dev/null +++ b/src/app/dashboard/management/comparison-view.tsx @@ -0,0 +1,29 @@ +"use client"; + +import type { ManagementBoard } from "@/lib/management/model"; +import { ComparisonChart } from "./management-charts"; +import { Change, compactMoney, ManagementShell, money, Panel, Stat } from "./management-ui"; + +const calendarMonths = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +export function ComparisonView({ board }: { board: ManagementBoard }) { + const [previous, current] = board.comparisons; + const data = calendarMonths.map((label, index) => ({ label, previous: previous.months[index].income, current: current.months[index].income })); + const rows = [ + { label: "Scheduled income", previous: previous.income, current: current.income }, + { label: "Scheduled costs", previous: previous.cost, current: current.cost }, + { label: "Retention payouts", previous: previous.payouts, current: current.payouts }, + { label: "Reported cashflow result", previous: previous.cashResult, current: current.cashResult }, + { label: "Closing retention", previous: previous.retentionClosing, current: current.retentionClosing }, + { label: "Reported result including retention", previous: previous.resultWithRetention, current: current.resultWithRetention }, + ]; + return +
= 0 ? "positive" : "negative"} note="Includes the sheet’s retention payouts" />
+ +
{rows.map((r) => )}
Measure{previous.year}{current.year}Difference
{r.label}{compactMoney(r.previous)}{compactMoney(r.current)}{compactMoney(r.current - r.previous)}

The source’s December payout cells are full-year subtotals, so they are counted once. “Result including retention” includes the closing retained balance; it is not the same measure as the turnover overview’s operating result.

+
{data.map((m, i) => )}
Month{previous.year} income{current.year} income{current.year} cost
{m.label}{money(m.previous)}{money(m.current)}{money(current.months[i].cost)}
+

Retention, work in progress & liabilities

The two October balance snapshots included in the workbook

+
{board.balanceSnapshots.map((s) =>
{s.liabilities.map((l) => )}
LiabilityAmount
{l.name}{money(l.amount)}
Retention{compactMoney(s.retention)}
Work in progress{compactMoney(s.wip)}
Net position{compactMoney(s.reportedNet)}
)}
+

Change in the reported net position

These are saved source figures, not current balances. The historical “Minus VAT” formula uses × 0.85; it is not used for this comparison because it does not undo the source’s × 1.15 conversion.

+
; +} diff --git a/src/app/dashboard/management/comparison/page.tsx b/src/app/dashboard/management/comparison/page.tsx new file mode 100644 index 0000000..b49f9f7 --- /dev/null +++ b/src/app/dashboard/management/comparison/page.tsx @@ -0,0 +1,6 @@ +import { getManagementBoard } from "@/lib/management/data"; +import { ComparisonView } from "../comparison-view"; + +export default async function ComparisonPage() { + return ; +} diff --git a/src/app/dashboard/management/layout.tsx b/src/app/dashboard/management/layout.tsx new file mode 100644 index 0000000..5ed3e96 --- /dev/null +++ b/src/app/dashboard/management/layout.tsx @@ -0,0 +1,5 @@ +import "./management.css"; + +export default function ManagementLayout({ children }: { children: React.ReactNode }) { + return
{children}
; +} diff --git a/src/app/dashboard/management/management-charts.tsx b/src/app/dashboard/management/management-charts.tsx new file mode 100644 index 0000000..e02a544 --- /dev/null +++ b/src/app/dashboard/management/management-charts.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useId, useState, type CSSProperties } from "react"; +import { Area, AreaChart, Bar, BarChart, CartesianGrid, ComposedChart, Line, ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts"; +import type { PlanningMonth } from "@/lib/management/model"; +import { Legend, money } from "./management-ui"; + +export const chartColors = { income: "#e5a12c", cost: "#8492a6", result: "#36a893", retention: "#7c8edb", previous: "#8492a6" }; +const axis = { tick: { fontSize: 11, fill: "var(--muted-foreground)" }, tickLine: false, axisLine: false }; +const currencyTick = (value: number) => `${value < 0 ? "−" : ""}R${Number((Math.abs(value) / 1_000_000).toFixed(1))}m`; + +function FinancialTooltip({ active, payload, label }: { active?: boolean; payload?: { name?: string; value?: number | null; color?: string }[]; label?: string | number }) { + if (!active || !payload?.length) return null; + return
{label}{payload.map((item) =>
{item.name}{money(item.value ?? null)}
)}
; +} + +type ChartMode = "columns" | "smooth"; +type PairedMonth = { label: string; first: number | null; second: number | null; difference?: number | null }; +type Series = { dataKey: "first" | "second" | "difference"; label: string; color: string }; + +// Keep the shared readout centered in reserved space above the plotted values. +const fixedTooltipStyle: CSSProperties = { transform: "translateX(-50%)", left: "50%", top: -8 }; + +function PairedMonthChart({ data, series, title, emphasis, description }: { + data: PairedMonth[]; series: Series[]; title: string; emphasis: 0 | 1; description?: string; +}) { + const [mode, setMode] = useState("columns"); + const gradientId = useId().replace(/:/g, ""); + const Chart = mode === "columns" ? BarChart : AreaChart; + const tooltipSpace = series.length === 3 ? 116 : 92; + return
+
+ +
+ + +
+
+
+ + + {series.map((s, i) => + + + )} + + + + } isAnimationActive={false} wrapperStyle={fixedTooltipStyle} + cursor={mode === "columns" ? { fill: "var(--muted)", opacity: .3, stroke: "none" } : { stroke: "var(--muted-foreground)", strokeDasharray: "3 5" }} /> + {series.map((s, i) => mode === "columns" + ? + : )} + + +
+ {description &&

{description}

} +
; +} + +export function IncomeCostChart({ months }: { months: { label: string; income: number | null; cost: number | null }[] }) { + return ({ label: m.label, first: m.income, second: m.cost, + difference: m.income === null || m.cost === null ? null : m.income - m.cost }))} + series={[{ dataKey: "first", label: "Income", color: chartColors.income }, { dataKey: "second", label: "Cost", color: chartColors.cost }, + { dataKey: "difference", label: "Income − cost", color: chartColors.result }]} emphasis={0} + description="Income − cost shows the monthly planned difference. It excludes retention payouts; values below zero are losses." />; +} + +export function ResultChart({ months }: { months: PlanningMonth[] }) { + return
+ + } cursor={{ stroke: "var(--muted-foreground)", strokeDasharray: "3 5" }} /> + +
; +} + +export function RetentionChart({ months }: { months: PlanningMonth[] }) { + return
+ + } cursor={{ fill: "var(--muted)", opacity: .45 }} /> + +
; +} + +export function ComparisonChart({ data, years }: { data: { label: string; previous: number | null; current: number | null }[]; years: number[] }) { + return ({ label: m.label, first: m.previous, second: m.current }))} + series={[{ dataKey: "first", label: String(years[0]), color: chartColors.previous }, { dataKey: "second", label: String(years[1]), color: chartColors.income }]} emphasis={1} />; +} diff --git a/src/app/dashboard/management/management-ui.tsx b/src/app/dashboard/management/management-ui.tsx new file mode 100644 index 0000000..3c564b2 --- /dev/null +++ b/src/app/dashboard/management/management-ui.tsx @@ -0,0 +1,77 @@ +"use client"; + +import Link from "next/link"; +import { ArrowDownRight, ArrowUpRight, CircleHelp, ShieldCheck } from "lucide-react"; +import { ThemeToggle } from "@/components/theme-toggle"; +import { cn } from "@/lib/utils"; +import type { ManagementBoard } from "@/lib/management/model"; + +const currency = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR", minimumFractionDigits: 2, maximumFractionDigits: 2 }); +export const money = (value: number | null) => value === null ? "Unavailable" : currency.format(value); +export const compactMoney = (value: number) => `${value < 0 ? "−" : ""}R${(Math.abs(value) / 1_000_000).toFixed(2)}m`; +export const percent = (value: number | null) => value === null ? "—" : `${(value * 100).toFixed(2)}%`; +export const dateLabel = (date: string) => new Intl.DateTimeFormat("en-ZA", { month: "short", year: "numeric", timeZone: "UTC" }).format(new Date(date)); + +const pages = [ + ["turnover", "Turnover Overview"], ["projects", "Project Planning"], + ["retention", "Retention"], ["comparison", "Year Comparison"], +] as const; + +export function ManagementShell({ active, title, description, board, children }: { + active: typeof pages[number][0]; title: string; description: string; + board: ManagementBoard; children: React.ReactNode; +}) { + return <> +
+

Management / Company portfolio

{title}

+
Admin access
+
+
+

{description}

Planning snapshot
+ + {children} +
Source: {board.sourceFile}Saved planning values · {board.periodLabel}
+
+ ; +} + +export function Panel({ title, subtitle, action, children, className }: { title: string; subtitle?: string; action?: React.ReactNode; children: React.ReactNode; className?: string }) { + return

{title}

{subtitle &&

{subtitle}

}
{action}
{children}
; +} + +export function Stat({ label, value, note, tone, compact }: { label: string; value: string; note?: string; tone?: "positive" | "negative"; compact?: boolean }) { + return

{label}

{value}
{note && {note}}
; +} + +export function Change({ value, percentage = false }: { value: number; percentage?: boolean }) { + const Icon = value < 0 ? ArrowDownRight : ArrowUpRight; + return {percentage ? `${Math.abs(value * 100).toFixed(1)}%` : compactMoney(value)}; +} + +export function Legend({ items }: { items: { label: string; color: string }[] }) { + return
{items.map((item) => {item.label})}
; +} + +export function Filter({ label, value, onChange, children }: { label: string; value: string; onChange: (value: string) => void; children: React.ReactNode }) { + return ; +} + +export function Reconciliation({ board }: { board: ManagementBoard }) { + return
Workbook reconciliationReported summaries differ from project detailView differences +
+

The overview follows the saved monthly summaries. Project Planning uses the project entries. These are different views of the supplied workbook.

+
+
Reported turnover{money(board.totals.income)}
Project income entries{money(board.totals.projectIncome)}
Reported minus detail{money(board.totals.incomeDifference)}
+
Reported monthly costs{money(board.totals.cost)}
Cost sheet annual total{money(board.totals.statedAnnualCost)}
Detail costs + head office{money(board.totals.projectCost + board.totals.headOfficeCost)}
+
+
{board.months.map((m) => )}
Monthly reconciliation between reported summaries and project detail
MonthReported incomeProject incomeDifferenceReported costDetail + head officeDifference
{m.name}{money(m.income)}{money(m.projectIncome)} .01 ? "mgmt-difference" : ""}>{money(m.incomeDifference)}{money(m.cost)}{money(m.projectCost + m.headOfficeCost)}{money(m.costDifference)}
+
    +
  • November, December and January income summaries are manual values. Monthly cost summaries are also manual values.
  • +
  • The income sheet labels costs “including VAT”; the cost sheet labels its totals “excluding VAT”. Figures are preserved without converting VAT.
  • +
  • Column G mixes schedule totals and stated project values. It is shown separately from scheduled turnover.
  • +
  • The workbook’s cumulative balance resets in December. The overview instead shows cumulative operating results starting from zero, not a bank balance.
  • +
  • The cost sheet’s lower month headings conflict with its main headings. This board follows the March–February main headings.
  • +
+
+
; +} diff --git a/src/app/dashboard/management/management.css b/src/app/dashboard/management/management.css new file mode 100644 index 0000000..247ef9d --- /dev/null +++ b/src/app/dashboard/management/management.css @@ -0,0 +1,125 @@ +.management-board { --mgmt-ink: #202b3c; --mgmt-gold: #edb14e; } +.mgmt-topbar { display:flex; align-items:center; justify-content:space-between; gap:1rem; padding:1.5rem 2rem; border-bottom:1px solid var(--border); background:var(--background); } +.mgmt-eyebrow { display:flex; gap:.65rem; align-items:center; font-size:.67rem; font-weight:600; letter-spacing:.1em; text-transform:uppercase; color:var(--muted-foreground); } +.mgmt-eyebrow span { opacity:.45; } +.mgmt-topbar h1 { font-size:1.6rem; font-weight:650; letter-spacing:-.045em; margin-top:.35rem; line-height:1.2; } +.mgmt-admin { display:flex; align-items:center; gap:.35rem; color:var(--muted-foreground); font-size:.7rem; } +.mgmt-main { max-width:1600px; margin:auto; padding:1.5rem 2rem 2rem; } +.mgmt-intro { display:flex; justify-content:space-between; align-items:center; gap:1rem; color:var(--muted-foreground); font-size:.82rem; margin-bottom:1.2rem; } +.mgmt-planning { display:inline-flex; align-items:center; gap:.45rem; white-space:nowrap; font-size:.65rem; font-weight:600; letter-spacing:.04em; border:1px solid var(--border); border-radius:20px; padding:.35rem .65rem; color:var(--foreground); } +.mgmt-planning > span { width:5px; height:5px; border-radius:50%; background:#d69a32; } +.mgmt-tabs { display:flex; gap:1.5rem; border-bottom:1px solid var(--border); margin-bottom:1.5rem; overflow:auto; } +.mgmt-tabs a { white-space:nowrap; padding:0 0 .85rem; font-size:.77rem; color:var(--muted-foreground); border-bottom:2px solid transparent; } +.mgmt-tabs a:hover { color:var(--foreground); } +.mgmt-tabs a.is-active { border-color:#d99b32; color:var(--foreground); font-weight:650; } +.mgmt-hero { color:#e7edf7; background:var(--mgmt-ink); border-radius:16px; overflow:hidden; margin-bottom:1.5rem; } +.mgmt-hero-heading { display:flex; justify-content:space-between; gap:1rem; padding:1.4rem 1.6rem 0; } +.mgmt-hero-heading p { color:#b4bed0; font-size:.66rem; letter-spacing:.11em; text-transform:uppercase; } +.mgmt-hero-heading span { font-size:.65rem; color:#b4bed0; } +.mgmt-hero-stats { display:grid; grid-template-columns:1.35fr 1fr 1fr 1fr; padding:1.6rem; gap:1.6rem; } +.mgmt-hero .mgmt-stat { border-left:1px solid #465164; padding-left:1.5rem; } +.mgmt-hero .mgmt-stat:first-child { border:0; padding-left:0; } +.mgmt-stat > p { font-size:.7rem; color:var(--muted-foreground); margin-bottom:.55rem; } +.mgmt-stat-value { font-size:1.9rem; line-height:1.15; font-weight:600; letter-spacing:-.055em; font-variant-numeric:tabular-nums; } +.mgmt-stat > span { display:block; font-size:.67rem; color:var(--muted-foreground); margin-top:.6rem; line-height:1.5; } +.mgmt-hero .mgmt-stat p,.mgmt-hero .mgmt-stat > span { color:#b4bed0; } +.mgmt-hero .mgmt-stat:first-child .mgmt-stat-value { color:var(--mgmt-gold); font-size:2.5rem; } +.is-positive { color:#228978; } +.is-negative { color:#cb5b65; } +.mgmt-hero .is-positive { color:#71d0b8; } +.mgmt-hero .is-negative { color:#ff9fa6; } +.mgmt-stat-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:1rem; margin-bottom:1.5rem; } +.mgmt-stat-grid .mgmt-stat { border:1px solid var(--border); background:var(--card); border-radius:12px; padding:1.2rem; } +.mgmt-stat.is-compact .mgmt-stat-value { font-size:1.65rem; } +.mgmt-panel { min-width:0; padding:1.4rem; border:1px solid var(--border); background:var(--card); border-radius:12px; } +.mgmt-panel-heading { display:flex; justify-content:space-between; align-items:flex-start; gap:1rem; margin-bottom:1.2rem; } +.mgmt-panel-heading h2 { font-weight:650; font-size:.93rem; letter-spacing:-.02em; } +.mgmt-panel-heading p { color:var(--muted-foreground); font-size:.72rem; margin-top:.35rem; line-height:1.55; } +.mgmt-chart-grid { display:grid; grid-template-columns:minmax(0,1.6fr) minmax(0,1fr); gap:1.3rem; margin-bottom:1.3rem; } +.mgmt-chart { width:100%; height:280px; min-width:0; } +.mgmt-paired-chart .mgmt-chart { height:calc(280px + var(--mgmt-tooltip-space)); } +.mgmt-chart-small { height:232px; } +.mgmt-chart-toolbar { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.7rem; margin-bottom:.7rem; } +.mgmt-chart-mode { display:inline-flex; flex-wrap:wrap; gap:2px; padding:3px; border:1px solid var(--border); background:var(--background); border-radius:7px; } +.mgmt-chart-mode button { padding:.4rem .6rem; border-radius:4px; font-size:.65rem; color:var(--muted-foreground); white-space:nowrap; cursor:pointer; } +.mgmt-chart-mode button[aria-pressed="true"] { background:var(--card); color:var(--foreground); box-shadow:0 1px 3px #0001; font-weight:600; } +.mgmt-chart-mode button:hover { color:var(--foreground); } +.mgmt-chart-mode button:focus-visible { outline:2px solid var(--primary); outline-offset:2px; } +.mgmt-legend { display:flex; flex-wrap:wrap; gap:.9rem; font-size:.65rem; color:var(--muted-foreground); align-items:center; } +.mgmt-legend > span,.mgmt-tooltip span { display:flex; align-items:center; gap:.4rem; } +.mgmt-legend i,.mgmt-tooltip i { display:inline-block; width:7px; height:7px; border-radius:2px; } +.mgmt-tooltip { background:var(--card); border:1px solid var(--border); box-shadow:0 5px 25px #0002; border-radius:8px; padding:.8rem 1rem; font-size:.72rem; } +.mgmt-tooltip > strong { display:block; margin-bottom:.5rem; } +.mgmt-tooltip > div { display:flex; gap:1.5rem; justify-content:space-between; margin-top:.35rem; } +.mgmt-tooltip b { font-weight:600; font-variant-numeric:tabular-nums; } +.mgmt-paired-chart .mgmt-tooltip span { white-space:nowrap; } +.mgmt-result-caption { display:flex; justify-content:space-between; gap:1rem; font-size:.68rem; color:var(--muted-foreground); margin-top:.8rem; border-top:1px solid var(--border); padding-top:.8rem; } +.mgmt-result-caption b { font-size:.8rem; } +.mgmt-filters { display:flex; flex-wrap:wrap; align-items:end; gap:.85rem; margin-bottom:1.2rem; } +.mgmt-filter { display:flex; flex-direction:column; gap:.35rem; font-size:.65rem; color:var(--muted-foreground); } +.mgmt-filter select,.mgmt-search input { background:var(--card); color:var(--foreground); border:1px solid var(--border); border-radius:6px; font-size:.75rem; padding:.55rem .8rem; min-width:145px; } +.mgmt-filter select:focus-visible,.mgmt-search input:focus-visible { outline:2px solid var(--primary); outline-offset:2px; } +.mgmt-search { flex:1; min-width:190px; } +.mgmt-search input { width:100%; } +.mgmt-table-scroll { overflow:auto; max-width:100%; } +.mgmt-table { width:100%; border-collapse:collapse; white-space:nowrap; font-size:.72rem; font-variant-numeric:tabular-nums; } +.mgmt-table th { font-size:.61rem; font-weight:600; color:var(--muted-foreground); letter-spacing:.035em; text-align:right; padding:.7rem .8rem; border-bottom:1px solid var(--border); } +.mgmt-table td { padding:.85rem .8rem; text-align:right; border-bottom:1px solid var(--border); } +.mgmt-table th:first-child,.mgmt-table td:first-child { text-align:left; padding-left:0; } +.mgmt-table tr:last-child td { border-bottom:0; } +.mgmt-table tbody tr:hover { background:color-mix(in srgb,var(--muted) 40%,transparent); } +.mgmt-table tfoot td { font-weight:650; border-top:1px solid var(--border); } +.mgmt-table .mgmt-month { display:flex; gap:.6rem; align-items:center; font-weight:550; } +.mgmt-month i { width:5px; height:5px; border-radius:50%; background:#45a891; } +.mgmt-month i.is-negative { background:#ce6370; } +.mgmt-difference { color:#b67b24; font-weight:550; } +.mgmt-reconciliation { border:1px solid var(--border); background:var(--card); border-radius:10px; margin:1.3rem 0; overflow:hidden; } +.mgmt-reconciliation summary { padding:1rem 1.25rem; font-size:.7rem; cursor:pointer; display:flex; justify-content:space-between; gap:1rem; list-style:none; } +.mgmt-reconciliation summary::-webkit-details-marker { display:none; } +.mgmt-reconciliation summary > span:first-child { display:flex; gap:.7rem; align-items:center; color:var(--muted-foreground); } +.mgmt-reconciliation summary strong { color:var(--foreground); font-weight:600; } +.mgmt-review-label { color:#b67b24; white-space:nowrap; } +.mgmt-reconciliation[open] summary { border-bottom:1px solid var(--border); } +.mgmt-reconciliation-content { padding:1.2rem; font-size:.75rem; color:var(--muted-foreground); } +.mgmt-audit-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; padding:1.25rem 0; } +.mgmt-audit-grid span { display:block; font-size:.65rem; margin-bottom:.4rem; } +.mgmt-audit-grid strong { color:var(--foreground); font-size:.85rem; } +.mgmt-source-notes { list-style:disc; padding-left:1rem; margin-top:1rem; font-size:.7rem; line-height:1.75; } +.mgmt-source-note { color:var(--muted-foreground); font-size:.7rem; line-height:1.65; padding-top:1rem; } +.mgmt-footer { display:flex; flex-wrap:wrap; justify-content:space-between; gap:.5rem; color:var(--muted-foreground); font-size:.63rem; border-top:1px solid var(--border); margin-top:2rem; padding-top:1rem; } +.mgmt-change { display:inline-flex; align-items:center; gap:.25rem; font-size:.72rem; font-variant-numeric:tabular-nums; } +.mgmt-project-table { min-width:1100px; } +.mgmt-project-table .mgmt-sticky { position:sticky; left:0; z-index:1; background:var(--card); padding-left:.5rem; min-width:225px; border-right:1px solid var(--border); } +.mgmt-project-table th.mgmt-sticky { z-index:2; } +.mgmt-project-name { text-align:left; font-weight:600; font-size:.71rem; cursor:pointer; } +.mgmt-project-name:hover { text-decoration:underline; text-underline-offset:3px; } +.mgmt-project-meta { display:block; font-size:.6rem; font-weight:400; color:var(--muted-foreground); margin-top:.3rem; } +.mgmt-heat-cell { display:block; border-radius:4px; padding:.55rem .45rem; min-width:60px; color:var(--foreground); } +.mgmt-project-table td { padding:.45rem .25rem; } +.mgmt-project-table td.mgmt-sticky { padding:.8rem .5rem; } +.mgmt-project-table th { padding:.7rem .4rem; } +.mgmt-project-total { font-weight:650; padding-right:.6rem!important; } +.mgmt-project-summary { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:1rem; margin-bottom:1.5rem; } +.mgmt-project-summary .mgmt-stat { border-left:1px solid var(--border); padding-left:1.1rem; } +.mgmt-project-summary .mgmt-stat:first-child { border:0; padding-left:0; } +.mgmt-empty { text-align:center; padding:3rem 1rem; font-size:.8rem; color:var(--muted-foreground); } +.mgmt-schedule { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:1rem; margin-bottom:1.5rem; } +.mgmt-schedule-month { padding:1rem; border:1px solid var(--border); border-radius:8px; background:var(--card); } +.mgmt-schedule-month > div { display:flex; justify-content:space-between; align-items:center; gap:.5rem; font-size:.7rem; } +.mgmt-schedule-month > strong { display:block; font-size:1.2rem; font-weight:600; letter-spacing:-.04em; margin:.6rem 0; } +.mgmt-schedule-month > p { font-size:.64rem; color:var(--muted-foreground); line-height:1.7; } +.mgmt-schedule-month.is-empty { background:transparent; } +.mgmt-schedule-month.is-empty > strong { color:var(--muted-foreground); } +.mgmt-retention-layout { display:grid; grid-template-columns:minmax(0,1.35fr) minmax(0,1fr); gap:1.3rem; margin-bottom:1.5rem; } +.mgmt-retention-bridge { display:grid; grid-template-columns:1fr 1fr; gap:.8rem; } +.mgmt-retention-bridge > div { border-top:1px solid var(--border); padding-top:1rem; } +.mgmt-retention-bridge span { display:block; font-size:.68rem; color:var(--muted-foreground); } +.mgmt-retention-bridge strong { display:block; font-weight:600; font-size:1.2rem; margin-top:.5rem; font-variant-numeric:tabular-nums; } +.mgmt-comparison-grid { display:grid; grid-template-columns:1fr 1fr; gap:1.3rem; margin-bottom:1.3rem; } +.mgmt-debtor { white-space:normal; min-width:220px; max-width:340px; text-align:left!important; color:var(--muted-foreground); font-size:.68rem; } +.mgmt-reset { font-size:.72rem; color:var(--muted-foreground); text-decoration:underline; text-underline-offset:3px; padding:.6rem .3rem; } +@media (min-width:1600px) { .mgmt-stat-value { font-size:2.2rem; } .mgmt-hero .mgmt-stat:first-child .mgmt-stat-value { font-size:3rem; } } +@media (max-width:700px) { .mgmt-paired-chart .mgmt-chart { height:calc(245px + var(--mgmt-tooltip-space)); } } +@media (max-width:1100px) { .mgmt-chart-grid,.mgmt-retention-layout { grid-template-columns:1fr; } .mgmt-hero-stats { grid-template-columns:1fr 1fr; } .mgmt-hero .mgmt-stat:nth-child(3) { border:0; padding-left:0; } .mgmt-stat-grid { grid-template-columns:1fr 1fr; } } +@media (max-width:700px) { .mgmt-topbar { padding:1.2rem 1rem 1.2rem 3.6rem; } .mgmt-topbar h1 { font-size:1.2rem; } .mgmt-main { padding:1.1rem 1rem; } .mgmt-eyebrow { font-size:.57rem; } .mgmt-admin { display:none; } .mgmt-intro { flex-direction:column; align-items:flex-start; font-size:.75rem; } .mgmt-tabs { gap:1.1rem; } .mgmt-tabs a { font-size:.7rem; } .mgmt-hero-heading { flex-direction:column; gap:.5rem; padding:1.2rem 1.2rem 0; } .mgmt-hero-stats { padding:1.2rem; gap:1.2rem; } .mgmt-hero .mgmt-stat { padding-left:1rem; } .mgmt-stat-value,.mgmt-stat.is-compact .mgmt-stat-value { font-size:1.45rem; } .mgmt-hero .mgmt-stat:first-child .mgmt-stat-value { font-size:1.85rem; } .mgmt-stat > p { font-size:.63rem; } .mgmt-stat-grid { gap:.65rem; } .mgmt-stat-grid .mgmt-stat { padding:1rem; } .mgmt-panel { padding:1rem; } .mgmt-panel-heading { flex-direction:column; gap:.7rem; } .mgmt-chart { height:245px; } .mgmt-audit-grid { grid-template-columns:1fr 1fr; } .mgmt-reconciliation summary { padding:1rem; } .mgmt-schedule { grid-template-columns:1fr 1fr; gap:.7rem; } .mgmt-comparison-grid { grid-template-columns:1fr; } .mgmt-project-summary { grid-template-columns:1fr; gap:.9rem; } .mgmt-project-summary .mgmt-stat { border-left:0; border-top:1px solid var(--border); padding-left:0; padding-top:.8rem; } .mgmt-project-summary .mgmt-stat:first-child { padding-top:0; } } +@media (prefers-reduced-motion:reduce) { .management-board * { animation:none!important; transition:none!important; } } diff --git a/src/app/dashboard/management/page.tsx b/src/app/dashboard/management/page.tsx new file mode 100644 index 0000000..e236efb --- /dev/null +++ b/src/app/dashboard/management/page.tsx @@ -0,0 +1,7 @@ +import { redirect } from "next/navigation"; +import { requirePageUser } from "@/lib/auth/guard"; + +export default async function ManagementPage() { + await requirePageUser("management"); + redirect("/dashboard/management/turnover"); +} diff --git a/src/app/dashboard/management/projects-view.tsx b/src/app/dashboard/management/projects-view.tsx new file mode 100644 index 0000000..3a8718c --- /dev/null +++ b/src/app/dashboard/management/projects-view.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { useState } from "react"; +import { Search } from "lucide-react"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import type { ManagementBoard, PlanningProject } from "@/lib/management/model"; +import { projectSummary } from "@/lib/management/model"; +import { IncomeCostChart } from "./management-charts"; +import { compactMoney, Filter, ManagementShell, money, Panel, percent, Reconciliation, Stat } from "./management-ui"; + +export function ProjectsView({ board }: { board: ManagementBoard }) { + const [query, setQuery] = useState(""); + const [manager, setManager] = useState("all"); + const [client, setClient] = useState("all"); + const [month, setMonth] = useState("all"); + const [selected, setSelected] = useState(null); + const managers = [...new Set(board.projects.map((p) => p.manager).filter((v): v is string => v !== null))].sort(); + const clients = [...new Set(board.projects.map((p) => p.client).filter((v): v is string => v !== null))].sort(); + const projects = board.projects.filter((p) => (manager === "all" || (p.manager ?? "unassigned") === manager) && (client === "all" || (p.client ?? "unassigned") === client) && `${p.name} ${p.engineer ?? ""}`.toLowerCase().includes(query.toLowerCase())); + const months = month === "all" ? board.months : board.months.filter((m) => String(m.index) === month); + const income = projects.reduce((total, p) => total + months.reduce((sub, m) => sub + (p.income[m.index] ?? 0), 0), 0); + const knownCost = projects.reduce((total, p) => total + months.reduce((sub, m) => sub + (p.costs?.[m.index] ?? 0), 0), 0); + const missingCosts = projects.filter((p) => p.costs === null).length; + const maxCell = Math.max(1, ...projects.flatMap((p) => months.map((m) => p.income[m.index] ?? 0))); + const summary = selected ? projectSummary(selected) : null; + return +
+ + {managers.map((v) => )} + {clients.map((v) => )} + {board.months.map((m) => )} + {(query || manager !== "all" || client !== "all" || month !== "all") && } +
+
+ {month === "all" ? "March–February" : months[0].name}}> +
{months.map((m) => )}{projects.map((p) => { + const planned = months.reduce((total, m) => total + (p.income[m.index] ?? 0), 0); + const cost = p.costs === null ? null : months.reduce((total, m) => total + (p.costs?.[m.index] ?? 0), 0); + return {months.map((m) => { + const value = p.income[m.index]; + return ; + })}; + })}{months.map((m) => )}
Project{m.label}Scheduled totalKnown cost
{value === null ? "—" : value === 0 ? "0.00" : (value / 1_000_000).toFixed(2)}{compactMoney(planned)}{cost === null ? "Unavailable" : compactMoney(cost)}
Selected project entries{(projects.reduce((total, p) => total + (p.income[m.index] ?? 0), 0) / 1_000_000).toFixed(2)}{compactMoney(income)}{compactMoney(knownCost)}
+ {projects.length === 0 &&

No projects match these filters.

} +

Blank cells are preserved as blanks. Scheduled totals follow Excel’s SUM treatment. Known costs include only matching project schedules.

+
+ + { if (!open) setSelected(null); }}> + {selected && summary && <>{selected.name}{selected.client ?? "Client not supplied"} · {selected.manager ?? "Manager not supplied"} · {selected.engineer ?? "Engineer not supplied"} +
+ ({ label: m.label, income: selected.income[m.index], cost: selected.costs?.[m.index] ?? null }))} /> +
{board.months.map((m) => )}
MonthIncomeCost
{m.name}{selected.income[m.index] === null ? "Blank in workbook" : money(selected.income[m.index])}{selected.costs === null ? "Unavailable" : selected.costs[m.index] === null ? "Blank in workbook" : money(selected.costs[m.index])}
+

Workbook value (column G): {selected.statedValue === null ? "Not supplied" : money(selected.statedValue)}. Column G mixes project values and schedule totals. {selected.retentionEligible ? "Included in the workbook’s 10% retention calculation." : "Outside the workbook’s retention calculation range."} Project results exclude head-office costs and retention payouts.

+ } +
+
; +} diff --git a/src/app/dashboard/management/projects/page.tsx b/src/app/dashboard/management/projects/page.tsx new file mode 100644 index 0000000..1f4432c --- /dev/null +++ b/src/app/dashboard/management/projects/page.tsx @@ -0,0 +1,6 @@ +import { getManagementBoard } from "@/lib/management/data"; +import { ProjectsView } from "../projects-view"; + +export default async function ProjectsPage() { + return ; +} diff --git a/src/app/dashboard/management/retention-view.tsx b/src/app/dashboard/management/retention-view.tsx new file mode 100644 index 0000000..b3ed34f --- /dev/null +++ b/src/app/dashboard/management/retention-view.tsx @@ -0,0 +1,24 @@ +"use client"; + +import { useState } from "react"; +import type { ManagementBoard } from "@/lib/management/model"; +import { chartColors, RetentionChart } from "./management-charts"; +import { compactMoney, dateLabel, Filter, Legend, ManagementShell, money, Panel, Stat } from "./management-ui"; + +export function RetentionView({ board }: { board: ManagementBoard }) { + const [month, setMonth] = useState("all"); + const [query, setQuery] = useState(""); + const entries = board.retentionSchedule.flatMap((m) => m.entries.map((entry) => ({ ...entry, date: m.date }))).filter((e) => (month === "all" || e.date === month) && `${e.project} ${e.debtor ?? ""}`.toLowerCase().includes(query.toLowerCase())); + const total = entries.reduce((sum, e) => sum + e.amount, 0); + return +
+
}> +
Opening balance{money(board.totals.retentionOpening)}
New retention{money(board.totals.retentionAdded)}
Less planned payouts{money(board.totals.payouts)}
Closing balance{money(board.totals.retentionClosing)}

The release calendar below comes from the separate “Retention 2026” sheet. Its dates and amounts are preserved separately; it is not deducted again from this balance.

+
+
{board.months.map((m) => )}
MonthOpeningAdditionsPayoutsClosing
{m.name}{money(m.retentionOpening)}{money(m.retentionAdded)}{money(m.payout)}{money(m.retentionClosing)}
+

Scheduled releases

November 2025–October 2026 · Separate payout schedule

{compactMoney(board.totals.scheduledPayouts)}

19 scheduled project entries

+
{board.retentionSchedule.map((m) =>
{dateLabel(m.date)}{m.entries.length ? `${m.entries.length} ${m.entries.length === 1 ? "project" : "projects"}` : "—"}
{m.entries.length === 0 ? "—" : compactMoney(m.total)}

{m.entries.length === 0 ? "No release listed" : m.entries.map((e) => e.project).join(" · ")}

)}
+
{board.retentionSchedule.map((m) => )}
+
{entries.map((e) => )}
ProjectDebtor / source noteRelease monthPlanned payout
{e.project}{e.debtor ?? "Not supplied"}{dateLabel(e.date)}{money(e.amount)}
Total in view{money(total)}
{entries.length === 0 &&

No releases match these filters.

}
+
; +} diff --git a/src/app/dashboard/management/retention/page.tsx b/src/app/dashboard/management/retention/page.tsx new file mode 100644 index 0000000..1d9bfaf --- /dev/null +++ b/src/app/dashboard/management/retention/page.tsx @@ -0,0 +1,6 @@ +import { getManagementBoard } from "@/lib/management/data"; +import { RetentionView } from "../retention-view"; + +export default async function RetentionPage() { + return ; +} diff --git a/src/app/dashboard/management/turnover-view.tsx b/src/app/dashboard/management/turnover-view.tsx new file mode 100644 index 0000000..d5a9d9b --- /dev/null +++ b/src/app/dashboard/management/turnover-view.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { ArrowUpRight } from "lucide-react"; +import type { ManagementBoard } from "@/lib/management/model"; +import { projectSummary } from "@/lib/management/model"; +import { IncomeCostChart, ResultChart } from "./management-charts"; +import { compactMoney, Filter, ManagementShell, money, Panel, percent, Reconciliation, Stat } from "./management-ui"; + +export function TurnoverView({ board }: { board: ManagementBoard }) { + const [period, setPeriod] = useState("all"); + const months = period === "all" ? board.months : board.months.filter((m) => String(m.index) === period); + const income = months.reduce((total, m) => total + m.income, 0); + const cost = months.reduce((total, m) => total + m.cost, 0); + const result = months.reduce((total, m) => total + m.result, 0); + const payouts = months.reduce((total, m) => total + m.payout, 0); + const leaders = board.projects.map((p) => ({ ...p, total: projectSummary(p).income })).sort((a, b) => b.total - a.total).slice(0, 5); + return +
{board.months.map((m) => )}{board.projects.length} projects · Reported workbook summaries
+

{period === "all" ? board.periodLabel : `${months[0].name} · 2025 workbook`}

All values in ZAR
+ + + = 0 ? "positive" : "negative"} /> + +
+
+ +
End of planning period= 0 ? "is-positive" : "is-negative"}>{compactMoney(board.totals.result)}
+
+
+
{months.map((m) => )}
MonthIncomeCostsPayoutsIncome − costOperating resultMargin
{m.name}{compactMoney(m.income)}{compactMoney(m.cost)}{m.payout === 0 ? "—" : money(m.payout)}{money(m.income - m.cost)}{money(m.result)}{percent(m.margin)}
{period === "all" ? "Full period" : months[0].name}{money(income)}{money(cost)}{money(payouts)}{money(income - cost)}{money(result)}{percent(income === 0 ? null : result / income)}
+ }> +
{leaders.map((p, i) =>
0{i + 1}{p.name}{compactMoney(p.total)}
)}
+

Project entries total {compactMoney(board.totals.projectIncome)}. The overview’s reported turnover is {compactMoney(board.totals.income)}. See the reconciliation below.

+ +
+ + ; +} diff --git a/src/app/dashboard/management/turnover/page.tsx b/src/app/dashboard/management/turnover/page.tsx new file mode 100644 index 0000000..a7a4795 --- /dev/null +++ b/src/app/dashboard/management/turnover/page.tsx @@ -0,0 +1,6 @@ +import { getManagementBoard } from "@/lib/management/data"; +import { TurnoverView } from "../turnover-view"; + +export default async function TurnoverPage() { + return ; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 120d35c..608edba 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -5,8 +5,7 @@ import { AuthProvider } from "@/hooks/use-auth"; import { ThemeProvider } from "@/hooks/use-theme"; import { PwaRegister } from "@/components/pwa-register"; -// AUTH_MODE is a deployment switch; read it for each request so rollout -// and rollback do not depend on the value used during next build. +// Every deployment uses named PocketBase sessions. export const dynamic = "force-dynamic"; export const metadata: Metadata = { @@ -97,7 +96,7 @@ export default function RootLayout({ - + {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1d1915c..0c70c10 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,172 +2,12 @@ import * as React from "react"; import { useRouter } from "next/navigation"; -import { Check, Copy, Delete, KeyRound, ShieldCheck } from "lucide-react"; +import { ShieldCheck } from "lucide-react"; import { Logo } from "@/components/logo"; import { Button } from "@/components/ui/button"; -import { useAuth } from "@/hooks/use-auth"; import { resolveRedirectTarget } from "@/lib/safe-redirect"; -import { cn } from "@/lib/utils"; - -const KEYS = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "blank", "0", "del"] as const; export default function LoginPage() { - const { mode } = useAuth(); - return mode === "users" ? : ; -} - -function PinLoginPage() { - const router = useRouter(); - const { expectedPin, unlock } = useAuth(); - const [pin, setPin] = React.useState(""); - const [error, setError] = React.useState(null); - const [shake, setShake] = React.useState(false); - - const press = React.useCallback( - (key: string) => { - setError(null); - if (key === "del") { - setPin((p) => p.slice(0, -1)); - return; - } - setPin((p) => { - if (p.length >= 6) return p; - const next = p + key; - if (next.length >= 4) { - // Auto-submit when we hit the length of the expected pin - if (next.length === expectedPin.length) { - setTimeout(() => { - if (next === expectedPin) { - unlock(); - router.replace(resolveRedirectTarget(window.location.search)); - } else { - setError("Incorrect PIN"); - setShake(true); - setTimeout(() => setShake(false), 400); - setPin(""); - } - }, 80); - } - } - return next; - }); - }, - [expectedPin, router, unlock] - ); - - // Keyboard support - React.useEffect(() => { - const onKey = (e: KeyboardEvent) => { - if (e.key >= "0" && e.key <= "9") press(e.key); - else if (e.key === "Backspace") press("del"); - else if (e.key === "Enter") { - if (pin === expectedPin) { - unlock(); - router.replace(resolveRedirectTarget(window.location.search)); - } else if (pin.length > 0) { - setError("Incorrect PIN"); - setShake(true); - setTimeout(() => setShake(false), 400); - setPin(""); - } - } - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [press, expectedPin, pin, router, unlock]); - - return ( -
-
-
-
- -
-
- -
- -
-
- - Site Manager Access -
-

Enter your PIN

-

- Use the numeric keypad. Default PIN is set in .env.local as NEXT_PUBLIC_APP_PIN. -

- -
- {Array.from({ length: Math.max(expectedPin.length, 4) }).map((_, i) => ( - - ))} -
- {error && ( -

{error}

- )} - -
- {KEYS.map((k) => { - if (k === "blank") return
; - if (k === "del") { - return ( - - ); - } - return ( - - ); - })} -
- -

- Connected: 5 sites · 9 vehicles · 24 employees -

- - -
- -

- © {new Date().getFullYear()} MEGA INFRASTRUCTURE · Internal use only -

-
-
- ); -} - -function UserLoginPage() { const router = useRouter(); const [email, setEmail] = React.useState(""); const [password, setPassword] = React.useState(""); @@ -223,39 +63,3 @@ function UserLoginPage() { ); } - -function DevPinHint({ pin }: { pin: string }) { - const [copied, setCopied] = React.useState(false); - const handleCopy = React.useCallback(async () => { - try { - await navigator.clipboard.writeText(pin); - setCopied(true); - setTimeout(() => setCopied(false), 1600); - } catch { - // ignore — clipboard API may be blocked in some sandboxes - } - }, [pin]); - - return ( -
-
- - Dev PIN - - {pin} - -
- -
- ); -} diff --git a/src/components/layout/sidebar-access.test.tsx b/src/components/layout/sidebar-access.test.tsx index 36a3d08..c31eb32 100644 --- a/src/components/layout/sidebar-access.test.tsx +++ b/src/components/layout/sidebar-access.test.tsx @@ -12,6 +12,16 @@ import { Sidebar } from "./sidebar"; const user: AppUser = { id: "office", email: "office@example.test", name: "Julius", role: "office_user", modules: ["workforce"], allSites: true, siteIds: [], mustChangePassword: false }; +it("shows the Management group and all four pages to an admin directly after Overview", () => { + const html = renderToStaticMarkup(); + for (const path of ["/dashboard/management", "/dashboard/management/turnover", "/dashboard/management/projects", "/dashboard/management/retention", "/dashboard/management/comparison"]) expect(html).toContain(`href="${path}"`); + expect(html.indexOf('href="/dashboard/management"')).toBeLessThan(html.indexOf('href="/dashboard/sites"')); +}); + +it.each(["site_user", "office_user", "clock_only"] as const)("hides the whole Management group from %s", (role) => { + expect(renderToStaticMarkup()).not.toContain('/dashboard/management'); +}); + it("shows Julius only Workforce and its tools, with the logo pointing to Workforce", () => { const html = renderToStaticMarkup(); for (const href of ["/dashboard/workforce", "/dashboard/workforce/workers", "/dashboard/workforce/timesheets", "/dashboard/workforce/on-site"]) expect(html).toContain(`href="${href}"`); diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index 32d7cde..f430ba7 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -5,6 +5,9 @@ import Link from "next/link"; import { usePathname, useSearchParams } from "next/navigation"; import { Building2, + BriefcaseBusiness, + CalendarRange, + ChartNoAxesCombined, Coins, Contact, Fuel, @@ -65,6 +68,18 @@ type NavEntry = NavItem | NavGroup; // wiring up the next module. const NAV: NavEntry[] = [ { href: "/dashboard", label: "Overview", icon: LayoutDashboard, description: "Site-at-a-glance KPIs" }, + { + kind: "group", + href: "/dashboard/management", + label: "Management", + icon: BriefcaseBusiness, + children: [ + { href: "/dashboard/management/turnover", label: "Turnover Overview", icon: ChartNoAxesCombined }, + { href: "/dashboard/management/projects", label: "Project Planning", icon: Building2 }, + { href: "/dashboard/management/retention", label: "Retention", icon: CalendarRange }, + { href: "/dashboard/management/comparison", label: "Year Comparison", icon: Coins }, + ], + }, { href: "/dashboard/sites", label: "Sites", icon: Building2, description: "Contracts, burn, profitability" }, { kind: "group", diff --git a/src/lib/agent/context.test.ts b/src/lib/agent/context.test.ts index 7c95268..0199397 100644 --- a/src/lib/agent/context.test.ts +++ b/src/lib/agent/context.test.ts @@ -1,64 +1,43 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppUser } from "@/lib/auth/access"; -const getFullListMock = vi.fn(); - -vi.mock("@/lib/pb", () => ({ - pbAdmin: vi.fn(async () => ({ - collection: (name: string) => { - if (name === "users") return { getFullList: getFullListMock }; - throw new Error(`unexpected collection in test: ${name}`); - }, - })), +let user: AppUser | null; +vi.mock("@/lib/auth/session", async (original) => ({ + ...await original(), + getCurrentUser: async () => user, })); +import { resolveActor } from "./context"; -import { resolveActor, _resetActorCacheForTest } from "./context"; +const req = (id?: string) => new Request("http://x/api/chat", { headers: id ? { "x-ask-user": id } : {} }); -function req(headers: Record = {}): Request { - return new Request("http://x/api/chat", { headers }); -} +beforeEach(() => { + user = { id: "u1", email: "admin@example.test", name: "Dashboard", role: "admin", siteIds: [], mustChangePassword: false }; +}); -describe("resolveActor", () => { - beforeEach(() => { - getFullListMock.mockReset(); - getFullListMock.mockResolvedValue([ - { id: "u1", name: "Dashboard" }, - { id: "u2", name: " Site Manager " }, - { id: "u3", name: "" }, - ]); - _resetActorCacheForTest(); +describe("named Ask actor", () => { + it("resolves the signed-in admin without an identity header", async () => { + expect(await resolveActor(req())).toEqual({ id: "u1", name: "Dashboard" }); }); - - it("accepts a real id and returns the actor", async () => { - const result = await resolveActor(req({ "x-ask-user": "u1" })); - expect(result).toEqual({ id: "u1", name: "Dashboard" }); + it("ignores a header attempting to impersonate another account", async () => { + expect(await resolveActor(req("another-admin"))).toEqual({ id: "u1", name: "Dashboard" }); }); - - it("trims the stored name", async () => { - const result = await resolveActor(req({ "x-ask-user": "u2" })); - expect(result).toEqual({ id: "u2", name: "Site Manager" }); + it("refuses an anonymous actor even with an identity header and legacy PIN config", async () => { + vi.stubEnv("AUTH_MODE", "pin"); + user = null; + try { expect((await resolveActor(req("u1")) as Response).status).toBe(403); } + finally { vi.unstubAllEnvs(); } }); - - it("rejects a missing header", async () => { - const result = await resolveActor(req()); - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(400); - expect(await (result as Response).json()).toEqual({ error: "Pick who is using Ask" }); + it.each(["site_user", "clock_only", "office_user"] as const)("refuses %s actors", async (role) => { + user = { ...user!, role }; + expect((await resolveActor(req()) as Response).status).toBe(403); }); - - it("rejects an unknown id", async () => { - const result = await resolveActor(req({ "x-ask-user": "ghost" })); - expect(result).toBeInstanceOf(Response); - expect((result as Response).status).toBe(400); + it("refuses an admin who must change their password", async () => { + user = { ...user!, mustChangePassword: true }; + expect((await resolveActor(req()) as Response).status).toBe(403); }); - - it("rejects an id whose user has no name", async () => { - const result = await resolveActor(req({ "x-ask-user": "u3" })); - expect(result).toBeInstanceOf(Response); - }); - - it("caches the user list across calls within the TTL", async () => { - await resolveActor(req({ "x-ask-user": "u1" })); - await resolveActor(req({ "x-ask-user": "u1" })); - expect(getFullListMock).toHaveBeenCalledTimes(1); + it("reads the current actor again when the session changes", async () => { + expect(await resolveActor(req())).toEqual({ id: "u1", name: "Dashboard" }); + user = { ...user!, id: "u2", name: "Second admin" }; + expect(await resolveActor(req())).toEqual({ id: "u2", name: "Second admin" }); }); }); diff --git a/src/lib/auth/access.test.ts b/src/lib/auth/access.test.ts index 4db84ef..aece275 100644 --- a/src/lib/auth/access.test.ts +++ b/src/lib/auth/access.test.ts @@ -12,6 +12,14 @@ const user = (overrides: Partial = {}): AppUser => ({ }); describe("login access", () => { + it.each(["site_user", "office_user", "clock_only"] as const)("keeps Management private from %s, even with all-sites access", (role) => { + expect(canAccessArea(user({ role, allSites: true, modules: ["overview", "sites", "workforce", "plant", "financial"] }), "management")).toBe(false); + }); + + it("grants Management only to a named administrator", () => { + expect(canAccessArea(user({ role: "admin" }), "management")).toBe(true); + expect(canAccessArea(user({ id: "pin", role: "admin", email: "" }), "management")).toBe(false); + }); it("allows admins to see all sites and both app areas", () => { const admin = user({ role: "admin", siteIds: [] }); expect(canAccessSite(admin, "site2")).toBe(true); diff --git a/src/lib/auth/access.ts b/src/lib/auth/access.ts index 4747c82..373db50 100644 --- a/src/lib/auth/access.ts +++ b/src/lib/auth/access.ts @@ -2,7 +2,7 @@ export type Role = "admin" | "site_user" | "clock_only" | "office_user"; export const DASHBOARD_MODULES = ["overview", "sites", "workforce", "plant", "financial"] as const; export type DashboardModule = typeof DASHBOARD_MODULES[number]; -export type AccessArea = "dashboard" | "clock" | DashboardModule; +export type AccessArea = "dashboard" | "clock" | "management" | DashboardModule; export const MODULE_LABELS: Record = { overview: "Overview", sites: "Sites", workforce: "Workforce", plant: "Plant", financial: "Financial", }; @@ -54,6 +54,8 @@ export function canAccessModule(user: AppUser, module: DashboardModule): boolean } export function canAccessArea(user: AppUser, area: AccessArea): boolean { + // Management is company leadership, not the operational Site manager role. + if (area === "management") return user.role === "admin" && user.id !== "pin" && Boolean(user.email); if (area === "clock") return user.role === "admin" || user.role === "site_user" || user.role === "clock_only"; if (area === "dashboard") return user.role !== "clock_only" && (user.role !== "office_user" || (user.modules?.length ?? 0) > 0); return canAccessModule(user, area); diff --git a/src/lib/auth/path-access.test.ts b/src/lib/auth/path-access.test.ts index 904bba1..d9d7a0e 100644 --- a/src/lib/auth/path-access.test.ts +++ b/src/lib/auth/path-access.test.ts @@ -4,6 +4,12 @@ import type { AppUser } from "./access"; const user: AppUser = { id: "office", name: "Office", email: "office@example.test", role: "office_user", siteIds: [], modules: ["financial"], allSites: true, mustChangePassword: false }; +it.each(["/dashboard/management", "/dashboard/management/turnover", "/dashboard/management/projects", "/dashboard/management/retention", "/dashboard/management/comparison", "/dashboard/management/future-page", "/api/management/turnover"])("restricts %s to named admins", (path) => { + for (const role of ["site_user", "office_user", "clock_only"] as const) expect(canAccessPath({ ...user, role }, path)).toBe(false); + expect(canAccessPath({ ...user, role: "admin" }, path)).toBe(true); + expect(canAccessPath({ ...user, role: "admin", id: "pin", email: "" }, path)).toBe(false); +}); + it.each([ ["/dashboard/financial", true], ["/dashboard/financial/purchase-orders/order", true], ["/dashboard/financial/new-feature", true], ["/api/purchase-orders/upload", true], diff --git a/src/lib/auth/path-access.ts b/src/lib/auth/path-access.ts index 910f52e..14b84de 100644 --- a/src/lib/auth/path-access.ts +++ b/src/lib/auth/path-access.ts @@ -22,6 +22,7 @@ export function moduleForPath(path: string): DashboardModule | null { } export function canAccessPath(user: AppUser, pathname: string): boolean { + if (within(pathname, "/dashboard/management") || within(pathname, "/api/management")) return canAccessArea(user, "management"); const category = classifyAuthPath(pathname); if (category === "public" || category === "machine") return true; if (category === "admin") return user.role === "admin"; diff --git a/src/lib/auth/proxy-path.ts b/src/lib/auth/proxy-path.ts index 7151b34..5fcf331 100644 --- a/src/lib/auth/proxy-path.ts +++ b/src/lib/auth/proxy-path.ts @@ -18,6 +18,8 @@ export function classifyAuthPath(pathname: string): AuthPath { ) return "machine"; if (pathname === "/dashboard/settings/users" || pathname === "/api/admin/users") return "manager"; if ( + pathname === "/dashboard/management" || pathname.startsWith("/dashboard/management/") || + pathname === "/api/management" || pathname.startsWith("/api/management/") || pathname.startsWith("/dashboard/settings") || pathname.startsWith("/dashboard/chat") || pathname.startsWith("/dashboard/change-requests") || diff --git a/src/lib/auth/session.test.ts b/src/lib/auth/session.test.ts index ce346a8..576ce4e 100644 --- a/src/lib/auth/session.test.ts +++ b/src/lib/auth/session.test.ts @@ -54,9 +54,11 @@ describe("PocketBase session", () => { expect(userFromRecord({ id: "u", email: "a@b.test", role: "unexpected", is_active: true })).toBeNull(); }); - it("keeps existing server behavior in PIN rollout mode", async () => { + it("requires a named session even with legacy PIN configuration", async () => { vi.stubEnv("AUTH_MODE", "pin"); cookieValue = undefined; - expect(await getCurrentUser()).toMatchObject({ role: "admin", mustChangePassword: false }); + expect(await getCurrentUser()).toBeNull(); + vi.stubEnv("AUTH_MODE", ""); + expect(await getCurrentUser()).toBeNull(); }); }); diff --git a/src/lib/auth/session.ts b/src/lib/auth/session.ts index a592a38..3800d8f 100644 --- a/src/lib/auth/session.ts +++ b/src/lib/auth/session.ts @@ -6,21 +6,12 @@ export { userFromRecord } from "./access"; export const SESSION_COOKIE = "mega_session"; -const PIN_USER: AppUser = { - id: "pin", - email: "", - name: "Site PIN", - role: "admin", - siteIds: [], - mustChangePassword: false, -}; - +/** Named sign-in is mandatory. Legacy PIN configuration cannot bypass a session. */ export function authMode(): "pin" | "users" { - return process.env.AUTH_MODE === "users" ? "users" : "pin"; + return "users"; } export async function getCurrentUser(): Promise { - if (authMode() === "pin") return PIN_USER; const token = (await cookies()).get(SESSION_COOKIE)?.value; if (!token) return null; const pb = new PocketBase(process.env.NEXT_PUBLIC_POCKETBASE_URL ?? "http://127.0.0.1:8090"); diff --git a/src/lib/dn-pipeline/fencing.test.ts b/src/lib/dn-pipeline/fencing.test.ts index 1aa6aa2..2ca0b77 100644 --- a/src/lib/dn-pipeline/fencing.test.ts +++ b/src/lib/dn-pipeline/fencing.test.ts @@ -1,3 +1,4 @@ +import "@/test-utils/named-admin-session"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fields, transactionalPb, type TestMutation } from "./test-batch"; import { withBatchLease } from "./lock"; diff --git a/src/lib/management/data.ts b/src/lib/management/data.ts new file mode 100644 index 0000000..6000477 --- /dev/null +++ b/src/lib/management/data.ts @@ -0,0 +1,12 @@ +import "server-only"; +import { requirePageUser } from "@/lib/auth/guard"; +import { buildManagementBoard } from "./model"; + +/** Replace this source with a database query when planning inputs are modelled. + * Every page calls this provider: authorization precedes loading any figures. + */ +export async function getManagementBoard() { + await requirePageUser("management"); + const { default: snapshot } = await import("./workbook-snapshot.json"); + return buildManagementBoard(snapshot); +} diff --git a/src/lib/management/model.test.ts b/src/lib/management/model.test.ts new file mode 100644 index 0000000..a90d938 --- /dev/null +++ b/src/lib/management/model.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import snapshot from "./workbook-snapshot.json"; +import { buildManagementBoard, projectSummary } from "./model"; + +describe("turnover planning calculations", () => { + it("preserves reported totals and shows the independently calculated project difference", () => { + const board = buildManagementBoard(snapshot); + expect(board.totals.income).toBeCloseTo(126009767.73, 2); + expect(board.totals.cost).toBeCloseTo(124031044.68, 2); + expect(board.totals.result).toBeCloseTo(5583744.05, 2); + expect(board.totals.projectIncome).toBeCloseTo(139153569.73, 2); + expect(board.totals.incomeDifference).toBeCloseTo(-13143802, 2); + expect(board.months[8].incomeDifference).toBeCloseTo(1856228, 2); + expect(board.months[9].incomeDifference).toBeCloseTo(-4000035, 2); + expect(board.months[10].incomeDifference).toBeCloseTo(-10999995, 2); + }); + + it("uses selected projects for retention, subtracts payouts, and keeps the separate schedule separate", () => { + const board = buildManagementBoard(snapshot); + expect(board.months[0].retentionAdded).toBeCloseTo(553868.909, 3); + expect(board.months[0].retentionClosing).toBeCloseTo(8255318.909, 3); + expect(board.months[1].retentionClosing).toBeCloseTo(8131982.744, 3); + expect(board.totals.retentionClosing).toBeCloseTo(15953413.918, 3); + expect(board.totals.payouts).toBeCloseTo(3605021, 2); + expect(board.totals.scheduledPayouts).toBeCloseTo(9058160.09, 2); + }); + + it("computes the aggregate margin from aggregate income rather than averaging monthly percentages", () => { + const board = buildManagementBoard(snapshot); + expect(board.totals.margin).toBeCloseTo(0.0443119938, 8); + expect(board.months[1].result).toBeCloseTo(-1501433.65, 2); + }); + + it("does not invent a cost or profit for projects without a cost schedule", () => { + const project = snapshot.projects.find((p) => p.id === "the-hills")!; + expect(projectSummary(project)).toMatchObject({ income: 9204180, cost: null, result: null, margin: null }); + const zeros = { ...project, income: Array(12).fill(0), costs: Array(12).fill(0) }; + expect(projectSummary(zeros)).toMatchObject({ income: 0, cost: 0, result: 0, margin: null }); + }); + + it("updates the rollforward and results when future source inputs change", () => { + const changed = structuredClone(snapshot); + changed.reportedIncome[0] += 1000; + changed.projects[0].income[0]! += 1000; + changed.retentionPayouts[1]! += 500; + const board = buildManagementBoard(changed); + expect(board.totals.income).toBeCloseTo(126010767.73, 2); + expect(board.totals.result).toBeCloseTo(5585244.05, 2); + expect(board.totals.retentionClosing).toBeCloseTo(15953013.918, 3); + expect(board.months[0].incomeDifference).toBeCloseTo(0, 2); + }); + + it("does not double-count the comparison sheet's December payout subtotal", () => { + const board = buildManagementBoard(snapshot); + expect(board.comparisons[0].payouts).toBeCloseTo(2838936.48, 2); + expect(board.comparisons[1].payouts).toBeCloseTo(3876109.05, 2); + expect(board.comparisons[1].income).toBeCloseTo(123591774.74118325, 2); + expect(board.comparisons[1].cashResult).toBeCloseTo(6785713.223983258, 2); + }); +}); diff --git a/src/lib/management/model.ts b/src/lib/management/model.ts new file mode 100644 index 0000000..8c71d77 --- /dev/null +++ b/src/lib/management/model.ts @@ -0,0 +1,123 @@ +export interface PlanningProject { + id: string; + name: string; + manager: string | null; + client: string | null; + engineer: string | null; + statedValue: number | null; + income: (number | null)[]; + costs: (number | null)[] | null; + retentionEligible: boolean; +} + +export interface RetentionMonth { + date: string; + entries: { id: string; project: string; debtor: string | null; amount: number }[]; +} + +export interface WorkbookSnapshot { + sourceFile: string; + periodLabel: string; + months: string[]; + projects: PlanningProject[]; + reportedIncome: number[]; + reportedCosts: number[]; + reportedCostAnnual: number; + reportedRetention: number[]; + reportedCumulativeBalance: (number | null)[]; + openingRetention: number; + retentionPayouts: (number | null)[]; + headOfficeCosts: number[]; + retentionSchedule: RetentionMonth[]; + comparisons: { + year: number; + income: (number | null)[]; + costs: (number | null)[]; + retentionAdded: (number | null)[]; + retentionPayouts: (number | null)[]; + reportedCashResult: number; + reportedResultWithRetention: number; + retentionClosing: number; + }[]; + balanceSnapshots: { + label: string; + liabilities: { name: string; amount: number | null }[]; + retention: number; + wip: number; + reportedNet: number; + }[]; +} + +const sum = (values: (number | null)[]) => values.reduce((total, value) => total + (value ?? 0), 0); + +export function projectSummary(project: PlanningProject) { + const income = sum(project.income); + // No matching cost row means unavailable, not a free-to-deliver project. + const cost = project.costs === null ? null : sum(project.costs); + const result = cost === null ? null : income - cost; + return { income, cost, result, margin: result === null || income === 0 ? null : result / income }; +} + +/** Workbook summaries remain authoritative for this first planning snapshot. + * Detail sums are calculated independently and exposed as reconciliation data. + * Blanks inside SUM ranges follow Excel's treatment; missing schedules stay null. + */ +export function buildManagementBoard(snapshot: WorkbookSnapshot) { + let retentionClosing = snapshot.openingRetention; + let cumulativeResult = 0; + const months = snapshot.months.map((name, index) => { + const income = snapshot.reportedIncome[index]; + const cost = snapshot.reportedCosts[index]; + const payout = snapshot.retentionPayouts[index] ?? 0; + const projectIncome = snapshot.projects.reduce((total, project) => total + (project.income[index] ?? 0), 0); + const projectCost = snapshot.projects.reduce((total, project) => total + (project.costs?.[index] ?? 0), 0); + const retentionAdded = snapshot.projects.filter((project) => project.retentionEligible) + .reduce((total, project) => total + (project.income[index] ?? 0), 0) * 0.1; + const retentionOpening = retentionClosing; + retentionClosing += retentionAdded - payout; + const result = income + payout - cost; + cumulativeResult += result; + return { + index, name, label: name.slice(0, 3), income, cost, payout, result, + margin: income === 0 ? null : result / income, + projectIncome, projectCost, headOfficeCost: snapshot.headOfficeCosts[index], + incomeDifference: income - projectIncome, + costDifference: cost - projectCost - snapshot.headOfficeCosts[index], + retentionOpening, retentionAdded, retentionClosing, cumulativeResult, + }; + }); + const income = sum(snapshot.reportedIncome); + const cost = sum(snapshot.reportedCosts); + const payouts = sum(snapshot.retentionPayouts); + const projectIncome = months.reduce((total, month) => total + month.projectIncome, 0); + const result = income + payouts - cost; + const retentionSchedule = snapshot.retentionSchedule.map((month) => ({ ...month, total: month.entries.reduce((total, entry) => total + entry.amount, 0) })); + return { + sourceFile: snapshot.sourceFile, periodLabel: snapshot.periodLabel, + months, projects: snapshot.projects, + totals: { + income, cost, payouts, result, margin: income === 0 ? null : result / income, + projectIncome, incomeDifference: income - projectIncome, + projectCost: months.reduce((total, month) => total + month.projectCost, 0), + headOfficeCost: sum(snapshot.headOfficeCosts), + statedAnnualCost: snapshot.reportedCostAnnual, + retentionOpening: snapshot.openingRetention, + retentionAdded: months.reduce((total, month) => total + month.retentionAdded, 0), + retentionClosing, + scheduledPayouts: retentionSchedule.reduce((total, month) => total + month.total, 0), + }, + retentionSchedule, + comparisons: snapshot.comparisons.map((year) => ({ + year: year.year, income: sum(year.income), cost: sum(year.costs), + payouts: sum(year.retentionPayouts), + cashResult: year.reportedCashResult, + resultWithRetention: year.reportedResultWithRetention, + retentionClosing: year.retentionClosing, + months: year.income.map((amount, index) => ({ index, income: amount, cost: year.costs[index] })), + })), + balanceSnapshots: snapshot.balanceSnapshots, + }; +} + +export type ManagementBoard = ReturnType; +export type PlanningMonth = ManagementBoard["months"][number]; diff --git a/src/lib/management/workbook-snapshot.json b/src/lib/management/workbook-snapshot.json new file mode 100644 index 0000000..8afefb5 --- /dev/null +++ b/src/lib/management/workbook-snapshot.json @@ -0,0 +1,1174 @@ +{ + "sourceFile": "Turnover sheet.xlsx", + "periodLabel": "2025 workbook \u00b7 March\u2013February", + "months": [ + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + "January", + "February" + ], + "projects": [ + { + "id": "legacy-project", + "name": "LEGACY PROJECT", + "manager": "Abrie", + "client": "HB REALTY", + "engineer": "ELLIS", + "statedValue": 3174430.0, + "income": [ + 345000.0, + 0.0, + 602250.0, + 446741.0, + 1587044.0, + 0.0, + 193395.0, + null, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "costs": [ + 420269.0, + 307000.0, + 800000.0, + 143000.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "retentionEligible": true + }, + { + "id": "mooi-straat", + "name": "MOOI STRAAT", + "manager": "Abrie", + "client": "HB REALTY", + "engineer": "ENSYNC", + "statedValue": 917038.0, + "income": [ + 204769.0, + 0.0, + 284664.0, + 0.0, + 0.0, + 0.0, + 0.0, + 427605.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "costs": [ + 280408.0, + 0.0, + null, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "retentionEligible": true + }, + { + "id": "wiltons", + "name": "WILTONS", + "manager": null, + "client": null, + "engineer": null, + "statedValue": null, + "income": [ + 629512.0, + null, + 350734.0, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "costs": null, + "retentionEligible": true + }, + { + "id": "404-on-main", + "name": "404 ON MAIN", + "manager": "Abrie", + "client": "HB REALTY", + "engineer": "ENSYNC", + "statedValue": 2531533.0, + "income": [ + 149965.0, + 0.0, + 148465.0, + 756250.0, + 271972.0, + 432152.0, + 455230.0, + 317499.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "costs": [ + 231480.0, + 656000.0, + 304000.0, + 332000.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0 + ], + "retentionEligible": true + }, + { + "id": "castle-gate", + "name": "CASTLE GATE", + "manager": "Willie", + "client": "CDP", + "engineer": "CIVILCRAFT", + "statedValue": 16414209.0, + "income": [ + null, + 0.0, + 0.0, + 0.0, + 1134353.0, + 1966989.0, + 3400209.0, + 2493210.0, + 1854862.0, + 1854862.0, + 1854862.0, + 1854862.0 + ], + "costs": [ + null, + null, + 685940.0, + 1342114.0, + 2089775.0, + 3053402.0, + 2303553.0, + 1507476.0, + 1599459.0, + 1047216.0, + 1375536.0, + 390870.0 + ], + "retentionEligible": true + }, + { + "id": "willowpark-98-external", + "name": "WILLOWPARK 98 EXTERNAL", + "manager": "Willie", + "client": "CDP", + "engineer": "CED", + "statedValue": 4309300.84, + "income": [ + 1078196.15, + 937539.69, + 943067.0, + 1127288.0, + 223210.0, + 0.0, + null, + 0.0, + 0.0, + null, + null, + null + ], + "costs": [ + 987525.0, + 803953.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + null, + null, + null + ], + "retentionEligible": true + }, + { + "id": "willowpark-98-internal", + "name": "WILLOWPARK 98 INTERNAL", + "manager": "Willie", + "client": "CDP", + "engineer": "CED", + "statedValue": 8156019.0, + "income": [ + 0.0, + null, + 0.0, + 0.0, + 0.0, + 0.0, + 524802.0, + 1044813.0, + 1646601.0, + 1646601.0, + 1646601.0, + 1646601.0 + ], + "costs": [ + 0.0, + 0.0, + 0.0, + 0.0, + 934831.0, + 1834831.0, + 900000.0, + 850000.0, + 0.0, + 650000.0, + 650000.0, + 650000.0 + ], + "retentionEligible": true + }, + { + "id": "bishops-gate", + "name": "BISHOPS GATE", + "manager": null, + "client": null, + "engineer": null, + "statedValue": null, + "income": [ + null, + 275515.48, + null, + null, + null, + 0.0, + null, + null, + null, + null, + null, + null + ], + "costs": null, + "retentionEligible": true + }, + { + "id": "erand-gardens-external", + "name": "ERAND GARDENS EXTERNAL", + "manager": "Abrie", + "client": "CDP", + "engineer": "DHUBECON & CED", + "statedValue": 15161456.0, + "income": [ + null, + 0.0, + 1016614.0, + 2134289.0, + 2350743.0, + 2533291.0, + 2086189.0, + 1667238.0, + 1124364.0, + 1124364.0, + 1124364.0, + null + ], + "costs": [ + 346592.0, + 1695000.0, + 1935428.0, + 2014433.0, + 2300000.0, + 2027220.0, + 478066.0, + 0.0, + 0.0, + null, + null, + null + ], + "retentionEligible": true + }, + { + "id": "heuweloord-24", + "name": "HEUWELOORD 24", + "manager": "Abrie", + "client": "CDP", + "engineer": "CIVILCRAFT", + "statedValue": null, + "income": [ + 581015.16, + 623604.87, + 288016.0, + 50043.0, + null, + 0.0, + null, + null, + null, + null, + null, + null + ], + "costs": null, + "retentionEligible": true + }, + { + "id": "heuweloord-27", + "name": "HEUWELOORD 27", + "manager": "Abrie", + "client": "CDP", + "engineer": "CIVILCRAFT", + "statedValue": 6578600.61, + "income": [ + 1518035.88, + 894326.99, + 1125782.0, + 453159.0, + 645457.45, + 712061.29, + 1229778.0, + 0.0, + 0.0, + null, + null, + null + ], + "costs": [ + 1475044.0, + 430000.0, + 460000.0, + 500000.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + null, + null, + null + ], + "retentionEligible": true + }, + { + "id": "willow-park-ext-5", + "name": "WILLOW PARK EXT 5", + "manager": "Abrie", + "client": "CDP", + "engineer": "CIVILCRAFT", + "statedValue": null, + "income": [ + 572825.06, + null, + null, + null, + null, + 0.0, + null, + null, + null, + null, + null, + null + ], + "costs": null, + "retentionEligible": true + }, + { + "id": "suid-straat", + "name": "SUID STRAAT", + "manager": "Willie", + "client": "CDP", + "engineer": "CIVILCRAFT", + "statedValue": 2568485.16, + "income": [ + 459370.84, + 658741.32, + 0.0, + 476742.0, + 973631.0, + 0.0, + 0.0, + 0.0, + 0.0, + null, + null, + null + ], + "costs": [ + 0.0, + 370000.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + null, + null, + null + ], + "retentionEligible": true + }, + { + "id": "cosmo-city", + "name": "COSMO CITY", + "manager": "Abrie", + "client": "CDP", + "engineer": "CED", + "statedValue": 31877471.0, + "income": [ + null, + 0.0, + 1164375.0, + 1050181.0, + 2500207.0, + 4272126.0, + 3423813.0, + 4317897.0, + 3787218.0, + 3787218.0, + 3787218.0, + 3787218.0 + ], + "costs": [ + 317590.0, + 980000.0, + 3029347.0, + 3652846.0, + 4087516.0, + 4150210.0, + 4170739.0, + 4540584.0, + 3389958.0, + 3216395.0, + 1733852.0, + 2022338.0 + ], + "retentionEligible": true + }, + { + "id": "montana-191", + "name": "MONTANA 191", + "manager": "Abrie", + "client": "CDP", + "engineer": "CIVILCRAFT", + "statedValue": 28875861.0, + "income": [ + null, + null, + null, + null, + null, + 0.0, + null, + 725780.0, + 2211836.0, + 2211836.0, + 2211836.0, + 2211836.0 + ], + "costs": null, + "retentionEligible": true + }, + { + "id": "halfway-gardens", + "name": "HALFWAY GARDENS", + "manager": "Abrie", + "client": "CDP", + "engineer": "CED", + "statedValue": 9686947.0, + "income": [ + null, + 0.0, + null, + 657608.0, + 1438232.0, + 1628412.0, + 1401014.0, + 1046554.0, + 1171709.0, + 1171709.0, + 1171709.0, + 0.0 + ], + "costs": [ + null, + null, + 1021348.0, + 2460514.0, + 2030886.0, + 1250000.0, + 952910.0, + 0.0, + 0.0, + 306321.0, + 295000.0, + 306321.0 + ], + "retentionEligible": true + }, + { + "id": "tweefontein-steelpoort", + "name": "TWEEFONTEIN STEELPOORT", + "manager": "Willie", + "client": "ENFIN", + "engineer": "DHUBECON", + "statedValue": 4249970.0, + "income": [ + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + null, + 2124985.0, + 2124985.0 + ], + "costs": [ + 0.0, + 0.0, + null, + 0.0, + 0.0, + 1003119.0, + 1990996.0, + 2033935.0, + 1065018.0, + null, + 0.0, + 0.0 + ], + "retentionEligible": true + }, + { + "id": "equestria-215", + "name": "EQUESTRIA 215", + "manager": "Willie", + "client": null, + "engineer": "STRENG", + "statedValue": 7736870.0, + "income": [ + null, + null, + null, + 0.0, + 0.0, + 0.0, + 1234117.0, + 1538147.0, + 1132833.0, + 1132833.0, + 1132833.0, + 1132833.0 + ], + "costs": [ + null, + null, + null, + 0.0, + 1800000.0, + 1800000.0, + 1800000.0, + 1600000.0, + null, + null, + null, + null + ], + "retentionEligible": false + }, + { + "id": "equestria-ext-283", + "name": "EQUESTRIA EXT 283", + "manager": null, + "client": null, + "engineer": "Bastion", + "statedValue": null, + "income": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + 1393370.0, + 1393370.0 + ], + "costs": null, + "retentionEligible": false + }, + { + "id": "willow-park-ex-99", + "name": "WILLOW PARK EX 99", + "manager": null, + "client": null, + "engineer": null, + "statedValue": null, + "income": [ + null, + null, + null, + null, + null, + null, + 217024.0, + 735971.0, + null, + null, + null, + null + ], + "costs": null, + "retentionEligible": false + }, + { + "id": "club-view", + "name": "CLUB VIEW", + "manager": "Willie", + "client": "CDP", + "engineer": "CIVILCRAFT", + "statedValue": null, + "income": [ + 336209.55, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null, + null + ], + "costs": null, + "retentionEligible": false + }, + { + "id": "the-hills", + "name": "The Hills", + "manager": "Willie", + "client": null, + "engineer": "CIVIL CONCEPTS", + "statedValue": null, + "income": [ + null, + null, + null, + null, + null, + null, + null, + null, + null, + 3068060.0, + 3068060.0, + 3068060.0 + ], + "costs": null, + "retentionEligible": false + } + ], + "reportedIncome": [ + 5874898.64, + 3389728.35, + 5923967.0, + 7152301.0, + 11124849.45, + 11545031.29, + 14165571.0, + 14314714.0, + 14785651.0, + 11997448.0, + 8515843.0, + 17219765.0 + ], + "reportedCosts": [ + 6716647.0, + 5353471.0, + 7659681.0, + 6611472.0, + 10533988.0, + 10660575.0, + 13371795.0, + 13355831.0, + 13698511.94, + 11577601.22, + 8304892.42, + 16186579.1 + ], + "reportedCostAnnual": 98907034.0, + "reportedRetention": [ + 553868.909, + 338972.835, + 592396.7000000001, + 715230.1000000001, + 1112484.945, + 1154503.129, + 1271443.0, + 1204059.6, + 1179659.0, + 1179659.0, + 1392157.5, + 1162550.2 + ], + "reportedCumulativeBalance": [ + -2361080.79, + -3862514.44, + -3760312.44, + -2997376.44, + -2009298.9900000007, + -1124842.7000000016, + -331256.0, + 830572.0, + 1998350.0600000005, + 419846.77999999933, + 630797.3599999994, + 2065872.2599999998 + ], + "openingRetention": 7701450.0, + "retentionPayouts": [ + null, + 462309.0, + 1837916.0, + 222107.0, + 397216.0, + null, + null, + 202945.0, + 80639.0, + 0.0, + null, + 401889.0 + ], + "headOfficeCosts": [ + 900000.0, + 2000000.0, + 1000000.0, + 1000000.0, + 1000000.0, + 1000000.0, + 2000000.0, + 1000000.0, + 1000000.0, + 1000000.0, + 1000000.0, + 1000000.0 + ], + "retentionSchedule": [ + { + "date": "2025-11-01", + "entries": [] + }, + { + "date": "2025-12-01", + "entries": [] + }, + { + "date": "2026-01-01", + "entries": [ + { + "id": "retention-5", + "project": "Halfway Gardens x 101 Erf 1403 and 1406", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 107750.0 + } + ] + }, + { + "date": "2026-02-01", + "entries": [ + { + "id": "retention-7", + "project": "Mostyn x 22", + "debtor": "Debtor 05KWIK001 - KwikProp Holdings (Pty) Ltd Retention", + "amount": 22848.68 + }, + { + "id": "retention-8", + "project": "Erand Gardens Ext 125 152", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 1070500.0 + }, + { + "id": "retention-9", + "project": "Die Hoewes x 330", + "debtor": "Debtor 05ARR001 - Arrow Academy Retention", + "amount": 294389.15 + } + ] + }, + { + "date": "2026-03-01", + "entries": [ + { + "id": "retention-11", + "project": "Willowpark Ext 5", + "debtor": "Debtor 05COS001 - Cosmopolitan Property Developments (Pty) Ltd Retention", + "amount": 154623.38 + }, + { + "id": "retention-12", + "project": "Country View External Works", + "debtor": "Move income from Country View - Sagewood to Country Ext", + "amount": 427967.59 + }, + { + "id": "retention-13", + "project": "Willowpark Manor x 98(Internal)", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 181650.0 + } + ] + }, + { + "date": "2026-04-01", + "entries": [ + { + "id": "retention-15", + "project": "Clubview Ext 122", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 292356.13 + } + ] + }, + { + "date": "2026-05-01", + "entries": [ + { + "id": "retention-17", + "project": "Heuweloord Ext 24", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 90675.55 + }, + { + "id": "retention-18", + "project": "Suid Straat Die Hoewes", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 403031.0 + }, + { + "id": "retention-19", + "project": "Erasmuspark Ext 9 (Knights Court)", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 628169.0 + } + ] + }, + { + "date": "2026-06-01", + "entries": [ + { + "id": "retention-21", + "project": "Willowpark Manor x 98", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 194095.98 + }, + { + "id": "retention-22", + "project": "Cosmo City x 61 and x 63", + "debtor": "Debtor 05COS002 - Cosmopolitan Projects Johannesburg (Pty) Ltd Retention", + "amount": 1746000.0 + } + ] + }, + { + "date": "2026-07-01", + "entries": [ + { + "id": "retention-24", + "project": "Sagewood - Country View", + "debtor": "Debtor 05CEN002 - Century Property Construction Retention", + "amount": 351714.0 + }, + { + "id": "retention-25", + "project": "Kyalami Gardens", + "debtor": "Debtor 05CEN002 - Century Property Construction Retention", + "amount": 463574.79 + }, + { + "id": "retention-26", + "project": "Mushroom Farm Phase 4", + "debtor": "Debtor 05CEN002 - Century Property Construction Retention", + "amount": 277167.26 + }, + { + "id": "retention-27", + "project": "29 Wilton Ave, Bryanston", + "debtor": "Debtor 05ANT001 - Omaromax Pty Ltd Retention", + "amount": 295893.82 + } + ] + }, + { + "date": "2026-08-01", + "entries": [ + { + "id": "retention-29", + "project": "The Hills", + "debtor": "Debtor 05CEN001 - Century Property Development Retention", + "amount": 1430000.0 + } + ] + }, + { + "date": "2026-09-01", + "entries": [ + { + "id": "retention-31", + "project": "Heuweloord Ext 27", + "debtor": "Debtor 05ZOT001 - ZOTEC DEVELOPMENTS (PTY) LTD Retention", + "amount": 625753.76 + } + ] + }, + { + "date": "2026-10-01", + "entries": [] + } + ], + "comparisons": [ + { + "year": 2024, + "income": [ + 7637777.0, + 7981520.0, + 7988733.0, + 8336075.149999999, + 11093590.828000002, + 11633735.82, + 16032411.993, + 9354179.31, + 9472770.799999997, + 16302376.635999998, + 8349895.996624999, + 5499441.703369564 + ], + "costs": [ + 6973938.0455, + 6898135.3325, + 11819848.396000002, + 14283492.7865, + 15842362.0615, + 6280985.486500001, + 14504432.2355, + 12369966.559, + 11052442.632, + 15737948.8695, + 8901119.772499999, + 9663254.442499999 + ], + "retentionAdded": [ + 580808.0, + 1309379.18, + 825036.82, + 540132.2, + 663754.25, + 626099.08, + 1174102.86, + 636557.8, + 794870.75, + 1113801.64, + 492058.16, + 118281.01 + ], + "retentionPayouts": [ + null, + 334018.65, + null, + 311774.53, + null, + 396951.86, + 153884.92, + 185545.79, + 882861.2, + 395711.74, + 178187.79 + ], + "reportedCashResult": -11806481.902505413, + "reportedResultWithRetention": -5770536.632505413, + "retentionClosing": 6035945.27 + }, + { + "year": 2025, + "income": [ + 5188136.829500001, + 9147541.05818325, + 5874899.981999999, + 5732872.12, + 5923962.7885, + 6413227.353, + 10983576.45, + 11738427.0, + 14115624.159999998, + 15341542.0, + 18503049.0, + 14628916.0 + ], + "costs": [ + 3591462.1210000003, + 8610711.522, + 8130629.36, + 8048887.219, + 6369921.564499999, + 6896629.6755, + 9330027.658, + 11020394.0, + 12986374.2272, + 14114218.64, + 17392866.06, + 14190048.52 + ], + "retentionAdded": [ + 517155.51, + 269328.57, + 753975.28, + 398938.77, + 439085.1, + 484874.5, + 779612.94, + 1073708.41, + 1411562.416, + 1534154.2000000002, + 1850304.9000000001, + 1462891.6 + ], + "retentionPayouts": [ + null, + 525307.95, + 446979.51, + 397216.78, + 608782.77, + 489989.8, + 1407832.24, + null, + null, + null, + null + ], + "reportedCashResult": 6785713.223983258, + "reportedResultWithRetention": 13885196.36998326, + "retentionClosing": 7099483.146000001 + } + ], + "balanceSnapshots": [ + { + "label": "October 2024", + "liabilities": [ + { + "name": "Creditors", + "amount": 7785955.0 + }, + { + "name": "MVL", + "amount": 2317419.0 + }, + { + "name": "Bastion", + "amount": 1439224.0 + }, + { + "name": "AEB", + "amount": 7061951.0 + }, + { + "name": "Loan", + "amount": 7921754.0 + }, + { + "name": "MSI", + "amount": 1710500.0 + }, + { + "name": "ABSA", + "amount": null + } + ], + "retention": 7630596.0, + "wip": 2156000.0, + "reportedNet": -18450207.0 + }, + { + "label": "October 2025", + "liabilities": [ + { + "name": "Creditors", + "amount": 878420.0 + }, + { + "name": "MVL", + "amount": 2442371.0 + }, + { + "name": "Bastion", + "amount": 582902.0 + }, + { + "name": "AEB", + "amount": 7636630.0 + }, + { + "name": "Loan", + "amount": 8774830.0 + }, + { + "name": "MSI", + "amount": 2300000.0 + }, + { + "name": "ABSA", + "amount": 4960966.0 + } + ], + "retention": 8945796.0, + "wip": 2650000.0, + "reportedNet": -15980323.0 + } + ] +} diff --git a/src/proxy.test.ts b/src/proxy.test.ts index 047c687..99e6e6b 100644 --- a/src/proxy.test.ts +++ b/src/proxy.test.ts @@ -15,6 +15,17 @@ beforeEach(() => { afterEach(() => { vi.unstubAllGlobals(); vi.unstubAllEnvs(); }); describe("login proxy", () => { + it("keeps requests protected when AUTH_MODE is absent or still set to pin", async () => { + for (const mode of ["", "pin"]) { + vi.stubEnv("AUTH_MODE", mode); + expect((await proxy(request("/api/sites", ""))).status).toBe(401); + } + }); + + it("blocks site managers from every Management page and API", async () => { + expect((await proxy(request("/dashboard/management/turnover"))).headers.get("location")).toBe("http://app.test/dashboard"); + expect((await proxy(request("/api/management/turnover"))).status).toBe(403); + }); it("rejects an anonymous data request", async () => { expect((await proxy(request("/api/sites", ""))).status).toBe(401); }); diff --git a/src/proxy.ts b/src/proxy.ts index c4f8def..a6d9923 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -4,7 +4,6 @@ import { canAccessPath } from "./lib/auth/path-access"; import { classifyAuthPath } from "./lib/auth/proxy-path"; export async function proxy(request: NextRequest) { - if (process.env.AUTH_MODE !== "users") return NextResponse.next(); const pathname = request.nextUrl.pathname; const pathType = classifyAuthPath(pathname); if (pathType === "public" || pathType === "machine") return NextResponse.next(); diff --git a/src/test-utils/named-admin-session.ts b/src/test-utils/named-admin-session.ts new file mode 100644 index 0000000..892ee66 --- /dev/null +++ b/src/test-utils/named-admin-session.ts @@ -0,0 +1,15 @@ +/** Business-route fixtures use a named actor now that shared PIN access is + * disabled. Auth-specific suites keep their own session fixtures and denials. + */ +import { vi } from "vitest"; +import type { AppUser } from "@/lib/auth/access"; + +export const namedAdmin: AppUser = { + id: "u1", email: "admin@example.test", name: "Dashboard", role: "admin", + siteIds: [], mustChangePassword: false, +}; + +vi.mock("@/lib/auth/session", async (original) => ({ + ...await original(), + getCurrentUser: vi.fn(async (): Promise => namedAdmin), +}));