From 63ef01f6042b4297a8eabd6bb8d0cca620cdf078 Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 6 Nov 2026 18:50:55 +0200 Subject: [PATCH] feat(repairs-app): the repair detail with one obvious next step --- .../(repairs-app)/repairs-app/[id]/page.tsx | 10 + .../repairs-app/[id]/repair-detail.test.tsx | 243 +++++++++ .../repairs-app/[id]/repair-detail.tsx | 509 ++++++++++++++++++ 3 files changed, 762 insertions(+) create mode 100644 src/app/(repairs-app)/repairs-app/[id]/page.tsx create mode 100644 src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx create mode 100644 src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx diff --git a/src/app/(repairs-app)/repairs-app/[id]/page.tsx b/src/app/(repairs-app)/repairs-app/[id]/page.tsx new file mode 100644 index 0000000..b365f69 --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/[id]/page.tsx @@ -0,0 +1,10 @@ +import { RepairDetail } from "./repair-detail"; + +export default async function RepairDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + return ; +} diff --git a/src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx new file mode 100644 index 0000000..de8631d --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx @@ -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 = 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; + 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).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(); + 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(); + await waitFor(() => + expect(screen.getAllByText(/With supplier/).length).toBeGreaterThan(0), + ); + }); + + it("offers exactly one next-step button, named for the action", async () => { + render(); + 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(); + 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(); + 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 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); + }); +}); diff --git a/src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx new file mode 100644 index 0000000..e1d609f --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx @@ -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 { + 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 `` 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; failed: string[] }> { + const urls: Record = {}; + 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(null); + const [error, setError] = useState(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>({}); + /** Photos that could not be loaded — shown as an error, not a spinner. */ + const [failedPhotos, setFailedPhotos] = useState([]); + const [photoError, setPhotoError] = useState(null); + /** Bumped after a change, to load the repair again. */ + const [reloadToken, setReloadToken] = useState(0); + + const quoteRef = useRef(null); + const photoRef = useRef(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 `` nor an + * `` 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

{error}

; + } + if (!repair) { + return

Loading…

; + } + + const step = nextStepFor(repair.status); + const customerName = repair.expand?.customer?.customer ?? ""; + const supplierName = repair.expand?.supplier?.suppliername ?? ""; + const photoFiles = repair.photos ?? []; + + return ( +
+ { + const file = e.target.files?.[0]; + if (file) void uploadQuote(file); + // Let the same file be picked again. + if (quoteRef.current) quoteRef.current.value = ""; + }} + /> + +
+

{repair.repair_number}

+ + {REPAIR_STATUS_LABEL[repair.status] || repair.status} + +
+ + {step ? ( + + ) : null} + + {notice ? ( +
+

{notice.text}

+ {notice.link ? ( + + Open this repair in the office + + ) : null} +
+ ) : null} + + {error ? ( +

+ {error} +

+ ) : null} + +
+

{repair.item_description}

+
+
+
Whose machine
+
{customerName}
+
+
+
Serial number
+
{repair.serial_no}
+
+
+
Customer says
+
{repair.fault_description}
+
+ {repair.supplier_scope ? ( +
+
Supplier to look at
+
{repair.supplier_scope}
+
+ ) : null} + {supplierName ? ( +
+
Supplier
+
{supplierName}
+
+ ) : null} +
+
+ +
+

Photos

+ {photoFiles.length > 0 ? ( +
    + {photoFiles.map((name) => ( +
  • + {photoUrls[name] ? ( + // eslint-disable-next-line @next/next/no-img-element + {name} + ) : failedPhotos.includes(name) ? ( + + Could not load this photo + + ) : ( + + Loading… + + )} +
  • + ))} +
+ ) : ( +

No photos yet

+ )} + +
+ +

+ Take a photo of the machine now, if you are still at the site. +

+ { + const file = e.target.files?.[0]; + if (file) void uploadPhoto(file); + // Let the same file be picked again. + if (photoRef.current) photoRef.current.value = ""; + }} + /> + +
+ {photoError ? ( +

+ {photoError} +

+ ) : null} +
+ +
+

Documents

+
    + {DOCUMENTS.map((doc) => { + const files = repair[doc.field] ?? []; + return ( +
  • +

    {doc.label}

    + {files.length === 0 ? ( +

    None yet

    + ) : ( +
      + {files.map((name) => ( +
    • + +
    • + ))} +
    + )} +
  • + ); + })} +
+
+ +
+

Progress

+
    + {REPAIR_STATUS.map((s: RepairStatus) => ( +
  1. + {REPAIR_STATUS_LABEL[s]} + {s === repair.status ? " — now" : ""} +
  2. + ))} +
+
+
+ ); +}