diff --git a/src/app/(repairs-app)/repairs-app/book-in-form.test.tsx b/src/app/(repairs-app)/repairs-app/book-in-form.test.tsx new file mode 100644 index 0000000..8015807 --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/book-in-form.test.tsx @@ -0,0 +1,68 @@ +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("@/lib/pocketbase", () => ({ + getPocketBase: () => ({ authStore: { token: "t" } }), +})); + +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(); + }); +}); diff --git a/src/app/(repairs-app)/repairs-app/book-in-form.tsx b/src/app/(repairs-app)/repairs-app/book-in-form.tsx new file mode 100644 index 0000000..9688e71 --- /dev/null +++ b/src/app/(repairs-app)/repairs-app/book-in-form.tsx @@ -0,0 +1,379 @@ +"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 { getPocketBase } from "@/lib/pocketbase"; +import type { Customer, Supplier } from "@/lib/types"; + +/** Ten photos is plenty for one booking. */ +const MAX_PHOTOS = 10; + +type Lookups = { customers: Customer[]; suppliers: Supplier[] }; + +/** + * 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 and + * the phone app's status board. + */ +function authHeaders(): Record { + const token = getPocketBase().authStore.token; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +/** + * 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. + * + * Plain words, one thing at a time. The person using this is standing at + * the customer's site and is not technical, so every field says what it + * wants and the photo button opens the camera. + */ +export function BookInForm() { + const router = useRouter(); + const fileRef = useRef(null); + + const [customerId, setCustomerId] = useState(""); + const [machine, setMachine] = useState(""); + const [serialNo, setSerialNo] = useState(""); + const [fault, setFault] = useState(""); + const [supplierScope, setSupplierScope] = useState(""); + const [supplierId, setSupplierId] = useState(""); + const [photos, setPhotos] = useState([]); + /** A problem with the photos themselves — shown with the photos. */ + const [photoError, setPhotoError] = useState(null); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + + /** + * The customer and supplier lists load when the operator asks for + * them, not when the screen opens. A phone on a bad connection should + * not pay for 500 customers before it can be used, and the repairs + * person may only need the supplier list. + */ + const [lookups, setLookups] = useState({ + customers: [], + suppliers: [], + }); + const [lookupsLoaded, setLookupsLoaded] = useState(false); + const [lookupsLoading, setLookupsLoading] = useState(false); + const [lookupsError, setLookupsError] = useState(null); + + /** + * Set the moment the repair exists. If a photo then fails to upload + * the booking is NOT lost — but it must not be booked in a second + * time either, so the submit button stops working from here on. + */ + const [createdRepairId, setCreatedRepairId] = useState(null); + + async function loadLookups() { + setLookupsLoading(true); + setLookupsError(null); + try { + const res = await fetch("/api/v1/repairs/form-data", { + headers: authHeaders(), + }); + if (!res.ok) { + throw new Error("Could not load the customer list."); + } + const data = (await res.json()) as Partial; + setLookups({ + customers: data.customers ?? [], + suppliers: data.suppliers ?? [], + }); + setLookupsLoaded(true); + } catch (err: unknown) { + setLookupsError( + err instanceof Error + ? err.message + : "Could not load the customer list.", + ); + } finally { + setLookupsLoading(false); + } + } + + function onPhotosPicked(e: React.ChangeEvent) { + const picked = Array.from(e.target.files ?? []); + const photosOnly = picked.filter((file) => file.type.startsWith("image/")); + setPhotoError( + photosOnly.length !== picked.length + ? "Only photos can be added here." + : null, + ); + setPhotos((prev) => [...prev, ...photosOnly].slice(0, MAX_PHOTOS)); + // Let the same file be picked again — phones keep the old value. + if (fileRef.current) fileRef.current.value = ""; + } + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + + // Already booked in — a second submit would book the machine in twice. + if (createdRepairId) return; + + // The photo is checked first. It is the one thing that cannot be + // added later without losing the record of how the machine arrived. + if (photos.length === 0) + return setError("Add at least one photo of the machine."); + 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."); + + setSaving(true); + try { + const res = await fetch("/api/v1/repairs", { + method: "POST", + headers: { "Content-Type": "application/json", ...authHeaders() }, + body: JSON.stringify({ + customer: customerId || null, + item_description: machine.trim(), + serial_no: serialNo.trim(), + fault_description: fault.trim(), + supplier_scope: supplierScope.trim() || null, + supplier: supplierId || null, + }), + }); + 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 }; + setCreatedRepairId(repair.id); + + // 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", + // No Content-Type — the browser sets the multipart boundary. + headers: authHeaders(), + body: form, + }, + ); + if (!up.ok) { + setError( + "The machine was booked in, but a photo did not upload. Open the repair and add it again.", + ); + return; + } + } + + 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

+ + {/* + The picker is a named group, not a bare label: the label names + the group and the group holds the control. That is what makes + "Whose machine is it?" reachable as a label for the picker. + */} +
+ + {lookupsLoaded ? ( + setCustomerId(id)} + customers={lookups.customers} + placeholder="Type the customer's name…" + /> + ) : ( + + )} + {lookupsError ? ( +

+ {lookupsError} +

+ ) : null} +
+ +
+ + setMachine(e.target.value)} + placeholder="Welding machine" + className="min-h-[56px] text-base" + /> +
+ +
+ + setSerialNo(e.target.value)} + placeholder="WM-9" + className="min-h-[56px] text-base" + /> +
+ +
+ +