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.
This commit is contained in:
parent
46452ce48f
commit
9a6aed50a4
68 changed files with 2143 additions and 294 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
106
design-previews/management-chart-gallery.html
Normal file
106
design-previews/management-chart-gallery.html
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const getFullListUsersMock = vi.fn();
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const employeesGetFirst = vi.fn();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const employeesGetFirst = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const vehiclesGetFirst = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const vehiclesGetFirst = vi.fn();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
/**
|
||||
* Tests for POST /api/dn-batches/[id]/dismiss.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
/**
|
||||
* Tests for POST /api/dn-batches/[id]/notes/[noteId]/dismiss.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const BATCH = "batch0000000001";
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pbAdmin = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const pbAdmin = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
/**
|
||||
* Tests for PATCH /api/dn-batches/[id]/notes/[noteId].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
/**
|
||||
* Tests for GET /api/dn-batches/[id]/pages/[n].
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
/**
|
||||
* Tests for POST /api/dn-batches/[id]/rerun.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const pbCreate = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
const pbUpdate = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
|
||||
const updateMock = vi.fn();
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
|
||||
const updateMock = vi.fn();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import "@/test-utils/named-admin-session";
|
||||
import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
|
||||
const createMock = vi.fn();
|
||||
|
|
|
|||
29
src/app/dashboard/management/comparison-view.tsx
Normal file
29
src/app/dashboard/management/comparison-view.tsx
Normal file
|
|
@ -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 <ManagementShell active="comparison" title="Year Comparison" description="Compare the saved 2024 and 2025 plans on the same January–December calendar." board={board}>
|
||||
<div className="mgmt-stat-grid"><Stat label={`${previous.year} scheduled income`} value={compactMoney(previous.income)} note="January–December project summary" /><Stat label={`${current.year} scheduled income`} value={compactMoney(current.income)} note="January–December project summary" /><Stat label="Income difference" value={compactMoney(current.income - previous.income)} note={previous.income === 0 ? "Growth unavailable" : `${((current.income / previous.income - 1) * 100).toFixed(1)}% from the earlier year`} /><Stat label={`${current.year} reported cashflow result`} value={compactMoney(current.cashResult)} tone={current.cashResult >= 0 ? "positive" : "negative"} note="Includes the sheet’s retention payouts" /></div>
|
||||
<Panel title="Monthly income comparison" subtitle="Values from the populated 2024–2025 sheet"><ComparisonChart data={data} years={[previous.year, current.year]} /></Panel>
|
||||
<div className="mgmt-comparison-grid mt-5"><Panel title="Full-year comparison" subtitle="Saved planning figures, including source estimates"><div className="mgmt-table-scroll"><table className="mgmt-table"><thead><tr><th>Measure</th><th>{previous.year}</th><th>{current.year}</th><th>Difference</th></tr></thead><tbody>{rows.map((r) => <tr key={r.label}><td>{r.label}</td><td>{compactMoney(r.previous)}</td><td>{compactMoney(r.current)}</td><td>{compactMoney(r.current - r.previous)}</td></tr>)}</tbody></table></div><p className="mgmt-source-note">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.</p></Panel>
|
||||
<Panel title="Monthly detail" subtitle="Income and costs by calendar month"><div className="mgmt-table-scroll"><table className="mgmt-table"><thead><tr><th>Month</th><th>{previous.year} income</th><th>{current.year} income</th><th>{current.year} cost</th></tr></thead><tbody>{data.map((m, i) => <tr key={m.label}><td>{m.label}</td><td>{money(m.previous)}</td><td>{money(m.current)}</td><td>{money(current.months[i].cost)}</td></tr>)}</tbody></table></div></Panel></div>
|
||||
<div className="mb-4 mt-8"><h2 className="text-lg font-semibold tracking-tight">Retention, work in progress & liabilities</h2><p className="mt-1 text-xs text-muted-foreground">The two October balance snapshots included in the workbook</p></div>
|
||||
<div className="mgmt-comparison-grid">{board.balanceSnapshots.map((s) => <Panel key={s.label} title={s.label} subtitle="Saved workbook balance snapshot"><div className="mgmt-table-scroll"><table className="mgmt-table"><thead><tr><th>Liability</th><th>Amount</th></tr></thead><tbody>{s.liabilities.map((l) => <tr key={l.name}><td>{l.name}</td><td>{money(l.amount)}</td></tr>)}</tbody></table></div><div className="mgmt-retention-bridge mt-4"><div><span>Retention</span><strong>{compactMoney(s.retention)}</strong></div><div><span>Work in progress</span><strong>{compactMoney(s.wip)}</strong></div><div><span>Net position</span><strong className={s.reportedNet < 0 ? "is-negative" : "is-positive"}>{compactMoney(s.reportedNet)}</strong></div></div></Panel>)}</div>
|
||||
<div className="mgmt-reconciliation-content rounded-lg border border-border bg-card"><p className="flex flex-wrap items-center gap-3"><strong className="text-foreground">Change in the reported net position</strong><Change value={board.balanceSnapshots[1].reportedNet - board.balanceSnapshots[0].reportedNet} /></p><p className="mgmt-source-note">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.</p></div>
|
||||
</ManagementShell>;
|
||||
}
|
||||
6
src/app/dashboard/management/comparison/page.tsx
Normal file
6
src/app/dashboard/management/comparison/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getManagementBoard } from "@/lib/management/data";
|
||||
import { ComparisonView } from "../comparison-view";
|
||||
|
||||
export default async function ComparisonPage() {
|
||||
return <ComparisonView board={await getManagementBoard()} />;
|
||||
}
|
||||
5
src/app/dashboard/management/layout.tsx
Normal file
5
src/app/dashboard/management/layout.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import "./management.css";
|
||||
|
||||
export default function ManagementLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="management-board">{children}</div>;
|
||||
}
|
||||
91
src/app/dashboard/management/management-charts.tsx
Normal file
91
src/app/dashboard/management/management-charts.tsx
Normal file
|
|
@ -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 <div className="mgmt-tooltip"><strong>{label}</strong>{payload.map((item) => <div key={item.name}><span><i style={{ background: item.color }} />{item.name}</span><b>{money(item.value ?? null)}</b></div>)}</div>;
|
||||
}
|
||||
|
||||
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<ChartMode>("columns");
|
||||
const gradientId = useId().replace(/:/g, "");
|
||||
const Chart = mode === "columns" ? BarChart : AreaChart;
|
||||
const tooltipSpace = series.length === 3 ? 116 : 92;
|
||||
return <div className="mgmt-paired-chart" style={{ "--mgmt-tooltip-space": `${tooltipSpace}px` } as CSSProperties}>
|
||||
<div className="mgmt-chart-toolbar">
|
||||
<Legend items={series} />
|
||||
<div className="mgmt-chart-mode" role="group" aria-label={`${title} chart style`}>
|
||||
<button type="button" aria-pressed={mode === "columns"} onClick={() => setMode("columns")}>{series.length === 2 ? "Paired columns" : "Grouped columns"}</button>
|
||||
<button type="button" aria-pressed={mode === "smooth"} onClick={() => setMode("smooth")}>Smooth comparison</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mgmt-chart" role="img" aria-label={`${title}: ${mode === "columns" ? "grouped monthly columns" : "smooth monthly comparison"}; exact amounts are in the table below`}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<Chart data={data} margin={{ top: tooltipSpace + 15, right: 8, bottom: 0, left: 0 }} barGap={4} accessibilityLayer>
|
||||
<defs>{series.map((s, i) => <linearGradient key={s.label} id={`${gradientId}-${i}`} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor={s.color} stopOpacity={i === emphasis ? .25 : .07} />
|
||||
<stop offset="100%" stopColor={s.color} stopOpacity={0} />
|
||||
</linearGradient>)}</defs>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="3 5" />
|
||||
<XAxis dataKey="label" {...axis} /><YAxis {...axis} tickFormatter={currencyTick} width={54} />
|
||||
<ReferenceLine y={0} stroke="var(--muted-foreground)" strokeDasharray="4 4" />
|
||||
<Tooltip shared filterNull={false} content={<FinancialTooltip />} 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"
|
||||
? <Bar key={s.label} dataKey={s.dataKey} name={s.label} fill={s.color} radius={[4, 4, 0, 0]} maxBarSize={26}
|
||||
activeBar={{ stroke: s.color, strokeWidth: 1 }} isAnimationActive={false} />
|
||||
: <Area key={s.label} dataKey={s.dataKey} name={s.label} type="monotone" stroke={s.color}
|
||||
strokeWidth={i === emphasis ? 2.75 : 2} fill={`url(#${gradientId}-${i})`} connectNulls={false} isAnimationActive={false}
|
||||
dot={data.length === 1 ? { r: 4, fill: s.color } : false} activeDot={{ r: 4, stroke: "var(--card)", strokeWidth: 3 }} />)}
|
||||
</Chart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
{description && <p className="mgmt-source-note">{description}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function IncomeCostChart({ months }: { months: { label: string; income: number | null; cost: number | null }[] }) {
|
||||
return <PairedMonthChart title="Income & cost" data={months.map((m) => ({ 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 <div className="mgmt-chart mgmt-chart-small" role="img" aria-label="Cumulative operating results from zero; not a cash or bank balance"><ResponsiveContainer width="100%" height="100%"><AreaChart data={months} margin={{ top: 15, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="3 5" /><XAxis dataKey="label" {...axis} /><YAxis {...axis} width={54} tickFormatter={currencyTick} />
|
||||
<ReferenceLine y={0} stroke="var(--muted-foreground)" strokeDasharray="4 4" /><Tooltip content={<FinancialTooltip />} cursor={{ stroke: "var(--muted-foreground)", strokeDasharray: "3 5" }} />
|
||||
<Area dataKey="cumulativeResult" name="Cumulative result" type="monotone" stroke={chartColors.result} fill={chartColors.result} fillOpacity={.13} strokeWidth={2.5} />
|
||||
</AreaChart></ResponsiveContainer></div>;
|
||||
}
|
||||
|
||||
export function RetentionChart({ months }: { months: PlanningMonth[] }) {
|
||||
return <div className="mgmt-chart" role="img" aria-label="Retention balance, additions and payouts across the planning period"><ResponsiveContainer width="100%" height="100%"><ComposedChart data={months} margin={{ top: 15, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" strokeDasharray="3 5" /><XAxis dataKey="label" {...axis} /><YAxis {...axis} width={54} tickFormatter={currencyTick} />
|
||||
<Tooltip content={<FinancialTooltip />} cursor={{ fill: "var(--muted)", opacity: .45 }} /><Bar dataKey="retentionAdded" name="New retention" fill={chartColors.income} radius={[3, 3, 0, 0]} maxBarSize={18} /><Bar dataKey="payout" name="Payouts" fill={chartColors.cost} radius={[3, 3, 0, 0]} maxBarSize={18} />
|
||||
<Line dataKey="retentionClosing" name="Closing balance" type="monotone" stroke={chartColors.retention} strokeWidth={2.5} dot={false} />
|
||||
</ComposedChart></ResponsiveContainer></div>;
|
||||
}
|
||||
|
||||
export function ComparisonChart({ data, years }: { data: { label: string; previous: number | null; current: number | null }[]; years: number[] }) {
|
||||
return <PairedMonthChart title="Monthly income comparison" data={data.map((m) => ({ 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} />;
|
||||
}
|
||||
77
src/app/dashboard/management/management-ui.tsx
Normal file
77
src/app/dashboard/management/management-ui.tsx
Normal file
|
|
@ -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 <>
|
||||
<header className="mgmt-topbar">
|
||||
<div><p className="mgmt-eyebrow">Management <span>/</span> Company portfolio</p><h1>{title}</h1></div>
|
||||
<div className="flex items-center gap-3"><span className="mgmt-admin"><ShieldCheck size={13} /> Admin access</span><ThemeToggle /></div>
|
||||
</header>
|
||||
<main className="mgmt-main">
|
||||
<div className="mgmt-intro"><p>{description}</p><span className="mgmt-planning"><span /> Planning snapshot</span></div>
|
||||
<nav className="mgmt-tabs" aria-label="Management pages">{pages.map(([slug, label]) => <Link key={slug} href={`/dashboard/management/${slug}`} aria-current={active === slug ? "page" : undefined} className={cn(active === slug && "is-active")}>{label}</Link>)}</nav>
|
||||
{children}
|
||||
<footer className="mgmt-footer"><span>Source: {board.sourceFile}</span><span>Saved planning values · {board.periodLabel}</span></footer>
|
||||
</main>
|
||||
</>;
|
||||
}
|
||||
|
||||
export function Panel({ title, subtitle, action, children, className }: { title: string; subtitle?: string; action?: React.ReactNode; children: React.ReactNode; className?: string }) {
|
||||
return <section className={cn("mgmt-panel", className)}><div className="mgmt-panel-heading"><div><h2>{title}</h2>{subtitle && <p>{subtitle}</p>}</div>{action}</div>{children}</section>;
|
||||
}
|
||||
|
||||
export function Stat({ label, value, note, tone, compact }: { label: string; value: string; note?: string; tone?: "positive" | "negative"; compact?: boolean }) {
|
||||
return <div className={cn("mgmt-stat", compact && "is-compact")}><p>{label}</p><div className={cn("mgmt-stat-value", tone && `is-${tone}`)}>{value}</div>{note && <span>{note}</span>}</div>;
|
||||
}
|
||||
|
||||
export function Change({ value, percentage = false }: { value: number; percentage?: boolean }) {
|
||||
const Icon = value < 0 ? ArrowDownRight : ArrowUpRight;
|
||||
return <span className={cn("mgmt-change", value < 0 ? "is-negative" : "is-positive")}><Icon size={14} />{percentage ? `${Math.abs(value * 100).toFixed(1)}%` : compactMoney(value)}</span>;
|
||||
}
|
||||
|
||||
export function Legend({ items }: { items: { label: string; color: string }[] }) {
|
||||
return <div className="mgmt-legend">{items.map((item) => <span key={item.label}><i style={{ background: item.color }} />{item.label}</span>)}</div>;
|
||||
}
|
||||
|
||||
export function Filter({ label, value, onChange, children }: { label: string; value: string; onChange: (value: string) => void; children: React.ReactNode }) {
|
||||
return <label className="mgmt-filter"><span>{label}</span><select value={value} onChange={(event) => onChange(event.target.value)}>{children}</select></label>;
|
||||
}
|
||||
|
||||
export function Reconciliation({ board }: { board: ManagementBoard }) {
|
||||
return <details className="mgmt-reconciliation"><summary><span><CircleHelp size={17} /><strong>Workbook reconciliation</strong><span className="hidden sm:inline">Reported summaries differ from project detail</span></span><span className="mgmt-review-label">View differences</span></summary>
|
||||
<div className="mgmt-reconciliation-content">
|
||||
<p>The overview follows the saved monthly summaries. Project Planning uses the project entries. These are different views of the supplied workbook.</p>
|
||||
<div className="mgmt-audit-grid">
|
||||
<div><span>Reported turnover</span><strong>{money(board.totals.income)}</strong></div><div><span>Project income entries</span><strong>{money(board.totals.projectIncome)}</strong></div><div><span>Reported minus detail</span><strong className="is-negative">{money(board.totals.incomeDifference)}</strong></div>
|
||||
<div><span>Reported monthly costs</span><strong>{money(board.totals.cost)}</strong></div><div><span>Cost sheet annual total</span><strong>{money(board.totals.statedAnnualCost)}</strong></div><div><span>Detail costs + head office</span><strong>{money(board.totals.projectCost + board.totals.headOfficeCost)}</strong></div>
|
||||
</div>
|
||||
<div className="mgmt-table-scroll"><table className="mgmt-table"><caption className="sr-only">Monthly reconciliation between reported summaries and project detail</caption><thead><tr><th>Month</th><th>Reported income</th><th>Project income</th><th>Difference</th><th>Reported cost</th><th>Detail + head office</th><th>Difference</th></tr></thead><tbody>{board.months.map((m) => <tr key={m.name}><td>{m.name}</td><td>{money(m.income)}</td><td>{money(m.projectIncome)}</td><td className={Math.abs(m.incomeDifference) > .01 ? "mgmt-difference" : ""}>{money(m.incomeDifference)}</td><td>{money(m.cost)}</td><td>{money(m.projectCost + m.headOfficeCost)}</td><td className="mgmt-difference">{money(m.costDifference)}</td></tr>)}</tbody></table></div>
|
||||
<ul className="mgmt-source-notes">
|
||||
<li>November, December and January income summaries are manual values. Monthly cost summaries are also manual values.</li>
|
||||
<li>The income sheet labels costs “including VAT”; the cost sheet labels its totals “excluding VAT”. Figures are preserved without converting VAT.</li>
|
||||
<li>Column G mixes schedule totals and stated project values. It is shown separately from scheduled turnover.</li>
|
||||
<li>The workbook’s cumulative balance resets in December. The overview instead shows cumulative operating results starting from zero, not a bank balance.</li>
|
||||
<li>The cost sheet’s lower month headings conflict with its main headings. This board follows the March–February main headings.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>;
|
||||
}
|
||||
125
src/app/dashboard/management/management.css
Normal file
125
src/app/dashboard/management/management.css
Normal file
|
|
@ -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; } }
|
||||
7
src/app/dashboard/management/page.tsx
Normal file
7
src/app/dashboard/management/page.tsx
Normal file
|
|
@ -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");
|
||||
}
|
||||
57
src/app/dashboard/management/projects-view.tsx
Normal file
57
src/app/dashboard/management/projects-view.tsx
Normal file
|
|
@ -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<PlanningProject | null>(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 <ManagementShell active="projects" title="Project Planning" description="Explore every project’s income schedule, ownership and available cost plan." board={board}>
|
||||
<div className="mgmt-filters">
|
||||
<label className="mgmt-filter mgmt-search"><span>Find a project or engineer</span><span className="relative"><Search size={14} className="absolute left-3 top-3 text-muted-foreground" /><input type="search" aria-label="Find a project or engineer" placeholder="Search the portfolio…" value={query} onChange={(e) => setQuery(e.target.value)} style={{ paddingLeft: "2rem" }} /></span></label>
|
||||
<Filter label="Assigned manager" value={manager} onChange={setManager}><option value="all">All managers</option>{managers.map((v) => <option key={v}>{v}</option>)}<option value="unassigned">Unassigned</option></Filter>
|
||||
<Filter label="Client" value={client} onChange={setClient}><option value="all">All clients</option>{clients.map((v) => <option key={v}>{v}</option>)}<option value="unassigned">Not supplied</option></Filter>
|
||||
<Filter label="Month" value={month} onChange={setMonth}><option value="all">Full period</option>{board.months.map((m) => <option key={m.name} value={m.index}>{m.name}</option>)}</Filter>
|
||||
{(query || manager !== "all" || client !== "all" || month !== "all") && <button type="button" className="mgmt-reset" onClick={() => { setQuery(""); setManager("all"); setClient("all"); setMonth("all"); }}>Reset filters</button>}
|
||||
</div>
|
||||
<div className="mgmt-stat-grid"><Stat label="Projects in view" value={String(projects.length).padStart(2, "0")} note={`${board.projects.length} projects in the workbook`} /><Stat label="Scheduled project income" value={compactMoney(income)} note="Sum of the selected project entries" /><Stat label="Known project costs" value={compactMoney(knownCost)} note="Excludes head office and missing schedules" /><Stat label="Missing cost schedules" value={String(missingCosts).padStart(2, "0")} note="Profit unavailable for these projects" /></div>
|
||||
<Panel title="Income schedule" subtitle="Select a project to inspect its full plan. Amounts in R millions; darker cells indicate higher planned income." action={<span className="text-xs text-muted-foreground">{month === "all" ? "March–February" : months[0].name}</span>}>
|
||||
<div className="mgmt-table-scroll"><table className="mgmt-table mgmt-project-table" style={month === "all" ? undefined : { minWidth: 550 }}><thead><tr><th className="mgmt-sticky">Project</th>{months.map((m) => <th key={m.name}>{m.label}</th>)}<th>Scheduled total</th><th>Known cost</th></tr></thead><tbody>{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 <tr key={p.id}><td className="mgmt-sticky"><button type="button" className="mgmt-project-name" onClick={() => setSelected(p)}>{p.name}<span className="mgmt-project-meta">{p.client ?? "Client not supplied"} · {p.manager ?? "Unassigned"}</span></button></td>{months.map((m) => {
|
||||
const value = p.income[m.index];
|
||||
return <td key={m.name}><span className="mgmt-heat-cell" title={`${p.name} · ${m.name}: ${value === null ? "Blank in workbook" : money(value)}`} style={{ background: value ? `rgba(218, 159, 54, ${.06 + Math.sqrt(Math.abs(value) / maxCell) * .4})` : undefined }}>{value === null ? "—" : value === 0 ? "0.00" : (value / 1_000_000).toFixed(2)}</span></td>;
|
||||
})}<td className="mgmt-project-total">{compactMoney(planned)}</td><td className="text-muted-foreground">{cost === null ? "Unavailable" : compactMoney(cost)}</td></tr>;
|
||||
})}</tbody><tfoot><tr><td className="mgmt-sticky">Selected project entries</td>{months.map((m) => <td key={m.name}>{(projects.reduce((total, p) => total + (p.income[m.index] ?? 0), 0) / 1_000_000).toFixed(2)}</td>)}<td>{compactMoney(income)}</td><td>{compactMoney(knownCost)}</td></tr></tfoot></table></div>
|
||||
{projects.length === 0 && <p className="mgmt-empty">No projects match these filters.</p>}
|
||||
<p className="mgmt-source-note">Blank cells are preserved as blanks. Scheduled totals follow Excel’s SUM treatment. Known costs include only matching project schedules.</p>
|
||||
</Panel>
|
||||
<Reconciliation board={board} />
|
||||
<Dialog open={selected !== null} onOpenChange={(open) => { if (!open) setSelected(null); }}><DialogContent className="max-h-[88dvh] overflow-y-auto sm:max-w-4xl">
|
||||
{selected && summary && <><DialogHeader><DialogTitle>{selected.name}</DialogTitle><DialogDescription>{selected.client ?? "Client not supplied"} · {selected.manager ?? "Manager not supplied"} · {selected.engineer ?? "Engineer not supplied"}</DialogDescription></DialogHeader>
|
||||
<div className="mgmt-project-summary"><Stat compact label="Scheduled income" value={compactMoney(summary.income)} /><Stat compact label="Scheduled cost" value={summary.cost === null ? "Unavailable" : compactMoney(summary.cost)} /><Stat compact label="Income less project cost" value={summary.result === null ? "Unavailable" : compactMoney(summary.result)} note={summary.margin === null ? "Margin unavailable" : `${percent(summary.margin)} project margin`} /></div>
|
||||
<IncomeCostChart months={board.months.map((m) => ({ label: m.label, income: selected.income[m.index], cost: selected.costs?.[m.index] ?? null }))} />
|
||||
<div className="mgmt-table-scroll"><table className="mgmt-table"><thead><tr><th>Month</th><th>Income</th><th>Cost</th></tr></thead><tbody>{board.months.map((m) => <tr key={m.name}><td>{m.name}</td><td>{selected.income[m.index] === null ? "Blank in workbook" : money(selected.income[m.index])}</td><td>{selected.costs === null ? "Unavailable" : selected.costs[m.index] === null ? "Blank in workbook" : money(selected.costs[m.index])}</td></tr>)}</tbody></table></div>
|
||||
<p className="mgmt-source-note">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.</p>
|
||||
</>}
|
||||
</DialogContent></Dialog>
|
||||
</ManagementShell>;
|
||||
}
|
||||
6
src/app/dashboard/management/projects/page.tsx
Normal file
6
src/app/dashboard/management/projects/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getManagementBoard } from "@/lib/management/data";
|
||||
import { ProjectsView } from "../projects-view";
|
||||
|
||||
export default async function ProjectsPage() {
|
||||
return <ProjectsView board={await getManagementBoard()} />;
|
||||
}
|
||||
24
src/app/dashboard/management/retention-view.tsx
Normal file
24
src/app/dashboard/management/retention-view.tsx
Normal file
|
|
@ -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 <ManagementShell active="retention" title="Retention" description="Follow retained income through the plan and inspect scheduled project releases." board={board}>
|
||||
<div className="mgmt-stat-grid"><Stat label="Opening retention" value={compactMoney(board.totals.retentionOpening)} note="Carried into the March–February plan" /><Stat label="New retention" value={compactMoney(board.totals.retentionAdded)} note="10% of selected project income" /><Stat label="Planned payouts" value={compactMoney(board.totals.payouts)} note="Payouts in the income planning sheet" /><Stat label="Closing retention" value={compactMoney(board.totals.retentionClosing)} note="Opening + additions − payouts" /></div>
|
||||
<div className="mgmt-retention-layout"><Panel title="Retention across the plan" subtitle={board.periodLabel} action={<Legend items={[{ label: "Additions", color: chartColors.income }, { label: "Payouts", color: chartColors.cost }, { label: "Balance", color: chartColors.retention }]} />}><RetentionChart months={board.months} /></Panel>
|
||||
<Panel title="Balance rollforward" subtitle="March–February income planning sheet"><div className="mgmt-retention-bridge"><div><span>Opening balance</span><strong>{money(board.totals.retentionOpening)}</strong></div><div><span>New retention</span><strong>{money(board.totals.retentionAdded)}</strong></div><div><span>Less planned payouts</span><strong>{money(board.totals.payouts)}</strong></div><div><span>Closing balance</span><strong>{money(board.totals.retentionClosing)}</strong></div></div><p className="mgmt-source-note">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.</p></Panel>
|
||||
</div>
|
||||
<Panel title="Monthly retention balances" subtitle="10% applies to the workbook’s selected rows, including months with no payouts"><div className="mgmt-table-scroll"><table className="mgmt-table"><thead><tr><th>Month</th><th>Opening</th><th>Additions</th><th>Payouts</th><th>Closing</th></tr></thead><tbody>{board.months.map((m) => <tr key={m.name}><td>{m.name}</td><td>{money(m.retentionOpening)}</td><td>{money(m.retentionAdded)}</td><td>{money(m.payout)}</td><td className="font-semibold">{money(m.retentionClosing)}</td></tr>)}</tbody></table></div></Panel>
|
||||
<div className="mb-5 mt-8 flex flex-wrap items-end justify-between gap-3"><div><h2 className="text-lg font-semibold tracking-tight">Scheduled releases</h2><p className="mt-1 text-xs text-muted-foreground">November 2025–October 2026 · Separate payout schedule</p></div><div className="text-right"><p className="text-2xl font-semibold tracking-tight tabular-nums">{compactMoney(board.totals.scheduledPayouts)}</p><p className="mt-1 text-xs text-muted-foreground">19 scheduled project entries</p></div></div>
|
||||
<div className="mgmt-schedule">{board.retentionSchedule.map((m) => <div key={m.date} className={`mgmt-schedule-month${m.entries.length === 0 ? " is-empty" : ""}`}><div><span>{dateLabel(m.date)}</span><span className="text-muted-foreground">{m.entries.length ? `${m.entries.length} ${m.entries.length === 1 ? "project" : "projects"}` : "—"}</span></div><strong>{m.entries.length === 0 ? "—" : compactMoney(m.total)}</strong><p>{m.entries.length === 0 ? "No release listed" : m.entries.map((e) => e.project).join(" · ")}</p></div>)}</div>
|
||||
<div className="mgmt-filters"><Filter label="Release month" value={month} onChange={setMonth}><option value="all">All release months</option>{board.retentionSchedule.map((m) => <option key={m.date} value={m.date}>{dateLabel(m.date)}</option>)}</Filter><label className="mgmt-filter mgmt-search"><span>Project or debtor</span><input type="search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Search scheduled releases…" /></label></div>
|
||||
<Panel title="Project release schedule" subtitle={`${entries.length} entries in view · ${money(total)}`}><div className="mgmt-table-scroll"><table className="mgmt-table"><thead><tr><th>Project</th><th>Debtor / source note</th><th>Release month</th><th>Planned payout</th></tr></thead><tbody>{entries.map((e) => <tr key={e.id}><td>{e.project}</td><td className="mgmt-debtor">{e.debtor ?? "Not supplied"}</td><td>{dateLabel(e.date)}</td><td>{money(e.amount)}</td></tr>)}</tbody><tfoot><tr><td>Total in view</td><td /><td /><td>{money(total)}</td></tr></tfoot></table></div>{entries.length === 0 && <p className="mgmt-empty">No releases match these filters.</p>}</Panel>
|
||||
</ManagementShell>;
|
||||
}
|
||||
6
src/app/dashboard/management/retention/page.tsx
Normal file
6
src/app/dashboard/management/retention/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getManagementBoard } from "@/lib/management/data";
|
||||
import { RetentionView } from "../retention-view";
|
||||
|
||||
export default async function RetentionPage() {
|
||||
return <RetentionView board={await getManagementBoard()} />;
|
||||
}
|
||||
40
src/app/dashboard/management/turnover-view.tsx
Normal file
40
src/app/dashboard/management/turnover-view.tsx
Normal file
|
|
@ -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 <ManagementShell active="turnover" title="Turnover Overview" description="Income, spend and retention across the company’s planning portfolio." board={board}>
|
||||
<div className="mgmt-filters"><Filter label="Planning period" value={period} onChange={setPeriod}><option value="all">Full period · March–February</option>{board.months.map((m) => <option key={m.name} value={m.index}>{m.name}</option>)}</Filter><span className="ml-auto pb-2 text-xs text-muted-foreground">{board.projects.length} projects · Reported workbook summaries</span></div>
|
||||
<section className="mgmt-hero" aria-label="Planning headline figures"><div className="mgmt-hero-heading"><p>{period === "all" ? board.periodLabel : `${months[0].name} · 2025 workbook`}</p><span>All values in ZAR</span></div><div className="mgmt-hero-stats">
|
||||
<Stat label="Planned turnover" value={compactMoney(income)} note="Saved income summary · including VAT" />
|
||||
<Stat label="Planned costs" value={compactMoney(cost)} note="Total of the selected monthly summaries" />
|
||||
<Stat label="Operating result" value={compactMoney(result)} note={`${percent(income === 0 ? null : result / income)} margin · includes payouts`} tone={result >= 0 ? "positive" : "negative"} />
|
||||
<Stat label="Retention at period end" value={compactMoney(months[months.length - 1].retentionClosing)} note="Outstanding retention rollforward" />
|
||||
</div></section>
|
||||
<div className="mgmt-chart-grid">
|
||||
<Panel title="Income & cost" subtitle={period === "all" ? "The shape of the March–February plan" : `${months[0].name} reported planning figures`}><IncomeCostChart months={months} /></Panel>
|
||||
<Panel title="Cumulative operating result" subtitle="Includes retention payouts · starts from zero"><ResultChart months={board.months} /><div className="mgmt-result-caption"><span>End of planning period</span><b className={board.totals.result >= 0 ? "is-positive" : "is-negative"}>{compactMoney(board.totals.result)}</b></div></Panel>
|
||||
</div>
|
||||
<div className="mgmt-chart-grid">
|
||||
<Panel title="Monthly plan" subtitle="Operating result = income + retention payouts − costs"><div className="mgmt-table-scroll"><table className="mgmt-table"><thead><tr><th>Month</th><th>Income</th><th>Costs</th><th>Payouts</th><th>Income − cost</th><th>Operating result</th><th>Margin</th></tr></thead><tbody>{months.map((m) => <tr key={m.name}><td><span className="mgmt-month"><i className={m.result < 0 ? "is-negative" : ""} />{m.name}</span></td><td>{compactMoney(m.income)}</td><td>{compactMoney(m.cost)}</td><td>{m.payout === 0 ? "—" : money(m.payout)}</td><td className={m.income < m.cost ? "is-negative" : "is-positive"}>{money(m.income - m.cost)}</td><td className={m.result < 0 ? "is-negative" : "is-positive"}>{money(m.result)}</td><td>{percent(m.margin)}</td></tr>)}</tbody><tfoot><tr><td>{period === "all" ? "Full period" : months[0].name}</td><td>{money(income)}</td><td>{money(cost)}</td><td>{money(payouts)}</td><td className={income < cost ? "is-negative" : "is-positive"}>{money(income - cost)}</td><td className={result < 0 ? "is-negative" : "is-positive"}>{money(result)}</td><td>{percent(income === 0 ? null : result / income)}</td></tr></tfoot></table></div></Panel>
|
||||
<Panel title="Largest income schedules" subtitle="Project detail · full planning period" action={<Link href="/dashboard/management/projects" aria-label="View all project forecasts"><ArrowUpRight size={17} /></Link>}>
|
||||
<div className="space-y-5">{leaders.map((p, i) => <div key={p.id}><div className="mb-2 flex items-center justify-between gap-3 text-xs"><span className="min-w-0 truncate"><span className="mr-2 text-muted-foreground">0{i + 1}</span>{p.name}</span><strong className="shrink-0 font-medium tabular-nums">{compactMoney(p.total)}</strong></div><div className="h-1.5 rounded-full bg-muted"><div className="h-full rounded-full bg-[#d9a346]" style={{ width: `${p.total / leaders[0].total * 100}%` }} /></div></div>)}</div>
|
||||
<p className="mgmt-source-note">Project entries total {compactMoney(board.totals.projectIncome)}. The overview’s reported turnover is {compactMoney(board.totals.income)}. See the reconciliation below.</p>
|
||||
</Panel>
|
||||
</div>
|
||||
<Reconciliation board={board} />
|
||||
</ManagementShell>;
|
||||
}
|
||||
6
src/app/dashboard/management/turnover/page.tsx
Normal file
6
src/app/dashboard/management/turnover/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { getManagementBoard } from "@/lib/management/data";
|
||||
import { TurnoverView } from "../turnover-view";
|
||||
|
||||
export default async function TurnoverPage() {
|
||||
return <TurnoverView board={await getManagementBoard()} />;
|
||||
}
|
||||
|
|
@ -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({
|
|||
</head>
|
||||
<body>
|
||||
<ThemeProvider>
|
||||
<AuthProvider mode={process.env.AUTH_MODE === "users" ? "users" : "pin"}>
|
||||
<AuthProvider mode="users">
|
||||
<PwaRegister />
|
||||
{children}
|
||||
</AuthProvider>
|
||||
|
|
|
|||
|
|
@ -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" ? <UserLoginPage /> : <PinLoginPage />;
|
||||
}
|
||||
|
||||
function PinLoginPage() {
|
||||
const router = useRouter();
|
||||
const { expectedPin, unlock } = useAuth();
|
||||
const [pin, setPin] = React.useState("");
|
||||
const [error, setError] = React.useState<string | null>(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 (
|
||||
<main className="relative min-h-screen overflow-hidden bg-background">
|
||||
<div className="absolute inset-0 grid-bg opacity-40" aria-hidden />
|
||||
<div className="absolute -top-32 right-[-10%] h-[420px] w-[420px] rounded-full bg-primary/20 blur-3xl" aria-hidden />
|
||||
<div className="absolute bottom-[-15%] left-[-10%] h-[420px] w-[420px] rounded-full bg-accent/30 blur-3xl dark:bg-accent/15" aria-hidden />
|
||||
|
||||
<div className="relative z-10 flex min-h-screen flex-col items-center justify-center px-4 py-10">
|
||||
<div className="mb-8 flex justify-center">
|
||||
<Logo />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"w-full max-w-md rounded-2xl border border-border bg-card/80 p-6 shadow-xl backdrop-blur transition-transform",
|
||||
shake && "animate-[wiggle_0.3s_ease-in-out]"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<ShieldCheck className="h-4 w-4 text-primary" />
|
||||
<span className="font-medium uppercase tracking-wider">Site Manager Access</span>
|
||||
</div>
|
||||
<h1 className="mt-2 text-2xl font-bold tracking-tight">Enter your PIN</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Use the numeric keypad. Default PIN is set in <code className="rounded bg-muted px-1 py-0.5 text-xs">.env.local</code> as <code className="rounded bg-muted px-1 py-0.5 text-xs">NEXT_PUBLIC_APP_PIN</code>.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 flex items-center justify-center gap-3">
|
||||
{Array.from({ length: Math.max(expectedPin.length, 4) }).map((_, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn(
|
||||
"h-4 w-4 rounded-full border-2 transition-colors",
|
||||
i < pin.length
|
||||
? error
|
||||
? "border-destructive bg-destructive"
|
||||
: "border-primary bg-primary"
|
||||
: "border-border"
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && (
|
||||
<p className="mt-3 text-center text-sm font-medium text-destructive">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-6 grid grid-cols-3 gap-2">
|
||||
{KEYS.map((k) => {
|
||||
if (k === "blank") return <div key={k} aria-hidden />;
|
||||
if (k === "del") {
|
||||
return (
|
||||
<Button
|
||||
key={k}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-14 text-base"
|
||||
onClick={() => press("del")}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Delete className="h-5 w-5" />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
key={k}
|
||||
variant="outline"
|
||||
size="lg"
|
||||
className="h-14 text-xl font-semibold"
|
||||
onClick={() => press(k)}
|
||||
>
|
||||
{k}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
Connected: <span className="font-medium text-foreground">5 sites</span> · 9 vehicles · 24 employees
|
||||
</p>
|
||||
|
||||
<DevPinHint pin={expectedPin} />
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} MEGA INFRASTRUCTURE · Internal use only
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
function UserLoginPage() {
|
||||
const router = useRouter();
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
|
|
@ -223,39 +63,3 @@ function UserLoginPage() {
|
|||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mt-4 flex items-center justify-between gap-2 rounded-lg border border-dashed border-border bg-muted/50 px-3 py-2 text-xs">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound className="h-3.5 w-3.5" />
|
||||
<span className="font-medium">Dev PIN</span>
|
||||
<code className="rounded bg-background px-2 py-0.5 font-mono text-sm font-bold tracking-[0.2em] text-foreground ring-1 ring-border">
|
||||
{pin}
|
||||
</code>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy PIN"
|
||||
>
|
||||
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(<Sidebar user={{ ...user, role: "admin" }} />);
|
||||
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(<Sidebar user={{ ...user, role }} />)).not.toContain('/dashboard/management');
|
||||
});
|
||||
|
||||
it("shows Julius only Workforce and its tools, with the logo pointing to Workforce", () => {
|
||||
const html = renderToStaticMarkup(<Sidebar {...{ user, role: user.role, name: user.name }} />);
|
||||
for (const href of ["/dashboard/workforce", "/dashboard/workforce/workers", "/dashboard/workforce/timesheets", "/dashboard/workforce/on-site"]) expect(html).toContain(`href="${href}"`);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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<typeof import("@/lib/auth/session")>(),
|
||||
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<string, string> = {}): 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" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -12,6 +12,14 @@ const user = (overrides: Partial<AppUser> = {}): 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);
|
||||
|
|
|
|||
|
|
@ -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<DashboardModule, string> = {
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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") ||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<AppUser | null> {
|
||||
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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
12
src/lib/management/data.ts
Normal file
12
src/lib/management/data.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
60
src/lib/management/model.test.ts
Normal file
60
src/lib/management/model.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
123
src/lib/management/model.ts
Normal file
123
src/lib/management/model.ts
Normal file
|
|
@ -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<number>((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<typeof buildManagementBoard>;
|
||||
export type PlanningMonth = ManagementBoard["months"][number];
|
||||
1174
src/lib/management/workbook-snapshot.json
Normal file
1174
src/lib/management/workbook-snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
15
src/test-utils/named-admin-session.ts
Normal file
15
src/test-utils/named-admin-session.ts
Normal file
|
|
@ -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<typeof import("@/lib/auth/session")>(),
|
||||
getCurrentUser: vi.fn(async (): Promise<AppUser | null> => namedAdmin),
|
||||
}));
|
||||
Loading…
Reference in a new issue