From 18cfd3d015bfec90fd08e87a7b098d13a48f690e Mon Sep 17 00:00:00 2001 From: Dave Date: Fri, 6 Nov 2026 13:14:15 +0200 Subject: [PATCH] docs(plan): phase 2 of the repairs app - close the API, build the phone app --- .../plans/2026-11-06-repairs-app-phase-2.md | 1603 +++++++++++++++++ 1 file changed, 1603 insertions(+) create mode 100644 docs/superpowers/plans/2026-11-06-repairs-app-phase-2.md diff --git a/docs/superpowers/plans/2026-11-06-repairs-app-phase-2.md b/docs/superpowers/plans/2026-11-06-repairs-app-phase-2.md new file mode 100644 index 0000000..a4a4cc4 --- /dev/null +++ b/docs/superpowers/plans/2026-11-06-repairs-app-phase-2.md @@ -0,0 +1,1603 @@ +# Repairs app — Phase 2 Implementation Plan (the phone app, and closing the repairs API) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Close the anonymously-readable repairs API, then build `/repairs-app` — the standalone, installable phone app the new repairs person uses to see every repair's status and to book a machine in from the customer's site. + +**Architecture:** Task 1 puts a session check on the repairs API and the repairs file proxy, and teaches the office screens to send the session. Tasks 2–5 build a new route group `(repairs-app)` mirroring the existing `(rep)` PWA exactly: its own manifest, its own service worker, its own layout, reusing `MobileAuthGate`. Four screens — the status board, book-in, repair detail, and (in Phase 4) the chat tab. It talks to the `/api/v1/repairs/*` REST surface Phase 1 built, and renders statuses from the shared `@/lib/repair-status` vocabulary. + +**Tech Stack:** Next.js (App Router, client components), TypeScript, Tailwind, Vitest 4 + @testing-library/react (the repo already has ten `page.test.tsx` files to copy). + +**Spec:** `docs/superpowers/specs/2026-11-05-repairs-app-design.md` `4 and `Build order` phase 2. +**Phase 1 plan:** `docs/superpowers/plans/2026-11-05-repairs-app-phase-1.md` + +## Global Constraints + +- Work on a new branch `feature/repairs-phase-2` branched from `main`. Phase 1 is merged and deployed (`9358b37`); the PB fields exist. +- The phone app is **for the operator's colleague, who is not technical**. Big targets, plain words, one question at a time. Every control must be labelled. No unexplained icons. +- **Status is always text.** Colour is a second signal, never the only one. Use `REPAIR_STATUS_LABEL[status]` and `REPAIR_STATUS_VARIANT[status]` from `@/lib/repair-status` — never a locally written label. +- **No money on any phone screen.** No cost, no selling price, no margin, no markup. Supplier cost and customer price live on the office pages and the documents. +- **Every filter option shows its count, and the counts reconcile.** The seven status counts add up to the "All" count. A status with no repairs still shows, with a zero. +- **Do not hide repairs.** Filters narrow the list; they never hide a count. +- `repairs.status` is a PocketBase `select` with fixed values: `new, assessing, quoted, approved, in_progress, completed, cancelled`. +- Tests: `npx vitest run `. Typecheck: `npx tsc --noEmit` — the baseline is CLEAN, so any error you see is yours. +- Commit per task with the exact message given. Stage by explicit path — never `git add -A`. +- Follow this repo's existing conventions. Read `src/app/(rep)/rep/layout.tsx` and `public/manifest-rep.json` before you start — you are copying that pattern, not inventing one. + +## What is already in place (do not re-create it) + +- `@/lib/repair-status` — `REPAIR_STATUS`, `REPAIR_STATUS_LABEL`, `REPAIR_STATUS_VARIANT`, `REPAIR_TRANSITIONS`, `nextStepFor(status)` returning `{ label, action, to }` or `null`, `type RepairStatus`, `type RepairAction`, `interface RepairNextStep`. +- REST: `GET /api/v1/repairs?page=&perPage=&search=&status=&sort=` returns `{ items, totalItems, totalPages, page, perPage }` · `GET /api/v1/repairs/[id]` · `POST /api/v1/repairs` returns 201 with the created repair · `PATCH /api/v1/repairs/[id]` · `POST /api/v1/repairs/[id]/files/[field]` (multipart, field name = the multi-file field, appends) · `GET /api/v1/repairs/status-counts` returns `{ total, byStatus }`. +- `POST /api/v1/repairs` requires `item_description` and `fault_description` and accepts `customer`, `company`, `order`, `supplier`, `supplier_scope`, `serial_no`, `notes`, `photos`. +- Components: `CustomerSearch` from `@/components/ui/customer-search` and `SupplierSearch` from `@/components/ui/supplier-search`. +- PB fields: `repairs.supplier_scope`, `repairs.signed_request`, `orders.repair`, `giellie_chats.mode` — all live. + +## The photo upload — read this before writing the book-in form + +`photos` on create is a list of **filenames already uploaded**, not file bytes. `createRepair` just writes the strings into the PocketBase file field. So the phone flow is two steps: + +1. `POST /api/v1/repairs` with the form fields and NO photos. +2. For each photo, `POST /api/v1/repairs/{id}/files/photos` as multipart, with the form field name literally `photos` and the file under it. The route APPENDS, so upload them one at a time. + +Do not try to base64 the photos into the create call. + +--- + +### Task 1: Close the repairs API + +**Files:** +- Modify: `src/app/api/v1/repairs/route.ts` +- Modify: `src/app/api/v1/repairs/[id]/route.ts` +- Modify: `src/app/api/v1/repairs/[id]/files/[field]/route.ts` +- Modify: `src/app/api/v1/repairs/[id]/create-customer-quote/route.ts` +- Modify: `src/app/api/v1/repairs/[id]/generate-repair-request/route.ts` +- Modify: `src/app/api/v1/repairs/form-data/route.ts` +- Modify: `src/app/api/v1/repairs/status-counts/route.ts` +- Modify: `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts` +- Modify: `src/app/(app)/repairs/page.tsx` +- Modify: `src/app/(app)/repairs/[id]/page.tsx` +- Test: `src/app/api/v1/repairs/repair-routes.test.ts` (extend) + +**Interfaces:** +- Consumes: `assertBrowserSession(req)` from `@/lib/server/browser-session`; `useAuth()` from `@/components/auth-provider`. +- Produces: every repairs route returns **401 without a browser session**, and the office screens keep working. + +**Why this task exists.** The reviews proved the repairs API is anonymously readable on production: repair records (customer addresses, serial numbers, fault descriptions) and files (photos, the repair request PDF, the supplier quote PDF *with pricing*) all return HTTP 200 to a caller with no credentials. The MCP tools are correctly gated by `assertAgentAuth` — this is a REST-only hole, and the phone app would sit right on top of it. + +**The plumbing, so you do not break the office screens:** +- `assertBrowserSession(req)` reads the **`authorization`** header and validates it against PocketBase's `auth-refresh`. +- The browser's global fetch wrapper (`src/lib/audit-fetch.ts`) adds a DIFFERENT header, `x-wfdash-auth`, for audit attribution only. It is not route auth and no Next.js route reads it. +- Authenticated pages therefore send the token explicitly. Copy the pattern from `src/app/(app)/delivery-pool/page.tsx` — it returns an Authorization header built from the token — and from `src/app/(app)/search/page.tsx`, which fetches with `{ headers: { Authorization: token } }`. +- READ `src/components/auth-provider.tsx` and use the EXACT token field its context exposes — do not guess the field name. + +- [ ] **Step 1: Write the failing test** + +Extend `src/app/api/v1/repairs/repair-routes.test.ts`. Its existing three tests call the routes without a session, so they will start failing — that is expected and correct. Rewrite them to pass a session, and add the 401 cases. + +Mock `@/lib/server/browser-session` at the top of the file: + +```ts +const sessionMock = vi.fn(); +vi.mock("@/lib/server/browser-session", () => ({ + assertBrowserSession: (...args: unknown[]) => sessionMock(...args), + getBrowserSessionUser: (...args: unknown[]) => sessionMock(...args), +})); +``` + +Then in each test set `sessionMock.mockResolvedValue({ id: "u1", name: "Dave", user_type: ["ADMIN"] })` before the call, and add: + +```ts +describe("repairs routes require a session", () => { + beforeEach(() => { + vi.clearAllMocks(); + sessionMock.mockRejectedValue( + Object.assign(new Error("Not authenticated."), { status: 401 }), + ); + }); + + it("rejects a list request with no session", async () => { + const res = await createRoute(req("http://localhost/api/v1/repairs")); + expect(res.status).toBe(401); + }); + + it("rejects a create request with no session", async () => { + const res = await createRoute( + req("http://localhost/api/v1/repairs", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + item_description: "Welding machine", + fault_description: "Will not strike", + }), + }), + ); + expect(res.status).toBe(401); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run src/app/api/v1/repairs/repair-routes.test.ts` + +Expected: FAIL — the routes still answer without a session. + +- [ ] **Step 3: Add the session check to every repairs route** + +In EACH of the seven repairs route files, add the import: + +```ts +import { assertBrowserSession } from "@/lib/server/browser-session"; +``` + +and call it as the FIRST thing in every exported handler, before any body parsing or PocketBase work: + +```ts + await assertBrowserSession(req); +``` + +Use `assertBrowserSession` — it discards the user, and none of these handlers needs it. + +Where a handler already has a `try/catch` that turns errors into a JSON response, make sure a 401 is NOT swallowed into a 500: the `assertBrowserSession` failure carries `status: 401`, so re-throw it or return it with that status. Check what the handler's catch block currently does with an error that has a `status` property and make it honour `status` rather than forcing 500. + +- [ ] **Step 4: Close the file proxy for repairs** + +The proxy at `src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts` already whitelists the `repairs` collection, so an anonymous caller can fetch a repair's photos and PDFs by id. + +Add a session requirement **for the repairs collection only** — every other collection's files keep working exactly as they do today, because changing them app-wide is a much bigger blast radius and is not this task. + +Inside the handler, before the PocketBase fetch: + +```ts + if (collection === "repairs" || collection === "pbc_2000000004") { + await assertBrowserSession(req); + } +``` + +Use the real repairs collection id from the constant block at the top of that file if it differs, and keep both spellings (name and id) in the check because the SDK accepts either. + +- [ ] **Step 5: Make the office screens send the session** + +In `src/app/(app)/repairs/page.tsx` and `src/app/(app)/repairs/[id]/page.tsx`, every fetch to `/api/v1/repairs*` must carry the browser token. + +Read the token from `useAuth()` — the same hook `MobileAuthGate` uses. Add a small helper in each page, matching the repo's existing shape: + +```ts + const { token } = useAuth(); + const authHeaders = () => (token ? { Authorization: "Bearer " + token } : {}); +``` + +Then add `...authHeaders()` to each fetch's `headers`. The existing calls are in these places: +- `page.tsx`: `GET /api/v1/repairs/form-data`, `GET /api/v1/repairs?...`, `POST /api/v1/repairs`, `POST /api/v1/repairs/{id}/files/photos` +- `[id]/page.tsx`: `GET /api/v1/repairs/{id}`, the `PATCH /api/v1/repairs/{id}`, and any file upload or delete it does. + +A `POST` with a JSON body must MERGE the auth header rather than replace `Content-Type`: + +```ts + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify(payload), + }); +``` + +A multipart upload must NOT set `Content-Type` — the browser sets the boundary. Put the auth header in the `FormData` fetch's headers and leave `Content-Type` alone: + +```ts + const res = await fetch(url, { method: "POST", headers: authHeaders(), body: form }); +``` + +- [ ] **Step 6: Run the tests** + +Run: `npx vitest run src/app/api/v1/repairs "src/app/(app)/repairs"` + +Expected: PASS. The three original route tests plus the new 401 cases. + +- [ ] **Step 7: Typecheck** + +Run: `npx tsc --noEmit` + +Expected: 0 errors. If `useAuth()` does not expose a `token`, read `src/components/auth-provider.tsx` and use the field it does expose. + +- [ ] **Step 8: Commit** + +```bash +git add src/app/api/v1/repairs src/app/api/v1/files "src/app/(app)/repairs" +git commit -m "fix(repairs): require a browser session on the repairs API and its files" +``` + +--- + +### Task 2: The shell — route group, manifest, service worker, tab bar + +**Files:** +- Create: `src/app/(repairs-app)/repairs-app/layout.tsx` +- Create: `src/app/(repairs-app)/repairs-app/tab-bar.tsx` +- Create: `public/manifest-repairs-app.json` +- Create: `public/sw-repairs-app.js` +- Modify: `src/components/service-worker-registration.tsx` +- Test: `src/app/(repairs-app)/repairs-app/tab-bar.test.tsx` + +**Interfaces:** +- Consumes: `MobileAuthGate` from `@/app/(mobile)/m/auth-gate`. +- Produces: the route `/repairs-app` and its sub-routes, ``, the manifest `/manifest-repairs-app.json`, the service worker `/sw-repairs-app.js`. + +- [ ] **Step 1: Write the failing test** + +Create `src/app/(repairs-app)/repairs-app/tab-bar.test.tsx`: + +```tsx +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { RepairsTabBar } from "./tab-bar"; + +vi.mock("next/navigation", () => ({ + usePathname: () => "/repairs-app", +})); + +describe("RepairsTabBar", () => { + it("shows the Repairs tab as a labelled link to the board", () => { + render(); + const link = screen.getByRole("link", { name: /repairs/i }); + expect(link).toHaveAttribute("href", "/repairs-app"); + }); + + it("has no dead controls — every element is a link", () => { + render(); + expect(screen.queryAllByRole("button")).toHaveLength(0); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run "src/app/(repairs-app)/repairs-app/tab-bar.test.tsx"` + +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Write the tab bar** + +Create `src/app/(repairs-app)/repairs-app/tab-bar.tsx`: + +```tsx +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +/** + * The phone app's only navigation. There is no sidebar and no office + * chrome in here — this is a standalone app for one job. + * + * Phase 2 ships the Repairs tab only. Phase 4 adds the "Ask Giellie" + * tab; the tabs array is the single place that changes. A tab that does + * nothing yet would be an unexplained control, and this app's whole + * point is that every control means something. + */ +const TABS = [{ href: "/repairs-app", label: "Repairs" }]; + +export function RepairsTabBar() { + const pathname = usePathname(); + return ( + + ); +} +``` + +- [ ] **Step 4: Write the layout** + +Create `src/app/(repairs-app)/repairs-app/layout.tsx`, modelled on `src/app/(rep)/rep/layout.tsx`: + +```tsx +import type { Metadata, Viewport } from "next"; +import type { ReactNode } from "react"; +import { MobileAuthGate } from "@/app/(mobile)/m/auth-gate"; +import { RepairsTabBar } from "./tab-bar"; + +/** + * Repairs phone-app shell. + * + * The fourth instance of the per-route-group PWA pattern already running + * here (`/m` boss phone, `/counter` till, `/rep` rep visits), so it + * reuses `MobileAuthGate` and gets its own manifest + service worker. + * + * Deliberately not in the sidebar — matching `/m`, `/counter` and `/rep`. + * The repairs person installs this on their home screen and never + * navigates to it from a menu. + */ +export const metadata: Metadata = { + title: "WFDASH — Repairs", + description: "Track repair jobs and book machines in.", + manifest: "/manifest-repairs-app.json", + icons: { apple: "/apple-touch-icon.png" }, + appleWebApp: { + capable: true, + statusBarStyle: "default", + title: "Repairs", + }, + other: { + // iOS only honours the apple-prefixed name. Without this tag the + // home-screen icon opens as a plain Safari bookmark instead of a + // standalone app. + "apple-mobile-web-app-capable": "yes", + }, +}; + +export const viewport: Viewport = { + width: "device-width", + initialScale: 1, + viewportFit: "cover", + themeColor: "#E85D4C", +}; + +export default function RepairsAppLayout({ + children, +}: { + children: ReactNode; +}) { + return ( + +
+ {children} + +
+
+ ); +} +``` + +- [ ] **Step 5: Write the manifest** + +Create `public/manifest-repairs-app.json`, modelled on `public/manifest-rep.json`: + +```json +{ + "name": "WFDASH — Repairs", + "short_name": "Repairs", + "description": "Track repair jobs and book machines in.", + "start_url": "/repairs-app", + "scope": "/repairs-app", + "display": "standalone", + "orientation": "portrait", + "background_color": "#FDF6F5", + "theme_color": "#E85D4C", + "icons": [ + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any" + }, + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any" + } + ] +} +``` + +- [ ] **Step 6: Write the service worker** + +Create `public/sw-repairs-app.js`, modelled on `public/sw-rep.js` but WITHOUT the offline machinery — this person always has signal: + +```js +// Service worker for the /repairs-app PWA. +// +// A shell cache, NOT an offline app. The repairs person always has +// signal, so live data must never be served stale: the fetch handler +// ignores /api/ entirely and only the shell is cached. +const CACHE_NAME = "wfdash-repairs-app-v1"; + +const PRECACHE_ASSETS = [ + "/repairs-app", + "/manifest-repairs-app.json", + "/offline", + "/android-chrome-192x192.png", + "/android-chrome-512x512.png", + "/apple-touch-icon.png", +]; + +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_ASSETS)) + ); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all( + keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k)) + ) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const { request } = event; + if (request.method !== "GET") return; + + const url = new URL(request.url); + if (url.origin !== self.location.origin) return; + // Never serve API data from the cache — a stale repair list is worse + // than no repair list. + if (url.pathname.startsWith("/api/")) return; + + event.respondWith( + fetch(request).catch(() => + caches.match(request).then((cached) => cached || caches.match("/offline")) + ) + ); +}); +``` + +- [ ] **Step 7: Register the service worker** + +In `src/components/service-worker-registration.tsx`, add a branch to `pickSwPath()` **above** the `/rep` check: + +```ts + if (path === "/repairs-app" || path.startsWith("/repairs-app/")) { + return "/sw-repairs-app.js"; + } +``` + +The `/rep` branch uses `path.startsWith("/rep/")`, which does **not** match `/repairs-app` — the slash is what separates them. Update the comment block at the top of the file to list `/repairs-app` → `/sw-repairs-app.js` alongside the others. + +- [ ] **Step 8: Run the test, typecheck, commit** + +Run: `npx vitest run "src/app/(repairs-app)/repairs-app/tab-bar.test.tsx"` — Expected: PASS, 2 tests. +Run: `npx tsc --noEmit` — Expected: 0 errors. + +```bash +git add "src/app/(repairs-app)/repairs-app/layout.tsx" "src/app/(repairs-app)/repairs-app/tab-bar.tsx" "src/app/(repairs-app)/repairs-app/tab-bar.test.tsx" public/manifest-repairs-app.json public/sw-repairs-app.js src/components/service-worker-registration.tsx +git commit -m "feat(repairs-app): the phone app shell, manifest and service worker" +``` + +### Task 3: The status board — see every repair's status at a glance + +**Files:** +- Create: `src/app/(repairs-app)/repairs-app/page.tsx` +- Create: `src/app/(repairs-app)/repairs-app/repair-board.tsx` +- Test: `src/app/(repairs-app)/repairs-app/repair-board.test.tsx` + +**Interfaces:** +- Consumes: `REPAIR_STATUS`, `REPAIR_STATUS_LABEL`, `REPAIR_STATUS_VARIANT` from `@/lib/repair-status`; `useAuth()` for the session header; `GET /api/v1/repairs` and `GET /api/v1/repairs/status-counts`. +- Produces: the board screen at `/repairs-app`, and a `` client component. + +- [ ] **Step 1: Write the failing test** + +Create `src/app/(repairs-app)/repairs-app/repair-board.test.tsx`: + +```tsx +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { RepairBoard } from "./repair-board"; + +vi.mock("@/components/auth-provider", () => ({ + useAuth: () => ({ token: "t", user: { id: "u1" }, loading: false }), +})); + +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", + customer: { name: "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" } }, + ); + }), + ); +} + +describe("RepairBoard", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + mockFetch(); + }); + + it("shows every status filter with its count", async () => { + render(); + await waitFor(() => expect(screen.getByText(/Booked in/)).toBeTruthy()); + expect(screen.getByText(/With supplier/)).toBeTruthy(); + expect(screen.getByText(/Quote in/)).toBeTruthy(); + expect(screen.getByText(/On the bench/)).toBeTruthy(); + expect(screen.getByText(/Done/)).toBeTruthy(); + expect(screen.getByText(/Cancelled/)).toBeTruthy(); + }); + + it("shows the status as text, not as colour alone", async () => { + render(); + await waitFor(() => screen.getByText(/With supplier/)); + expect(screen.getAllByText(/With supplier/).length).toBeGreaterThan(0); + }); + + it("never renders a price, a cost or a margin", async () => { + render(); + await waitFor(() => screen.getByText(/With supplier/)); + 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 () => { + render(); + await waitFor(() => screen.getByText(/Hydraulic press, 40T/)); + expect(screen.getByText(/Borer Plant/)).toBeTruthy(); + expect(screen.getByText(/HP-2211/)).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run "src/app/(repairs-app)/repairs-app/repair-board.test.tsx"` + +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Write the board** + +Create `src/app/(repairs-app)/repairs-app/repair-board.tsx`: + +```tsx +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { Badge } from "@/components/ui/badge"; +import { useAuth } from "@/components/auth-provider"; +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 }; + +const EMPTY_COUNTS: Counts = { + total: 0, + byStatus: { + new: 0, + assessing: 0, + quoted: 0, + approved: 0, + in_progress: 0, + completed: 0, + cancelled: 0, + }, +}; + +/** + * 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 { token } = useAuth(); + const [repairs, setRepairs] = useState([]); + const [counts, setCounts] = useState(EMPTY_COUNTS); + const [status, setStatus] = useState(""); + const [search, setSearch] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 = token ? { Authorization: "Bearer " + token } : {}; + 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: Repair[] }; + 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, token]); + + 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 ( +
+

Repairs

+ + setSearch(e.target.value)} + className="min-h-[48px] w-full rounded-md border bg-background px-3 text-base" + /> + +
+ {chips.map((chip) => { + const active = chip.key === status; + return ( + + ); + })} +
+ + {error ? ( +

+ {error} +

+ ) : null} + + {loading ? ( +

Loading repairs…

+ ) : null} + + {!loading && !error && repairs.length === 0 ? ( +

+ No repairs match this filter. +

+ ) : null} + +
    + {repairs.map((repair) => ( +
  • + +
    + {repair.repair_number} + + {REPAIR_STATUS_LABEL[repair.status] || repair.status} + +
    +

    {repair.item_description}

    +

    + {customerName(repair)} + {repair.serial_no ? " · S/N " + repair.serial_no : ""} +

    +

    + {photoCount(repair) > 0 + ? photoCount(repair) + + " photo" + + (photoCount(repair) === 1 ? "" : "s") + : "No photos"} + {supplierName(repair) ? " · " + supplierName(repair) : ""} +

    + +
  • + ))} +
+ +
+ + Book in a machine + +
+
+ ); +} + +function customerName(repair: Repair): string { + const c = repair.customer as { name?: string } | null | undefined; + return c?.name ?? ""; +} + +function supplierName(repair: Repair): string { + const s = repair.supplier as { suppliername?: string } | null | undefined; + return s?.suppliername ?? ""; +} + +function photoCount(repair: Repair): number { + return Array.isArray(repair.photos) ? repair.photos.length : 0; +} +``` + +- [ ] **Step 4: Write the page** + +Create `src/app/(repairs-app)/repairs-app/page.tsx`: + +```tsx +import { RepairBoard } from "./repair-board"; + +export default function RepairsAppPage() { + return ; +} +``` + +- [ ] **Step 5: Run the test, typecheck, commit** + +Run: `npx vitest run "src/app/(repairs-app)/repairs-app/repair-board.test.tsx"` — Expected: PASS, 4 tests. +Run: `npx tsc --noEmit` — Expected: 0 errors. If `Badge` does not exist under `@/components/ui`, read the office repairs page and use whatever it uses. + +```bash +git add "src/app/(repairs-app)/repairs-app/page.tsx" "src/app/(repairs-app)/repairs-app/repair-board.tsx" "src/app/(repairs-app)/repairs-app/repair-board.test.tsx" +git commit -m "feat(repairs-app): the status board with reconciling counts" +``` + +--- + +### Task 4: Book in — capture the machine, the fault and the photos + +**Files:** +- Create: `src/app/(repairs-app)/repairs-app/book-in/page.tsx` +- Create: `src/app/(repairs-app)/repairs-app/book-in-form.tsx` +- Test: `src/app/(repairs-app)/repairs-app/book-in-form.test.tsx` + +**Interfaces:** +- Consumes: `CustomerSearch`, `SupplierSearch`, `useAuth()`, `POST /api/v1/repairs`, `POST /api/v1/repairs/[id]/files/photos`. +- Produces: the booking screen at `/repairs-app/book-in`. + +- [ ] **Step 1: Write the failing test** + +Create `src/app/(repairs-app)/repairs-app/book-in-form.test.tsx`: + +```tsx +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { BookInForm } from "./book-in-form"; + +vi.mock("next/navigation", () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn() }), +})); + +vi.mock("@/components/auth-provider", () => ({ + useAuth: () => ({ token: "t", user: { id: "u1" }, loading: false }), +})); + +vi.mock("@/components/ui/customer-search", () => ({ + CustomerSearch: () =>
, +})); + +vi.mock("@/components/ui/supplier-search", () => ({ + SupplierSearch: () =>
, +})); + +describe("BookInForm", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + }); + + it("labels every field in plain words", () => { + render(); + expect(screen.getByLabelText(/whose machine/i)).toBeTruthy(); + expect(screen.getByLabelText(/what is the machine/i)).toBeTruthy(); + expect(screen.getByLabelText(/serial number/i)).toBeTruthy(); + expect( + screen.getByLabelText(/what does the customer say is wrong/i), + ).toBeTruthy(); + expect( + screen.getByLabelText(/what must the supplier look at/i), + ).toBeTruthy(); + }); + + it("offers a camera button that takes photos", () => { + render(); + const input = screen.getByLabelText( + /photos of the machine/i, + ) as HTMLInputElement; + expect(input.getAttribute("accept")).toContain("image/"); + expect(input.getAttribute("capture")).toBe("environment"); + }); + + it("will not submit without a photo", async () => { + const post = vi.fn(); + vi.stubGlobal("fetch", vi.fn(post)); + render(); + + fireEvent.change(screen.getByLabelText(/what is the machine/i), { + target: { value: "Welding machine" }, + }); + fireEvent.change(screen.getByLabelText(/serial number/i), { + target: { value: "WM-9" }, + }); + fireEvent.change( + screen.getByLabelText(/what does the customer say is wrong/i), + { target: { value: "Will not strike" } }, + ); + fireEvent.click(screen.getByRole("button", { name: /^book in$/i })); + + await waitFor(() => expect(post).not.toHaveBeenCalled()); + expect(screen.getByText(/at least one photo/i)).toBeTruthy(); + }); +}); +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `npx vitest run "src/app/(repairs-app)/repairs-app/book-in-form.test.tsx"` + +Expected: FAIL — the module does not exist. + +- [ ] **Step 3: Write the form** + +Create `src/app/(repairs-app)/repairs-app/book-in-form.tsx`: + +```tsx +"use client"; + +import { useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; +import { CustomerSearch } from "@/components/ui/customer-search"; +import { SupplierSearch } from "@/components/ui/supplier-search"; +import { useAuth } from "@/components/auth-provider"; + +const MAX_PHOTOS = 10; + +/** + * Booking a machine in. + * + * The five things this form exists to capture: whose machine it is, what + * it is, its serial number, what the customer says is wrong, and a photo + * of the machine as it arrived. "What the supplier must look at" is + * optional — the repairs person may not know yet. + */ +export function BookInForm() { + const router = useRouter(); + const { token } = useAuth(); + const fileRef = useRef(null); + + const [customerId, setCustomerId] = useState(null); + const [customerLabel, setCustomerLabel] = useState(""); + const [machine, setMachine] = useState(""); + const [serialNo, setSerialNo] = useState(""); + const [fault, setFault] = useState(""); + const [supplierScope, setSupplierScope] = useState(""); + const [supplierId, setSupplierId] = useState(null); + const [photos, setPhotos] = useState([]); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + const authHeaders = () => (token ? { Authorization: "Bearer " + token } : {}); + + function onPhotosPicked(e: React.ChangeEvent) { + const picked = Array.from(e.target.files ?? []); + setPhotos((prev) => [...prev, ...picked].slice(0, MAX_PHOTOS)); + if (fileRef.current) fileRef.current.value = ""; + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + if (!customerId) return setError("Choose whose machine it is."); + if (!machine.trim()) return setError("Say what the machine is."); + if (!serialNo.trim()) return setError("Add the machine's serial number."); + if (!fault.trim()) + return setError("Say what the customer says is wrong."); + if (photos.length === 0) + return setError("Add at least one photo of the machine."); + + setSaving(true); + try { + const res = await fetch("/api/v1/repairs", { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + customer: customerId, + item_description: machine.trim(), + serial_no: serialNo.trim(), + fault_description: fault.trim(), + supplier_scope: supplierScope.trim() || null, + supplier: supplierId, + }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + error?: string; + }; + throw new Error(body.error ?? "Could not book the machine in."); + } + const repair = (await res.json()) as { id: string }; + + // Photos are uploaded one at a time — the route APPENDS to the + // multi-file field, and it takes real bytes, not filenames. + for (const photo of photos) { + const form = new FormData(); + form.append("photos", photo, photo.name); + const up = await fetch( + "/api/v1/repairs/" + repair.id + "/files/photos", + { method: "POST", headers: authHeaders(), body: form }, + ); + if (!up.ok) { + throw new Error( + "The machine was booked in, but a photo did not upload. Open the repair and add it again.", + ); + } + } + + router.push("/repairs-app/" + repair.id); + } catch (err: unknown) { + setError( + err instanceof Error ? err.message : "Could not book the machine in.", + ); + } finally { + setSaving(false); + } + } + + return ( +
+

Book in a machine

+ +
+ + {customerId ? ( +

+ {customerLabel}{" "} + +

+ ) : ( + { + setCustomerId(c.id); + setCustomerLabel(c.name); + }} + /> + )} +
+ +
+ + setMachine(e.target.value)} + placeholder="Welding machine" + /> +
+ +
+ + setSerialNo(e.target.value)} + placeholder="WM-9" + /> +
+ +
+ +