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 (
+
+ );
+}
diff --git a/src/app/(repairs-app)/repairs-app/book-in/page.tsx b/src/app/(repairs-app)/repairs-app/book-in/page.tsx
new file mode 100644
index 0000000..8f1755a
--- /dev/null
+++ b/src/app/(repairs-app)/repairs-app/book-in/page.tsx
@@ -0,0 +1,5 @@
+import { BookInForm } from "../book-in-form";
+
+export default function BookInPage() {
+ return ;
+}