feat(repairs-app): the repair detail with one obvious next step

This commit is contained in:
Dave 2026-11-06 18:50:55 +02:00
parent c4e97c3cd5
commit 63ef01f604
3 changed files with 762 additions and 0 deletions

View file

@ -0,0 +1,10 @@
import { RepairDetail } from "./repair-detail";
export default async function RepairDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <RepairDetail repairId={id} />;
}

View file

@ -0,0 +1,243 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { RepairDetail } from "./repair-detail";
vi.mock("@/lib/pocketbase", () => ({
getPocketBase: () => ({ authStore: { token: "t" } }),
}));
/**
* The shape the API actually returns.
*
* The customer and the supplier are RELATION IDS on the repair row —
* the names arrive under `expand` (the service expands
* `customer,company,order,supplier`). Reading the name off the
* relation id renders a blank line in production.
*/
const REPAIR = {
id: "rep1",
repair_number: "R-0007",
status: "assessing",
item_description: "Hydraulic press, 40T",
fault_description: "Will not build pressure",
supplier_scope: "Strip and quote",
serial_no: "HP-2211",
customer: "cust1",
supplier: "sup1",
expand: {
customer: { id: "cust1", customer: "Borer Plant" },
supplier: { id: "sup1", suppliername: "Bolt & Nut" },
},
photos: ["a.jpg"],
repair_request: ["RR-0007.pdf"],
quote: [],
purchase_order: [],
signed_request: [],
};
/**
* jsdom has no `URL.createObjectURL`. The photos are fetched with the
* session and rendered from a blob URL, so the stub is what makes the
* photo path testable at all.
*/
if (!URL.createObjectURL) {
URL.createObjectURL = vi.fn(() => "blob:test");
URL.revokeObjectURL = vi.fn();
}
type Call = { url: string; method: string; init: RequestInit | undefined };
let calls: Call[] = [];
let currentRepair: Record<string, unknown> = REPAIR;
function mockFetch() {
calls = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
calls.push({ url, method: init?.method ?? "GET", init });
return new Response(JSON.stringify(currentRepair), {
status: 200,
headers: { "content-type": "application/json" },
});
}),
);
}
/** A header value, whatever shape the caller used to send it. */
function headerValue(
init: RequestInit | undefined,
name: string,
): string | undefined {
const headers = init?.headers;
if (!headers) return undefined;
if (typeof (headers as Headers).get === "function") {
return (headers as Headers).get(name) ?? undefined;
}
const record = headers as Record<string, string>;
const key = Object.keys(record).find(
(k) => k.toLowerCase() === name.toLowerCase(),
);
return key === undefined ? undefined : record[key];
}
/** Every header name on the request, lowercased. */
function headerNames(init: RequestInit | undefined): string[] {
const headers = init?.headers;
if (!headers) return [];
if (typeof (headers as Headers).get === "function") {
return Array.from((headers as Headers).keys()).map((h) => h.toLowerCase());
}
return Object.keys(headers as Record<string, string>).map((h) =>
h.toLowerCase(),
);
}
function callTo(urlPart: string, method?: string): Call | undefined {
return calls.find(
(c) =>
c.url.includes(urlPart) &&
(method === undefined || c.method === method),
);
}
/** Render the screen and wait for the repair to arrive. */
async function renderDetail() {
render(<RepairDetail repairId="rep1" />);
await screen.findByText(/Hydraulic press, 40T/);
}
/**
* Every label the next step can carry. A finished job must offer NONE
* of them — not a disabled one.
*/
const NEXT_STEP_LABELS = [
/send to supplier/i,
/upload supplier quote/i,
/make customer quote/i,
/start work/i,
/mark completed/i,
];
describe("RepairDetail", () => {
beforeEach(() => {
vi.unstubAllGlobals();
currentRepair = REPAIR;
mockFetch();
});
it("shows the status as a word", async () => {
render(<RepairDetail repairId="rep1" />);
await waitFor(() =>
expect(screen.getAllByText(/With supplier/).length).toBeGreaterThan(0),
);
});
it("offers exactly one next-step button, named for the action", async () => {
render(<RepairDetail repairId="rep1" />);
await waitFor(() =>
expect(
screen.getByRole("button", { name: /upload supplier quote/i }),
).toBeTruthy(),
);
expect(
screen.queryByRole("button", { name: /send to supplier/i }),
).toBeNull();
});
it("shows the machine, the customer and the serial number", async () => {
render(<RepairDetail repairId="rep1" />);
await waitFor(() =>
expect(screen.getByText(/Hydraulic press, 40T/)).toBeTruthy(),
);
expect(screen.getByText(/Borer Plant/)).toBeTruthy();
expect(screen.getByText(/HP-2211/)).toBeTruthy();
});
it("never renders a price, a cost or a margin", async () => {
render(<RepairDetail repairId="rep1" />);
await waitFor(() =>
expect(screen.getByText(/Hydraulic press, 40T/)).toBeTruthy(),
);
expect(screen.queryByText(/R ?[0-9.,]+/)).toBeNull();
expect(screen.queryByText(/cost/i)).toBeNull();
expect(screen.queryByText(/margin/i)).toBeNull();
});
it("reads the customer and the supplier from the expanded relations", async () => {
// The relation id carries no name — the name is on the expanded
// record. Reading the id renders a blank line.
await renderDetail();
expect(screen.getByText("Borer Plant")).toBeTruthy();
expect(screen.getByText("Bolt & Nut")).toBeTruthy();
// The id itself is never shown to the operator.
expect(screen.queryByText("cust1")).toBeNull();
});
it("sends the browser session on every request it makes", async () => {
// Task 1 gated the repairs routes AND the repairs slice of the file
// proxy — a plain <img src> cannot carry the header, so the photo
// has to be fetched with the session too.
await renderDetail();
const read = callTo("/api/v1/repairs/rep1");
const photo = callTo("/api/v1/files/repairs/rep1/");
expect(read).toBeDefined();
expect(photo).toBeDefined();
expect(headerValue(read?.init, "authorization")).toBe("Bearer t");
expect(headerValue(photo?.init, "authorization")).toBe("Bearer t");
});
it("offers a camera that takes a photo of the machine", async () => {
await renderDetail();
const input = screen.getByLabelText(
/add a photo of the machine/i,
) as HTMLInputElement;
expect(input.getAttribute("accept")).toContain("image/");
expect(input.getAttribute("capture")).toBe("environment");
});
it("uploads a photo as multipart, without a Content-Type", async () => {
await renderDetail();
fireEvent.change(screen.getByLabelText(/add a photo of the machine/i), {
target: {
files: [new File(["img"], "machine.jpg", { type: "image/jpeg" })],
},
});
await waitFor(() => expect(callTo("/files/photos", "POST")).toBeDefined(), {
timeout: 2000,
});
const upload = callTo("/files/photos", "POST");
expect(upload?.url).toBe("/api/v1/repairs/rep1/files/photos");
expect(headerValue(upload?.init, "authorization")).toBe("Bearer t");
// The field name is literally "photos".
const form = upload?.init?.body as FormData;
expect(form).toBeInstanceOf(FormData);
const photo = form.get("photos");
expect(photo).toBeInstanceOf(File);
expect((photo as File).name).toBe("machine.jpg");
// No Content-Type — the browser sets the multipart boundary, and a
// content type set by hand breaks it.
expect(headerNames(upload?.init)).not.toContain("content-type");
});
it("offers no next step at all once the job is finished", async () => {
currentRepair = { ...REPAIR, status: "completed" };
await renderDetail();
for (const label of NEXT_STEP_LABELS) {
expect(screen.queryByRole("button", { name: label })).toBeNull();
}
// The status still reads as a word.
expect(screen.getAllByText(/Done/).length).toBeGreaterThan(0);
});
it("offers no next step at all once the job is cancelled", async () => {
currentRepair = { ...REPAIR, status: "cancelled" };
await renderDetail();
for (const label of NEXT_STEP_LABELS) {
expect(screen.queryByRole("button", { name: label })).toBeNull();
}
expect(screen.getAllByText(/Cancelled/).length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,509 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { getPocketBase } from "@/lib/pocketbase";
import {
REPAIR_STATUS,
REPAIR_STATUS_LABEL,
REPAIR_STATUS_VARIANT,
nextStepFor,
type RepairStatus,
} from "@/lib/repair-status";
import type { Repair } from "@/lib/types";
/**
* Authorization header for the repairs routes AND the repairs slice of
* the file proxy.
*
* 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 and
* the phone app's status board.
*/
function authHeaders(): Record<string, string> {
const token = getPocketBase().authStore.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
/** URL of a repair file inside the (session-gated) file proxy. */
function repairFileUrl(repairId: string, filename: string): string {
return `/api/v1/files/repairs/${repairId}/${encodeURIComponent(filename)}`;
}
/**
* Fetch the repair's photos and return a filename -> blob URL map, plus
* the filenames that could not be loaded.
*
* The file proxy requires a session and an `<img src>` cannot carry an
* Authorization header, so the bytes are fetched with the session and
* the browser renders them from a blob URL. A failed photo is reported
* by name rather than skipped: a silent skip would leave its tile
* reading "Loading…" for ever.
*/
async function loadPhotoUrls(
repairId: string,
files: string[],
): Promise<{ urls: Record<string, string>; failed: string[] }> {
const urls: Record<string, string> = {};
const failed: string[] = [];
for (const name of files) {
try {
const res = await fetch(repairFileUrl(repairId, name), {
headers: authHeaders(),
});
if (!res.ok) {
failed.push(name);
continue;
}
urls[name] = URL.createObjectURL(await res.blob());
} catch {
failed.push(name);
}
}
return { urls, failed };
}
/** The documents a repair carries, in the order the job produces them. */
const DOCUMENTS: {
label: string;
field: "repair_request" | "signed_request" | "quote" | "purchase_order";
}[] = [
{ label: "Repair request", field: "repair_request" },
{ label: "Signed copy", field: "signed_request" },
{ label: "Supplier quote", field: "quote" },
{ label: "Purchase order", field: "purchase_order" },
];
/**
* One repair, and the one thing most likely to happen next.
*
* The next-step button comes from `nextStepFor` so it can never offer a
* move the server would refuse, and a finished job shows no button at all
* rather than a dead one.
*
* Plain words for a person who does not work with repairs all day: the
* status is a word, the machine is named, and the next step is a single
* big button. Nothing on this screen is a price — the office quotes, the
* phone does not.
*/
export function RepairDetail({ repairId }: { repairId: string }) {
const [repair, setRepair] = useState<Repair | null>(null);
const [error, setError] = useState<string | null>(null);
/** Something the operator should know — the request is ready, etc. */
const [notice, setNotice] = useState<{ text: string; link?: string } | null>(
null,
);
const [busy, setBusy] = useState(false);
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
/** Photos that could not be loaded — shown as an error, not a spinner. */
const [failedPhotos, setFailedPhotos] = useState<string[]>([]);
const [photoError, setPhotoError] = useState<string | null>(null);
/** Bumped after a change, to load the repair again. */
const [reloadToken, setReloadToken] = useState(0);
const quoteRef = useRef<HTMLInputElement>(null);
const photoRef = useRef<HTMLInputElement>(null);
useEffect(() => {
let cancelled = false;
async function load() {
setError(null);
try {
const res = await fetch("/api/v1/repairs/" + repairId, {
headers: authHeaders(),
});
if (!res.ok) throw new Error("Could not load this repair.");
const data = (await res.json()) as Repair;
if (cancelled) return;
setRepair(data);
const photos = await loadPhotoUrls(repairId, data.photos ?? []);
if (cancelled) {
// The screen closed while the photos were coming in — the blob
// URLs would otherwise stay in memory for ever.
for (const url of Object.values(photos.urls)) {
URL.revokeObjectURL(url);
}
return;
}
setPhotoUrls(photos.urls);
setFailedPhotos(photos.failed);
} catch (err: unknown) {
if (!cancelled) {
setError(
err instanceof Error ? err.message : "Could not load this repair.",
);
}
}
}
void load();
return () => {
cancelled = true;
};
}, [repairId, reloadToken]);
// Revoke the photos' blob URLs when they are replaced, and when the
// screen closes. A blob URL holds the file in memory until it is
// revoked.
useEffect(() => {
return () => {
for (const url of Object.values(photoUrls)) URL.revokeObjectURL(url);
};
}, [photoUrls]);
async function takeNextStep() {
if (!repair) return;
const step = nextStepFor(repair.status);
if (!step) return;
setBusy(true);
setError(null);
setNotice(null);
try {
if (step.action === "generate_repair_request") {
const res = await fetch(
"/api/v1/repairs/" + repair.id + "/generate-repair-request",
{ method: "POST", headers: authHeaders() },
);
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(body.error ?? "Could not make the repair request.");
}
setNotice({
text: "The repair request is ready. Print it and send it with the machine.",
});
setReloadToken((n) => n + 1);
} else if (step.action === "upload_supplier_quote") {
// The button opens the file picker; the upload happens when the
// operator picks the file.
quoteRef.current?.click();
return;
} else if (
step.action === "start_work" ||
step.action === "mark_completed"
) {
const res = await fetch("/api/v1/repairs/" + repair.id, {
method: "PATCH",
headers: { "Content-Type": "application/json", ...authHeaders() },
body: JSON.stringify({ status: step.to }),
});
if (!res.ok) {
const body = (await res.json().catch(() => ({}))) as {
error?: string;
};
throw new Error(body.error ?? "Could not update this repair.");
}
setReloadToken((n) => n + 1);
} else if (step.action === "create_customer_quote") {
// The customer quote is priced in the office — this screen shows
// no prices at all — so the button says where the job goes next
// instead of pretending to do it.
setNotice({
text: "The customer quote is made in the office. Ask the office to quote this repair for the customer.",
link: "/repairs/" + repair.id,
});
}
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Something went wrong.");
} finally {
setBusy(false);
}
}
async function uploadQuote(file: File) {
if (!repair) return;
setBusy(true);
setError(null);
try {
const form = new FormData();
form.append("quote", file, file.name);
const res = await fetch("/api/v1/repairs/" + repair.id + "/files/quote", {
method: "POST",
// Multipart: the browser sets the Content-Type boundary, so only
// the session header goes here.
headers: authHeaders(),
body: form,
});
if (!res.ok) throw new Error("The quote did not upload.");
setNotice({ text: "The supplier's quote is on the repair." });
setReloadToken((n) => n + 1);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "The quote did not upload.",
);
} finally {
setBusy(false);
}
}
async function uploadPhoto(file: File) {
if (!repair) return;
setPhotoError(null);
setBusy(true);
try {
const form = new FormData();
form.append("photos", file, file.name);
const res = await fetch(
"/api/v1/repairs/" + repair.id + "/files/photos",
{
method: "POST",
// No Content-Type — the browser sets the multipart boundary.
headers: authHeaders(),
body: form,
},
);
if (!res.ok) throw new Error("The photo did not upload.");
setReloadToken((n) => n + 1);
} catch (err: unknown) {
setPhotoError(
err instanceof Error ? err.message : "The photo did not upload.",
);
} finally {
setBusy(false);
}
}
/**
* Open a document with the browser session.
*
* The file proxy requires a session and neither an `<a href>` nor an
* `<img src>` can carry an Authorization header — so the bytes are
* fetched here and handed to the browser as a blob URL.
*/
async function openDocument(filename: string) {
if (!repair) return;
try {
const res = await fetch(repairFileUrl(repair.id, filename), {
headers: authHeaders(),
});
if (!res.ok) throw new Error("Could not open the file.");
const blobUrl = URL.createObjectURL(await res.blob());
window.open(blobUrl, "_blank", "noopener,noreferrer");
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
} catch (err: unknown) {
setError(
err instanceof Error ? err.message : "Could not open the file.",
);
}
}
if (error && !repair) {
return <p className="p-4 text-sm">{error}</p>;
}
if (!repair) {
return <p className="p-4 text-sm text-muted-foreground">Loading…</p>;
}
const step = nextStepFor(repair.status);
const customerName = repair.expand?.customer?.customer ?? "";
const supplierName = repair.expand?.supplier?.suppliername ?? "";
const photoFiles = repair.photos ?? [];
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-3">
<input
ref={quoteRef}
type="file"
accept="application/pdf,image/*"
className="sr-only"
aria-label="Upload the supplier's quote"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadQuote(file);
// Let the same file be picked again.
if (quoteRef.current) quoteRef.current.value = "";
}}
/>
<div className="flex items-start justify-between gap-2">
<h1 className="text-2xl font-semibold">{repair.repair_number}</h1>
<Badge variant={REPAIR_STATUS_VARIANT[repair.status] || "secondary"}>
{REPAIR_STATUS_LABEL[repair.status] || repair.status}
</Badge>
</div>
{step ? (
<Button
type="button"
className="min-h-[56px] w-full text-base"
onClick={() => void takeNextStep()}
disabled={busy}
>
{step.label}
</Button>
) : null}
{notice ? (
<div className="rounded-md border border-primary/40 p-3 text-sm">
<p>{notice.text}</p>
{notice.link ? (
<Link href={notice.link} className="mt-1 inline-block underline">
Open this repair in the office
</Link>
) : null}
</div>
) : null}
{error ? (
<p
role="alert"
className="rounded-md border border-destructive/40 p-3 text-sm"
>
{error}
</p>
) : null}
<section className="rounded-lg border p-3">
<h2 className="font-medium">{repair.item_description}</h2>
<dl className="mt-2 space-y-1 text-sm">
<div className="flex gap-2">
<dt className="text-muted-foreground">Whose machine</dt>
<dd>{customerName}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">Serial number</dt>
<dd>{repair.serial_no}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">Customer says</dt>
<dd>{repair.fault_description}</dd>
</div>
{repair.supplier_scope ? (
<div className="flex gap-2">
<dt className="text-muted-foreground">Supplier to look at</dt>
<dd>{repair.supplier_scope}</dd>
</div>
) : null}
{supplierName ? (
<div className="flex gap-2">
<dt className="text-muted-foreground">Supplier</dt>
<dd>{supplierName}</dd>
</div>
) : null}
</dl>
</section>
<section>
<h2 className="font-medium">Photos</h2>
{photoFiles.length > 0 ? (
<ul className="mt-2 grid grid-cols-3 gap-2">
{photoFiles.map((name) => (
<li
key={name}
className="aspect-square overflow-hidden rounded border bg-muted"
>
{photoUrls[name] ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={photoUrls[name]}
alt={name}
className="h-full w-full object-cover"
/>
) : failedPhotos.includes(name) ? (
<span className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-destructive">
Could not load this photo
</span>
) : (
<span className="flex h-full w-full items-center justify-center text-[10px] text-muted-foreground">
Loading…
</span>
)}
</li>
))}
</ul>
) : (
<p className="text-sm text-muted-foreground">No photos yet</p>
)}
<div className="mt-3 space-y-2">
<Label htmlFor="repair-photo">Add a photo of the machine</Label>
<p className="text-sm text-muted-foreground">
Take a photo of the machine now, if you are still at the site.
</p>
<input
ref={photoRef}
id="repair-photo"
type="file"
accept="image/*"
capture="environment"
className="sr-only"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) void uploadPhoto(file);
// Let the same file be picked again.
if (photoRef.current) photoRef.current.value = "";
}}
/>
<Button
type="button"
variant="outline"
className="min-h-[56px] w-full text-base"
onClick={() => photoRef.current?.click()}
disabled={busy}
>
Take a photo
</Button>
</div>
{photoError ? (
<p role="alert" className="mt-2 text-sm text-destructive">
{photoError}
</p>
) : null}
</section>
<section>
<h2 className="font-medium">Documents</h2>
<ul className="mt-1 space-y-2 text-sm">
{DOCUMENTS.map((doc) => {
const files = repair[doc.field] ?? [];
return (
<li key={doc.field}>
<p className="font-medium">{doc.label}</p>
{files.length === 0 ? (
<p className="text-muted-foreground">None yet</p>
) : (
<ul className="mt-1 space-y-1">
{files.map((name) => (
<li key={name}>
<button
type="button"
onClick={() => void openDocument(name)}
className="text-left underline"
>
{name}
</button>
</li>
))}
</ul>
)}
</li>
);
})}
</ul>
</section>
<section>
<h2 className="font-medium">Progress</h2>
<ol className="mt-2 space-y-1">
{REPAIR_STATUS.map((s: RepairStatus) => (
<li
key={s}
className={
s === repair.status
? "font-medium"
: "text-sm text-muted-foreground"
}
>
{REPAIR_STATUS_LABEL[s]}
{s === repair.status ? " — now" : ""}
</li>
))}
</ol>
</section>
</div>
);
}