fix(repairs-app): stop the book-in button covering the last card, and cover the session header

This commit is contained in:
Dave 2026-11-06 17:32:33 +02:00
parent e2764506c7
commit 13d887eae8
2 changed files with 268 additions and 69 deletions

View file

@ -1,5 +1,11 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen, within } from "@testing-library/react";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import { RepairBoard } from "./repair-board";
// The browser session lives in the PocketBase auth store, not on the
@ -8,6 +14,15 @@ vi.mock("@/lib/pocketbase", () => ({
getPocketBase: () => ({ authStore: { token: "t" } }),
}));
/**
* A price-shaped figure.
*
* "R 1200.50", "1200.00" and "1 200.00" are money. "HR 200 Press" and
* "HP-2211" are a machine and a serial number, and must not trip it.
*/
const MONEY = /\bR ?[0-9.,]+|\d[\d\s]*[.,]\d{2}/;
/** The shape the list API actually returns — relations expanded. */
const REPAIRS = [
{
id: "rep1",
@ -16,8 +31,11 @@ const REPAIRS = [
item_description: "Hydraulic press, 40T",
fault_description: "Will not build pressure",
serial_no: "HP-2211",
customer: { name: "Borer Plant" },
supplier: { suppliername: "Bolt & Nut" },
customer: "cust1",
expand: {
customer: { id: "cust1", customer: "Borer Plant" },
supplier: { id: "sup1", suppliername: "Bolt & Nut" },
},
photos: ["a.jpg", "b.jpg"],
},
{
@ -27,21 +45,39 @@ const REPAIRS = [
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: [],
},
{
id: "rep3",
repair_number: "R-0009",
status: "quoted",
item_description: "HR 200 Press",
fault_description: "Loses pressure",
serial_no: "HR-200",
customer: "cust3",
expand: { customer: { id: "cust3", customer: "Vulcan Plant" } },
photos: [],
},
];
function mockFetch() {
type Call = { url: string; headers: Record<string, string> };
let calls: Call[] = [];
function mockFetch(options: { listFail?: boolean; countsFail?: boolean } = {}) {
calls = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const headers = { ...((init?.headers ?? {}) as Record<string, string>) };
calls.push({ url, headers });
if (url.includes("/status-counts")) {
if (options.countsFail) return new Response("{}", { status: 500 });
return new Response(
JSON.stringify({
total: 12,
@ -58,13 +94,14 @@ function mockFetch() {
{ status: 200, headers: { "content-type": "application/json" } },
);
}
if (options.listFail) return new Response("{}", { status: 500 });
return new Response(
JSON.stringify({
items: REPAIRS,
totalItems: 12,
totalPages: 1,
page: 1,
perPage: 20,
perPage: 100,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
@ -72,11 +109,24 @@ function mockFetch() {
);
}
function listCalls(): Call[] {
return calls.filter((c) => c.url.startsWith("/api/v1/repairs?"));
}
function countsCalls(): Call[] {
return calls.filter((c) => c.url.includes("/api/v1/repairs/status-counts"));
}
/** The filter chips — scoped, because the cards repeat the same words. */
function chips() {
return within(screen.getByRole("group", { name: "Filter by status" }));
}
/**
* 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
* no repairs still shows a chip), so waiting on a chip would not prove
* the board loaded. Waiting on the first card does.
*/
async function renderBoard() {
@ -92,9 +142,7 @@ describe("RepairBoard", () => {
it("shows every status filter with its count", async () => {
await renderBoard();
const filters = within(
screen.getByRole("group", { name: "Filter by status" }),
);
const filters = chips();
expect(filters.getByText(/Booked in/)).toBeTruthy();
expect(filters.getByText(/With supplier/)).toBeTruthy();
expect(filters.getByText(/Quote in/)).toBeTruthy();
@ -106,9 +154,7 @@ describe("RepairBoard", () => {
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 filters = chips();
const expected: Array<[string, number]> = [
["Booked in", 3],
@ -130,24 +176,112 @@ describe("RepairBoard", () => {
expect(shown.reduce((sum, n) => sum + n, 0)).toBe(total);
});
it("sends the browser session on both calls", async () => {
// Task 1 made both endpoints session-gated. Without this header the
// board answers 401 on every request.
await renderBoard();
expect(listCalls()[0]?.headers.Authorization).toBe("Bearer t");
expect(countsCalls()[0]?.headers.Authorization).toBe("Bearer t");
});
it("shows the status as text, not as colour alone", async () => {
await renderBoard();
expect(screen.getAllByText(/With supplier/).length).toBeGreaterThan(0);
// The chip AND the badge on the card — one without the other is not
// enough, and the unscoped count must catch a missing badge.
expect(screen.getAllByText(/With supplier/).length).toBeGreaterThanOrEqual(
2,
);
expect(chips().getByText(/With supplier/)).toBeTruthy();
});
it("never renders a price, a cost or a margin", async () => {
await renderBoard();
expect(screen.queryByText(/R ?[0-9.,]+/)).toBeNull();
expect(screen.queryByText(MONEY)).toBeNull();
expect(screen.queryByText(/cost/i)).toBeNull();
expect(screen.queryByText(/margin/i)).toBeNull();
});
it("the money guard catches a figure but not a machine name", () => {
expect(MONEY.test("1200.00")).toBe(true);
expect(MONEY.test("R 1200.50")).toBe(true);
expect(MONEY.test("1 200.00")).toBe(true);
expect(MONEY.test("HR 200 Press")).toBe(false);
expect(MONEY.test("HP-2211")).toBe(false);
expect(MONEY.test("Hydraulic press, 40T")).toBe(false);
});
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();
});
it("says so when the counts fail instead of showing zeros", async () => {
mockFetch({ countsFail: true });
render(<RepairBoard />);
await screen.findByText(/Hydraulic press, 40T/);
expect(screen.getByText(/Could not load the counts/)).toBeTruthy();
// No fake zero above a list of real repairs.
expect(chips().queryByText("All 0")).toBeNull();
expect(chips().getByText(/All/)).toBeTruthy();
// The list itself still renders.
expect(screen.getByText(/Borer Plant/)).toBeTruthy();
});
it("retries the counts when the error line is pressed", async () => {
mockFetch({ countsFail: true });
render(<RepairBoard />);
await screen.findByText(/Could not load the counts/);
const before = countsCalls().length;
fireEvent.click(screen.getByText(/Could not load the counts/));
await waitFor(() => expect(countsCalls().length).toBe(before + 1), {
timeout: 2000,
});
});
it("keeps the counts when the list fails", async () => {
mockFetch({ listFail: true });
render(<RepairBoard />);
await screen.findByText(/Could not load the repairs/);
expect(chips().getByText("All 12")).toBeTruthy();
});
it("waits for a pause in typing before searching", async () => {
await renderBoard();
const before = listCalls().length;
const input = screen.getByLabelText("Search repairs");
fireEvent.change(input, { target: { value: "H" } });
fireEvent.change(input, { target: { value: "HP" } });
fireEvent.change(input, { target: { value: "HP-2" } });
// Nothing goes out while the finger is still on the keyboard.
await new Promise((resolve) => setTimeout(resolve, 100));
expect(listCalls().length).toBe(before);
// Three keystrokes, then ONE request — with the search term.
await waitFor(() => expect(listCalls().length).toBe(before + 1), {
timeout: 2000,
});
expect(listCalls()[before].url).toContain("search=HP-2");
});
it("does not refetch the counts when only the search text changes", async () => {
await renderBoard();
const countsBefore = countsCalls().length;
fireEvent.change(screen.getByLabelText("Search repairs"), {
target: { value: "HP" },
});
await waitFor(() => expect(listCalls().length).toBeGreaterThan(1), {
timeout: 2000,
});
// The counts are global — a new search cannot change them.
expect(countsCalls().length).toBe(countsBefore);
});
});

View file

@ -27,35 +27,44 @@ const EMPTY_COUNTS: Counts = {
},
};
/**
* 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;
};
/** How long to wait after the last keystroke before searching. */
const SEARCH_DEBOUNCE_MS = 300;
/**
* The board — the screen the repairs person opens first.
*
* Two rules drive everything here:
* Three rules drive everything here:
* - every status shows its COUNT, and the counts add up to the total;
* - a count that could not be loaded is shown as unknown, never as a
* zero that would contradict the list below it;
* - 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 [repairs, setRepairs] = useState<Repair[]>([]);
const [counts, setCounts] = useState<Counts>(EMPTY_COUNTS);
/** The counts have never loaded — the chips say "unknown", not "0". */
const [countsLoaded, setCountsLoaded] = useState(false);
const [countsError, setCountsError] = useState<string | null>(null);
const [status, setStatus] = useState<RepairStatus | "">("");
const [search, setSearch] = useState("");
/** What the list is actually filtered by — the search, after a pause. */
const [appliedSearch, setAppliedSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [countsRetry, setCountsRetry] = useState(0);
// Typing fires a request per character otherwise — two per keystroke on
// a phone. Wait for a pause before searching.
useEffect(() => {
const handle = window.setTimeout(
() => setAppliedSearch(search),
SEARCH_DEBOUNCE_MS,
);
return () => window.clearTimeout(handle);
}, [search]);
// The list. Only the status filter and the paused search change it.
useEffect(() => {
let cancelled = false;
async function load() {
@ -63,23 +72,17 @@ export function RepairBoard() {
setError(null);
try {
const params = new URLSearchParams();
params.set("perPage", "50");
params.set("perPage", "100");
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 (appliedSearch) params.set("search", appliedSearch);
const res = await fetch("/api/v1/repairs?" + params.toString(), {
headers: authHeaders(),
});
if (!res.ok) throw new Error("Could not load the repairs.");
const list = (await res.json()) as { items: Repair[] };
if (cancelled) return;
setRepairs(list.items ?? []);
setCounts(c);
} catch (err: unknown) {
if (!cancelled) {
setError(
@ -90,11 +93,55 @@ export function RepairBoard() {
if (!cancelled) setLoading(false);
}
}
load();
void load();
return () => {
cancelled = true;
};
}, [status, search]);
}, [status, appliedSearch]);
// The counts are GLOBAL — they take no query params, so a new search
// cannot change them and re-fetching them for every keystroke is pure
// waste (each one costs seven queries). They are re-fetched when the
// status filter changes, and when the phone comes back to this screen
// so the numbers stay true.
useEffect(() => {
let cancelled = false;
async function loadCounts() {
try {
const res = await fetch("/api/v1/repairs/status-counts", {
headers: authHeaders(),
});
if (!res.ok) throw new Error("Could not load the counts.");
const c = (await res.json()) as Counts;
if (cancelled) return;
setCounts(c);
setCountsLoaded(true);
setCountsError(null);
} catch (err: unknown) {
if (cancelled) return;
// An unknown count is shown as unknown — a zero here would
// contradict the list of repairs right underneath it.
setCountsLoaded(false);
setCountsError(
err instanceof Error ? err.message : "Could not load the counts.",
);
}
}
void loadCounts();
const refresh = () => {
if (document.visibilityState === "visible") void loadCounts();
};
window.addEventListener("focus", refresh);
document.addEventListener("visibilitychange", refresh);
return () => {
cancelled = true;
window.removeEventListener("focus", refresh);
document.removeEventListener("visibilitychange", refresh);
};
}, [status, countsRetry]);
const chips = useMemo(
() => [
@ -108,6 +155,8 @@ export function RepairBoard() {
[counts],
);
const countsKnown = countsLoaded && !countsError;
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>
@ -139,12 +188,22 @@ export function RepairBoard() {
(active ? "border-primary bg-primary/10 font-medium" : "")
}
>
{chip.label} {chip.count}
{chip.label} {countsKnown ? chip.count : "\u2014"}
</button>
);
})}
</div>
{countsError ? (
<button
type="button"
onClick={() => setCountsRetry((n) => n + 1)}
className="rounded-md border border-destructive/40 p-3 text-left text-sm underline"
>
Could not load the counts — try again.
</button>
) : null}
{error ? (
<p className="rounded-md border border-destructive/40 p-3 text-sm">
{error}
@ -152,7 +211,7 @@ export function RepairBoard() {
) : null}
{loading ? (
<p className="p-3 text-sm text-muted-foreground">Loading repairs…</p>
<p className="p-3 text-sm text-muted-foreground">Loading repairs\u2026</p>
) : null}
{!loading && !error && repairs.length === 0 ? (
@ -161,7 +220,12 @@ export function RepairBoard() {
</p>
) : null}
<ul className="flex flex-col gap-2">
{/*
The list carries its own bottom padding so the LAST card clears
the floating "Book in a machine" button, which sits 56px above
the bottom of the shell's content box.
*/}
<ul className="flex flex-col gap-2 pb-16">
{repairs.map((repair) => (
<li key={repair.id}>
<Link
@ -169,7 +233,14 @@ export function RepairBoard() {
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>
<div>
<span className="font-medium">{repair.repair_number}</span>
{repair.serial_no ? (
<p className="text-xs text-muted-foreground">
S/N: {repair.serial_no}
</p>
) : null}
</div>
<Badge
variant={REPAIR_STATUS_VARIANT[repair.status] || "secondary"}
>
@ -177,17 +248,18 @@ export function RepairBoard() {
</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>
{customerName(repair) ? (
<p className="mt-1 text-sm text-muted-foreground">
{customerName(repair)}
</p>
) : null}
<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) : ""}
{supplierName(repair) ? " \u00b7 " + supplierName(repair) : ""}
</p>
</Link>
</li>
@ -222,22 +294,15 @@ function authHeaders(): Record<string, string> {
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 "";
/** The expanded customer record — the relation id carries no name. */
function customerName(repair: Repair): string {
return repair.expand?.customer?.customer ?? "";
}
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 supplierName(repair: Repair): string {
return repair.expand?.supplier?.suppliername ?? "";
}
function photoCount(repair: BoardRepair): number {
function photoCount(repair: Repair): number {
return Array.isArray(repair.photos) ? repair.photos.length : 0;
}