feat(repairs-app): book a machine in, with photos
This commit is contained in:
parent
13d887eae8
commit
0d9848e90e
3 changed files with 452 additions and 0 deletions
68
src/app/(repairs-app)/repairs-app/book-in-form.test.tsx
Normal file
68
src/app/(repairs-app)/repairs-app/book-in-form.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
379
src/app/(repairs-app)/repairs-app/book-in-form.tsx
Normal file
379
src/app/(repairs-app)/repairs-app/book-in-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/(repairs-app)/repairs-app/book-in/page.tsx
Normal file
5
src/app/(repairs-app)/repairs-app/book-in/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { BookInForm } from "../book-in-form";
|
||||
|
||||
export default function BookInPage() {
|
||||
return <BookInForm />;
|
||||
}
|
||||
Loading…
Reference in a new issue