feat(repairs-app): the status board with reconciling counts
This commit is contained in:
parent
43aa1a96bb
commit
e2764506c7
3 changed files with 401 additions and 0 deletions
5
src/app/(repairs-app)/repairs-app/page.tsx
Normal file
5
src/app/(repairs-app)/repairs-app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { RepairBoard } from "./repair-board";
|
||||
|
||||
export default function RepairsAppPage() {
|
||||
return <RepairBoard />;
|
||||
}
|
||||
153
src/app/(repairs-app)/repairs-app/repair-board.test.tsx
Normal file
153
src/app/(repairs-app)/repairs-app/repair-board.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
243
src/app/(repairs-app)/repairs-app/repair-board.tsx
Normal file
243
src/app/(repairs-app)/repairs-app/repair-board.tsx
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue