feat(repairs-app): book a machine in, with photos

This commit is contained in:
Dave 2026-11-06 17:53:42 +02:00
parent 13d887eae8
commit 0d9848e90e
3 changed files with 452 additions and 0 deletions

View file

@ -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: () => <div data-testid="customer-search" />,
}));
vi.mock("@/components/ui/supplier-search", () => ({
SupplierSearch: () => <div data-testid="supplier-search" />,
}));
describe("BookInForm", () => {
beforeEach(() => {
vi.unstubAllGlobals();
});
it("labels every field in plain words", () => {
render(<BookInForm />);
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(<BookInForm />);
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(<BookInForm />);
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();
});
});

View file

@ -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<string, string> {
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<HTMLInputElement>(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<File[]>([]);
/** A problem with the photos themselves — shown with the photos. */
const [photoError, setPhotoError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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<Lookups>({
customers: [],
suppliers: [],
});
const [lookupsLoaded, setLookupsLoaded] = useState(false);
const [lookupsLoading, setLookupsLoading] = useState(false);
const [lookupsError, setLookupsError] = useState<string | null>(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<string | null>(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<Lookups>;
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<HTMLInputElement>) {
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 (
<form
onSubmit={onSubmit}
className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-3"
>
<h1 className="text-2xl font-semibold">Book in a machine</h1>
{/*
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.
*/}
<div
className="space-y-2"
role="group"
aria-labelledby="customer-label"
>
<Label id="customer-label">Whose machine is it?</Label>
{lookupsLoaded ? (
<CustomerSearch
value={customerId}
onValueChange={(id) => setCustomerId(id)}
customers={lookups.customers}
placeholder="Type the customer's name…"
/>
) : (
<Button
type="button"
variant="outline"
className="min-h-[56px] w-full text-base"
disabled={lookupsLoading}
onClick={() => void loadLookups()}
>
{lookupsLoading ? "Loading…" : "Choose the customer"}
</Button>
)}
{lookupsError ? (
<p className="rounded-md border border-destructive/40 p-3 text-sm">
{lookupsError}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="machine">What is the machine?</Label>
<Input
id="machine"
value={machine}
onChange={(e) => setMachine(e.target.value)}
placeholder="Welding machine"
className="min-h-[56px] text-base"
/>
</div>
<div className="space-y-2">
<Label htmlFor="serial_no">Serial number</Label>
<Input
id="serial_no"
value={serialNo}
onChange={(e) => setSerialNo(e.target.value)}
placeholder="WM-9"
className="min-h-[56px] text-base"
/>
</div>
<div className="space-y-2">
<Label htmlFor="fault">What does the customer say is wrong?</Label>
<Textarea
id="fault"
value={fault}
onChange={(e) => setFault(e.target.value)}
placeholder="Will not strike"
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="supplier_scope">What must the supplier look at?</Label>
<Textarea
id="supplier_scope"
value={supplierScope}
onChange={(e) => setSupplierScope(e.target.value)}
placeholder="Strip and quote the strike mechanism"
rows={3}
/>
<p className="text-sm text-muted-foreground">
Optional. Leave it empty if you are not sure yet.
</p>
</div>
<div
className="space-y-2"
role="group"
aria-labelledby="supplier-label"
>
<Label id="supplier-label">Which supplier?</Label>
{lookupsLoaded ? (
<SupplierSearch
value={supplierId}
onValueChange={(id) => setSupplierId(id)}
suppliers={lookups.suppliers}
placeholder="Type the supplier's name…"
/>
) : (
<Button
type="button"
variant="outline"
className="min-h-[56px] w-full text-base"
disabled={lookupsLoading}
onClick={() => void loadLookups()}
>
{lookupsLoading ? "Loading…" : "Choose the supplier"}
</Button>
)}
<p className="text-sm text-muted-foreground">
Optional — the office can pick the supplier later.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="photos">Photos of the machine</Label>
<p className="text-sm text-muted-foreground">
Take a photo of the machine as it arrived.
</p>
<input
ref={fileRef}
id="photos"
type="file"
accept="image/*"
capture="environment"
multiple
className="sr-only"
onChange={onPhotosPicked}
/>
<Button
type="button"
variant="outline"
className="min-h-[56px] w-full text-base"
disabled={photos.length >= MAX_PHOTOS}
onClick={() => fileRef.current?.click()}
>
Take a photo
</Button>
{photos.length > 0 ? (
<p className="text-sm">
{photos.length} photo{photos.length === 1 ? "" : "s"} added
</p>
) : null}
{photoError ? (
<p className="text-sm text-destructive">{photoError}</p>
) : null}
</div>
{error ? (
<p
role="alert"
className="rounded-md border border-destructive/40 p-3 text-sm"
>
{error}
</p>
) : null}
{createdRepairId ? (
<Button
type="button"
variant="outline"
className="min-h-[56px] w-full text-base"
onClick={() => router.push("/repairs-app/" + createdRepairId)}
>
Open the repair
</Button>
) : null}
<Button
type="submit"
className="min-h-[56px] w-full text-base"
disabled={saving || !!createdRepairId}
>
{saving
? "Booking in…"
: createdRepairId
? "Booked in"
: "Book in"}
</Button>
</form>
);
}

View file

@ -0,0 +1,5 @@
import { BookInForm } from "../book-in-form";
export default function BookInPage() {
return <BookInForm />;
}