From e2764506c747e1a08c80f8aa1c7205658e485db5 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 6 Nov 2026 16:52:17 +0200 Subject: [PATCH] feat(repairs-app): the status board with reconciling counts --- src/app/(repairs-app)/repairs-app/page.tsx | 5 + .../repairs-app/repair-board.test.tsx | 153 +++++++++++ .../repairs-app/repair-board.tsx | 243 ++++++++++++++++++ 3 files changed, 401 insertions(+) create mode 100644 src/app/(repairs-app)/repairs-app/page.tsx create mode 100644 src/app/(repairs-app)/repairs-app/repair-board.test.tsx create mode 100644 src/app/(repairs-app)/repairs-app/repair-board.tsx diff --git a/src/app/(repairs-app)/repairs-app/page.tsx b/src/app/(repairs-app)/repairs-app/page.tsx new file mode 100644 index 0000000..46c02ee --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/page.tsx @@ -0,0 +1,5 @@ +import { RepairBoard } from "./repair-board"; + +export default function RepairsAppPage() { + return ; +} diff --git a/src/app/(repairs-app)/repairs-app/repair-board.test.tsx b/src/app/(repairs-app)/repairs-app/repair-board.test.tsx new file mode 100644 index 0000000..f1c329a --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/repair-board.test.tsx @@ -0,0 +1,153 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, within } from "@testing-library/react"; +import { RepairBoard } from "./repair-board"; + +// The browser session lives in the PocketBase auth store, not on the +// auth context — so that is what gets mocked. +vi.mock("@/lib/pocketbase", () => ({ + getPocketBase: () => ({ authStore: { token: "t" } }), +})); + +const REPAIRS = [ + { + id: "rep1", + repair_number: "R-0007", + status: "assessing", + item_description: "Hydraulic press, 40T", + fault_description: "Will not build pressure", + serial_no: "HP-2211", + customer: { name: "Borer Plant" }, + supplier: { suppliername: "Bolt & Nut" }, + photos: ["a.jpg", "b.jpg"], + }, + { + id: "rep2", + repair_number: "R-0008", + status: "completed", + item_description: "Welding machine", + fault_description: "Will not strike", + serial_no: "WM-9", + // The list API expands the relations — this is the shape the + // board actually gets from the server. + customer: "cust2", + expand: { customer: { id: "cust2", customer: "Acme" } }, + supplier: null, + photos: [], + }, +]; + +function mockFetch() { + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/status-counts")) { + return new Response( + JSON.stringify({ + total: 12, + byStatus: { + new: 3, + assessing: 2, + quoted: 4, + approved: 1, + in_progress: 1, + completed: 1, + cancelled: 0, + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + } + return new Response( + JSON.stringify({ + items: REPAIRS, + totalItems: 12, + totalPages: 1, + page: 1, + perPage: 20, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }), + ); +} + +/** + * Render the board and wait for it to load. + * + * The status chips are on screen before the fetch resolves (a status with + * no repairs still shows a zero), so waiting on a chip would not prove + * the board loaded. Waiting on the first card does. + */ +async function renderBoard() { + render(); + await screen.findByText(/Hydraulic press, 40T/); +} + +describe("RepairBoard", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + mockFetch(); + }); + + it("shows every status filter with its count", async () => { + await renderBoard(); + const filters = within( + screen.getByRole("group", { name: "Filter by status" }), + ); + expect(filters.getByText(/Booked in/)).toBeTruthy(); + expect(filters.getByText(/With supplier/)).toBeTruthy(); + expect(filters.getByText(/Quote in/)).toBeTruthy(); + expect(filters.getByText(/Approved/)).toBeTruthy(); + expect(filters.getByText(/On the bench/)).toBeTruthy(); + expect(filters.getByText(/Done/)).toBeTruthy(); + expect(filters.getByText(/Cancelled/)).toBeTruthy(); + }); + + it("the seven status counts add up to the All count", async () => { + await renderBoard(); + const filters = within( + screen.getByRole("group", { name: "Filter by status" }), + ); + + const expected: Array<[string, number]> = [ + ["Booked in", 3], + ["With supplier", 2], + ["Quote in", 4], + ["Approved", 1], + ["On the bench", 1], + ["Done", 1], + ["Cancelled", 0], + ]; + + const shown = expected.map(([label, count]) => { + expect(filters.getByText(`${label} ${count}`)).toBeTruthy(); + return count; + }); + + const all = filters.getByText(/^All \d+$/).textContent ?? ""; + const total = Number(all.replace("All", "").trim()); + expect(shown.reduce((sum, n) => sum + n, 0)).toBe(total); + }); + + it("shows the status as text, not as colour alone", async () => { + await renderBoard(); + expect(screen.getAllByText(/With supplier/).length).toBeGreaterThan(0); + }); + + it("never renders a price, a cost or a margin", async () => { + await renderBoard(); + expect(screen.queryByText(/R ?[0-9.,]+/)).toBeNull(); + expect(screen.queryByText(/cost/i)).toBeNull(); + expect(screen.queryByText(/margin/i)).toBeNull(); + }); + + it("shows the machine, the customer and the serial number on each card", async () => { + await renderBoard(); + expect(screen.getByText(/Hydraulic press, 40T/)).toBeTruthy(); + expect(screen.getByText(/Borer Plant/)).toBeTruthy(); + expect(screen.getByText(/HP-2211/)).toBeTruthy(); + // The expanded customer the list API actually sends. + expect(screen.getByText(/Acme/)).toBeTruthy(); + }); +}); diff --git a/src/app/(repairs-app)/repairs-app/repair-board.tsx b/src/app/(repairs-app)/repairs-app/repair-board.tsx new file mode 100644 index 0000000..360618e --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/repair-board.tsx @@ -0,0 +1,243 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { getPocketBase } from "@/lib/pocketbase"; +import { + REPAIR_STATUS, + REPAIR_STATUS_LABEL, + REPAIR_STATUS_VARIANT, + type RepairStatus, +} from "@/lib/repair-status"; +import type { Repair } from "@/lib/types"; + +type Counts = { total: number; byStatus: Record }; + +const EMPTY_COUNTS: Counts = { + total: 0, + byStatus: { + new: 0, + assessing: 0, + quoted: 0, + approved: 0, + in_progress: 0, + completed: 0, + cancelled: 0, + }, +}; + +/** + * A row as the list API returns it. + * + * The repairs route expands `customer` and `supplier`, so a row carries + * the relation id AND the expanded record. The board shows the NAME, so + * the expanded record is read first and a plain nested object is the + * fallback. + */ +type BoardRepair = Omit & { + customer?: string | { name?: string; customer?: string } | null; + supplier?: string | { suppliername?: string } | null; +}; + +/** + * The board — the screen the repairs person opens first. + * + * Two rules drive everything here: + * - every status shows its COUNT, and the counts add up to the total; + * - no money, ever. A repair row carries no prices, and this screen + * must not invent one. + */ +export function RepairBoard() { + const [repairs, setRepairs] = useState([]); + const [counts, setCounts] = useState(EMPTY_COUNTS); + const [status, setStatus] = useState(""); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function load() { + setLoading(true); + setError(null); + try { + const params = new URLSearchParams(); + params.set("perPage", "50"); + params.set("sort", "-created"); + if (status) params.set("status", status); + if (search) params.set("search", search); + const headers = authHeaders(); + const [listRes, countRes] = await Promise.all([ + fetch("/api/v1/repairs?" + params.toString(), { headers }), + fetch("/api/v1/repairs/status-counts", { headers }), + ]); + if (!listRes.ok) throw new Error("Could not load the repairs."); + const list = (await listRes.json()) as { items: BoardRepair[] }; + const c = countRes.ok + ? ((await countRes.json()) as Counts) + : EMPTY_COUNTS; + if (cancelled) return; + setRepairs(list.items ?? []); + setCounts(c); + } catch (err: unknown) { + if (!cancelled) { + setError( + err instanceof Error ? err.message : "Could not load the repairs.", + ); + } + } finally { + if (!cancelled) setLoading(false); + } + } + load(); + return () => { + cancelled = true; + }; + }, [status, search]); + + const chips = useMemo( + () => [ + { key: "" as const, label: "All", count: counts.total }, + ...REPAIR_STATUS.map((s) => ({ + key: s, + label: REPAIR_STATUS_LABEL[s], + count: counts.byStatus[s] ?? 0, + })), + ], + [counts], + ); + + return ( +
+

Repairs

+ + setSearch(e.target.value)} + className="min-h-[48px] w-full rounded-md border bg-background px-3 text-base" + /> + +
+ {chips.map((chip) => { + const active = chip.key === status; + return ( + + ); + })} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

Loading repairs…

+ ) : null} + + {!loading && !error && repairs.length === 0 ? ( +

+ No repairs match this filter. +

+ ) : null} + +
    + {repairs.map((repair) => ( +
  • + +
    + {repair.repair_number} + + {REPAIR_STATUS_LABEL[repair.status] || repair.status} + +
    +

    {repair.item_description}

    +

    + {customerName(repair)} + {repair.serial_no ? " · S/N " + repair.serial_no : ""} +

    +

    + {photoCount(repair) > 0 + ? photoCount(repair) + + " photo" + + (photoCount(repair) === 1 ? "" : "s") + : "No photos"} + {supplierName(repair) ? " · " + supplierName(repair) : ""} +

    + +
  • + ))} +
+ + {/* Sits just above the shell's fixed tab bar. */} +
+ + Book in a machine + +
+
+ ); +} + +/** + * Authorization header for the repairs routes. + * + * The browser session lives in the PocketBase auth store — the same + * instance the auth provider uses — NOT on the auth context, so it is + * read from there directly. Same helper as the office repairs page. + */ +function authHeaders(): Record { + const token = getPocketBase().authStore.token; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +function customerName(repair: BoardRepair): string { + const expanded = repair.expand?.customer; + if (expanded?.customer) return expanded.customer; + const raw = repair.customer; + if (raw && typeof raw === "object") return raw.name ?? raw.customer ?? ""; + return ""; +} + +function supplierName(repair: BoardRepair): string { + const expanded = repair.expand?.supplier; + if (expanded?.suppliername) return expanded.suppliername; + const raw = repair.supplier; + if (raw && typeof raw === "object") return raw.suppliername ?? ""; + return ""; +} + +function photoCount(repair: BoardRepair): number { + return Array.isArray(repair.photos) ? repair.photos.length : 0; +}