feat(repairs-app): the status board with reconciling counts

This commit is contained in:
Dave 2026-11-06 16:52:17 +02:00
parent 43aa1a96bb
commit e2764506c7
3 changed files with 401 additions and 0 deletions

View file

@ -0,0 +1,5 @@
import { RepairBoard } from "./repair-board";
export default function RepairsAppPage() {
return <RepairBoard />;
}

View file

@ -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(<RepairBoard />);
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();
});
});

View file

@ -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<RepairStatus, number> };
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<Repair, "customer" | "supplier"> & {
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<BoardRepair[]>([]);
const [counts, setCounts] = useState<Counts>(EMPTY_COUNTS);
const [status, setStatus] = useState<RepairStatus | "">("");
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-3 p-3">
<h1 className="text-2xl font-semibold">Repairs</h1>
<input
type="search"
aria-label="Search repairs"
placeholder="Search repair number, machine or serial"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="min-h-[48px] w-full rounded-md border bg-background px-3 text-base"
/>
<div
className="flex flex-wrap gap-2"
role="group"
aria-label="Filter by status"
>
{chips.map((chip) => {
const active = chip.key === status;
return (
<button
key={chip.key || "all"}
type="button"
aria-pressed={active}
onClick={() => setStatus(chip.key)}
className={
"min-h-[40px] rounded-full border px-3 text-sm " +
(active ? "border-primary bg-primary/10 font-medium" : "")
}
>
{chip.label} {chip.count}
</button>
);
})}
</div>
{error ? (
<p className="rounded-md border border-destructive/40 p-3 text-sm">
{error}
</p>
) : null}
{loading ? (
<p className="p-3 text-sm text-muted-foreground">Loading repairs…</p>
) : null}
{!loading && !error && repairs.length === 0 ? (
<p className="p-3 text-sm text-muted-foreground">
No repairs match this filter.
</p>
) : null}
<ul className="flex flex-col gap-2">
{repairs.map((repair) => (
<li key={repair.id}>
<Link
href={"/repairs-app/" + repair.id}
className="block rounded-lg border bg-card p-3"
>
<div className="flex items-start justify-between gap-2">
<span className="font-medium">{repair.repair_number}</span>
<Badge
variant={REPAIR_STATUS_VARIANT[repair.status] || "secondary"}
>
{REPAIR_STATUS_LABEL[repair.status] || repair.status}
</Badge>
</div>
<p className="mt-1 text-base">{repair.item_description}</p>
<p className="mt-1 text-sm text-muted-foreground">
{customerName(repair)}
{repair.serial_no ? " · S/N " + repair.serial_no : ""}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{photoCount(repair) > 0
? photoCount(repair) +
" photo" +
(photoCount(repair) === 1 ? "" : "s")
: "No photos"}
{supplierName(repair) ? " · " + supplierName(repair) : ""}
</p>
</Link>
</li>
))}
</ul>
{/* Sits just above the shell's fixed tab bar. */}
<div
className="fixed inset-x-0 z-30 px-3"
style={{ bottom: "calc(env(safe-area-inset-bottom) + 72px)" }}
>
<Link
href="/repairs-app/book-in"
className="flex min-h-[56px] items-center justify-center rounded-lg bg-primary text-base font-semibold text-primary-foreground"
>
Book in a machine
</Link>
</div>
</div>
);
}
/**
* 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<string, string> {
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;
}