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.
+
+
+
+
+
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
+
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.
+
Month
{previous.year} income
{current.year} income
{current.year} cost
{data.map((m, i) =>
{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) =>
Liability
Amount
{s.liabilities.map((l) =>
{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
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.
+
+
Month
Opening
Additions
Payouts
Closing
{board.months.map((m) =>
{m.name}
{money(m.retentionOpening)}
{money(m.retentionAdded)}
{money(m.payout)}
{money(m.retentionClosing)}
)}
+
Scheduled releases
November 2025–October 2026 · Separate payout schedule
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.
-