feat(repairs-app): the repair detail with one obvious next step
This commit is contained in:
parent
c4e97c3cd5
commit
63ef01f604
3 changed files with 762 additions and 0 deletions
10
src/app/(repairs-app)/repairs-app/[id]/page.tsx
Normal file
10
src/app/(repairs-app)/repairs-app/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { RepairDetail } from "./repair-detail";
|
||||
|
||||
export default async function RepairDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
return <RepairDetail repairId={id} />;
|
||||
}
|
||||
243
src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx
Normal file
243
src/app/(repairs-app)/repairs-app/[id]/repair-detail.test.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { RepairDetail } from "./repair-detail";
|
||||
|
||||
vi.mock("@/lib/pocketbase", () => ({
|
||||
getPocketBase: () => ({ authStore: { token: "t" } }),
|
||||
}));
|
||||
|
||||
/**
|
||||
* The shape the API actually returns.
|
||||
*
|
||||
* The customer and the supplier are RELATION IDS on the repair row —
|
||||
* the names arrive under `expand` (the service expands
|
||||
* `customer,company,order,supplier`). Reading the name off the
|
||||
* relation id renders a blank line in production.
|
||||
*/
|
||||
const REPAIR = {
|
||||
id: "rep1",
|
||||
repair_number: "R-0007",
|
||||
status: "assessing",
|
||||
item_description: "Hydraulic press, 40T",
|
||||
fault_description: "Will not build pressure",
|
||||
supplier_scope: "Strip and quote",
|
||||
serial_no: "HP-2211",
|
||||
customer: "cust1",
|
||||
supplier: "sup1",
|
||||
expand: {
|
||||
customer: { id: "cust1", customer: "Borer Plant" },
|
||||
supplier: { id: "sup1", suppliername: "Bolt & Nut" },
|
||||
},
|
||||
photos: ["a.jpg"],
|
||||
repair_request: ["RR-0007.pdf"],
|
||||
quote: [],
|
||||
purchase_order: [],
|
||||
signed_request: [],
|
||||
};
|
||||
|
||||
/**
|
||||
* jsdom has no `URL.createObjectURL`. The photos are fetched with the
|
||||
* session and rendered from a blob URL, so the stub is what makes the
|
||||
* photo path testable at all.
|
||||
*/
|
||||
if (!URL.createObjectURL) {
|
||||
URL.createObjectURL = vi.fn(() => "blob:test");
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
}
|
||||
|
||||
type Call = { url: string; method: string; init: RequestInit | undefined };
|
||||
|
||||
let calls: Call[] = [];
|
||||
let currentRepair: Record<string, unknown> = REPAIR;
|
||||
|
||||
function mockFetch() {
|
||||
calls = [];
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, method: init?.method ?? "GET", init });
|
||||
return new Response(JSON.stringify(currentRepair), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** A header value, whatever shape the caller used to send it. */
|
||||
function headerValue(
|
||||
init: RequestInit | undefined,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
const headers = init?.headers;
|
||||
if (!headers) return undefined;
|
||||
if (typeof (headers as Headers).get === "function") {
|
||||
return (headers as Headers).get(name) ?? undefined;
|
||||
}
|
||||
const record = headers as Record<string, string>;
|
||||
const key = Object.keys(record).find(
|
||||
(k) => k.toLowerCase() === name.toLowerCase(),
|
||||
);
|
||||
return key === undefined ? undefined : record[key];
|
||||
}
|
||||
|
||||
/** Every header name on the request, lowercased. */
|
||||
function headerNames(init: RequestInit | undefined): string[] {
|
||||
const headers = init?.headers;
|
||||
if (!headers) return [];
|
||||
if (typeof (headers as Headers).get === "function") {
|
||||
return Array.from((headers as Headers).keys()).map((h) => h.toLowerCase());
|
||||
}
|
||||
return Object.keys(headers as Record<string, string>).map((h) =>
|
||||
h.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
function callTo(urlPart: string, method?: string): Call | undefined {
|
||||
return calls.find(
|
||||
(c) =>
|
||||
c.url.includes(urlPart) &&
|
||||
(method === undefined || c.method === method),
|
||||
);
|
||||
}
|
||||
|
||||
/** Render the screen and wait for the repair to arrive. */
|
||||
async function renderDetail() {
|
||||
render(<RepairDetail repairId="rep1" />);
|
||||
await screen.findByText(/Hydraulic press, 40T/);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every label the next step can carry. A finished job must offer NONE
|
||||
* of them — not a disabled one.
|
||||
*/
|
||||
const NEXT_STEP_LABELS = [
|
||||
/send to supplier/i,
|
||||
/upload supplier quote/i,
|
||||
/make customer quote/i,
|
||||
/start work/i,
|
||||
/mark completed/i,
|
||||
];
|
||||
|
||||
describe("RepairDetail", () => {
|
||||
beforeEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
currentRepair = REPAIR;
|
||||
mockFetch();
|
||||
});
|
||||
|
||||
it("shows the status as a word", async () => {
|
||||
render(<RepairDetail repairId="rep1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByText(/With supplier/).length).toBeGreaterThan(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("offers exactly one next-step button, named for the action", async () => {
|
||||
render(<RepairDetail repairId="rep1" />);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole("button", { name: /upload supplier quote/i }),
|
||||
).toBeTruthy(),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /send to supplier/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the machine, the customer and the serial number", async () => {
|
||||
render(<RepairDetail repairId="rep1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Hydraulic press, 40T/)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.getByText(/Borer Plant/)).toBeTruthy();
|
||||
expect(screen.getByText(/HP-2211/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("never renders a price, a cost or a margin", async () => {
|
||||
render(<RepairDetail repairId="rep1" />);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/Hydraulic press, 40T/)).toBeTruthy(),
|
||||
);
|
||||
expect(screen.queryByText(/R ?[0-9.,]+/)).toBeNull();
|
||||
expect(screen.queryByText(/cost/i)).toBeNull();
|
||||
expect(screen.queryByText(/margin/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("reads the customer and the supplier from the expanded relations", async () => {
|
||||
// The relation id carries no name — the name is on the expanded
|
||||
// record. Reading the id renders a blank line.
|
||||
await renderDetail();
|
||||
expect(screen.getByText("Borer Plant")).toBeTruthy();
|
||||
expect(screen.getByText("Bolt & Nut")).toBeTruthy();
|
||||
// The id itself is never shown to the operator.
|
||||
expect(screen.queryByText("cust1")).toBeNull();
|
||||
});
|
||||
|
||||
it("sends the browser session on every request it makes", async () => {
|
||||
// Task 1 gated the repairs routes AND the repairs slice of the file
|
||||
// proxy — a plain <img src> cannot carry the header, so the photo
|
||||
// has to be fetched with the session too.
|
||||
await renderDetail();
|
||||
const read = callTo("/api/v1/repairs/rep1");
|
||||
const photo = callTo("/api/v1/files/repairs/rep1/");
|
||||
expect(read).toBeDefined();
|
||||
expect(photo).toBeDefined();
|
||||
expect(headerValue(read?.init, "authorization")).toBe("Bearer t");
|
||||
expect(headerValue(photo?.init, "authorization")).toBe("Bearer t");
|
||||
});
|
||||
|
||||
it("offers a camera that takes a photo of the machine", async () => {
|
||||
await renderDetail();
|
||||
const input = screen.getByLabelText(
|
||||
/add a photo of the machine/i,
|
||||
) as HTMLInputElement;
|
||||
expect(input.getAttribute("accept")).toContain("image/");
|
||||
expect(input.getAttribute("capture")).toBe("environment");
|
||||
});
|
||||
|
||||
it("uploads a photo as multipart, without a Content-Type", async () => {
|
||||
await renderDetail();
|
||||
fireEvent.change(screen.getByLabelText(/add a photo of the machine/i), {
|
||||
target: {
|
||||
files: [new File(["img"], "machine.jpg", { type: "image/jpeg" })],
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(callTo("/files/photos", "POST")).toBeDefined(), {
|
||||
timeout: 2000,
|
||||
});
|
||||
const upload = callTo("/files/photos", "POST");
|
||||
expect(upload?.url).toBe("/api/v1/repairs/rep1/files/photos");
|
||||
expect(headerValue(upload?.init, "authorization")).toBe("Bearer t");
|
||||
// The field name is literally "photos".
|
||||
const form = upload?.init?.body as FormData;
|
||||
expect(form).toBeInstanceOf(FormData);
|
||||
const photo = form.get("photos");
|
||||
expect(photo).toBeInstanceOf(File);
|
||||
expect((photo as File).name).toBe("machine.jpg");
|
||||
// No Content-Type — the browser sets the multipart boundary, and a
|
||||
// content type set by hand breaks it.
|
||||
expect(headerNames(upload?.init)).not.toContain("content-type");
|
||||
});
|
||||
|
||||
it("offers no next step at all once the job is finished", async () => {
|
||||
currentRepair = { ...REPAIR, status: "completed" };
|
||||
await renderDetail();
|
||||
for (const label of NEXT_STEP_LABELS) {
|
||||
expect(screen.queryByRole("button", { name: label })).toBeNull();
|
||||
}
|
||||
// The status still reads as a word.
|
||||
expect(screen.getAllByText(/Done/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("offers no next step at all once the job is cancelled", async () => {
|
||||
currentRepair = { ...REPAIR, status: "cancelled" };
|
||||
await renderDetail();
|
||||
for (const label of NEXT_STEP_LABELS) {
|
||||
expect(screen.queryByRole("button", { name: label })).toBeNull();
|
||||
}
|
||||
expect(screen.getAllByText(/Cancelled/).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
509
src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx
Normal file
509
src/app/(repairs-app)/repairs-app/[id]/repair-detail.tsx
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { getPocketBase } from "@/lib/pocketbase";
|
||||
import {
|
||||
REPAIR_STATUS,
|
||||
REPAIR_STATUS_LABEL,
|
||||
REPAIR_STATUS_VARIANT,
|
||||
nextStepFor,
|
||||
type RepairStatus,
|
||||
} from "@/lib/repair-status";
|
||||
import type { Repair } from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Authorization header for the repairs routes AND the repairs slice of
|
||||
* the file proxy.
|
||||
*
|
||||
* 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}` } : {};
|
||||
}
|
||||
|
||||
/** URL of a repair file inside the (session-gated) file proxy. */
|
||||
function repairFileUrl(repairId: string, filename: string): string {
|
||||
return `/api/v1/files/repairs/${repairId}/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the repair's photos and return a filename -> blob URL map, plus
|
||||
* the filenames that could not be loaded.
|
||||
*
|
||||
* The file proxy requires a session and an `<img src>` cannot carry an
|
||||
* Authorization header, so the bytes are fetched with the session and
|
||||
* the browser renders them from a blob URL. A failed photo is reported
|
||||
* by name rather than skipped: a silent skip would leave its tile
|
||||
* reading "Loading…" for ever.
|
||||
*/
|
||||
async function loadPhotoUrls(
|
||||
repairId: string,
|
||||
files: string[],
|
||||
): Promise<{ urls: Record<string, string>; failed: string[] }> {
|
||||
const urls: Record<string, string> = {};
|
||||
const failed: string[] = [];
|
||||
for (const name of files) {
|
||||
try {
|
||||
const res = await fetch(repairFileUrl(repairId, name), {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
failed.push(name);
|
||||
continue;
|
||||
}
|
||||
urls[name] = URL.createObjectURL(await res.blob());
|
||||
} catch {
|
||||
failed.push(name);
|
||||
}
|
||||
}
|
||||
return { urls, failed };
|
||||
}
|
||||
|
||||
/** The documents a repair carries, in the order the job produces them. */
|
||||
const DOCUMENTS: {
|
||||
label: string;
|
||||
field: "repair_request" | "signed_request" | "quote" | "purchase_order";
|
||||
}[] = [
|
||||
{ label: "Repair request", field: "repair_request" },
|
||||
{ label: "Signed copy", field: "signed_request" },
|
||||
{ label: "Supplier quote", field: "quote" },
|
||||
{ label: "Purchase order", field: "purchase_order" },
|
||||
];
|
||||
|
||||
/**
|
||||
* One repair, and the one thing most likely to happen next.
|
||||
*
|
||||
* The next-step button comes from `nextStepFor` so it can never offer a
|
||||
* move the server would refuse, and a finished job shows no button at all
|
||||
* rather than a dead one.
|
||||
*
|
||||
* Plain words for a person who does not work with repairs all day: the
|
||||
* status is a word, the machine is named, and the next step is a single
|
||||
* big button. Nothing on this screen is a price — the office quotes, the
|
||||
* phone does not.
|
||||
*/
|
||||
export function RepairDetail({ repairId }: { repairId: string }) {
|
||||
const [repair, setRepair] = useState<Repair | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
/** Something the operator should know — the request is ready, etc. */
|
||||
const [notice, setNotice] = useState<{ text: string; link?: string } | null>(
|
||||
null,
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
|
||||
/** Photos that could not be loaded — shown as an error, not a spinner. */
|
||||
const [failedPhotos, setFailedPhotos] = useState<string[]>([]);
|
||||
const [photoError, setPhotoError] = useState<string | null>(null);
|
||||
/** Bumped after a change, to load the repair again. */
|
||||
const [reloadToken, setReloadToken] = useState(0);
|
||||
|
||||
const quoteRef = useRef<HTMLInputElement>(null);
|
||||
const photoRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/v1/repairs/" + repairId, {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Could not load this repair.");
|
||||
const data = (await res.json()) as Repair;
|
||||
if (cancelled) return;
|
||||
setRepair(data);
|
||||
const photos = await loadPhotoUrls(repairId, data.photos ?? []);
|
||||
if (cancelled) {
|
||||
// The screen closed while the photos were coming in — the blob
|
||||
// URLs would otherwise stay in memory for ever.
|
||||
for (const url of Object.values(photos.urls)) {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setPhotoUrls(photos.urls);
|
||||
setFailedPhotos(photos.failed);
|
||||
} catch (err: unknown) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not load this repair.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
void load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [repairId, reloadToken]);
|
||||
|
||||
// Revoke the photos' blob URLs when they are replaced, and when the
|
||||
// screen closes. A blob URL holds the file in memory until it is
|
||||
// revoked.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
for (const url of Object.values(photoUrls)) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [photoUrls]);
|
||||
|
||||
async function takeNextStep() {
|
||||
if (!repair) return;
|
||||
const step = nextStepFor(repair.status);
|
||||
if (!step) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
if (step.action === "generate_repair_request") {
|
||||
const res = await fetch(
|
||||
"/api/v1/repairs/" + repair.id + "/generate-repair-request",
|
||||
{ method: "POST", headers: authHeaders() },
|
||||
);
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(body.error ?? "Could not make the repair request.");
|
||||
}
|
||||
setNotice({
|
||||
text: "The repair request is ready. Print it and send it with the machine.",
|
||||
});
|
||||
setReloadToken((n) => n + 1);
|
||||
} else if (step.action === "upload_supplier_quote") {
|
||||
// The button opens the file picker; the upload happens when the
|
||||
// operator picks the file.
|
||||
quoteRef.current?.click();
|
||||
return;
|
||||
} else if (
|
||||
step.action === "start_work" ||
|
||||
step.action === "mark_completed"
|
||||
) {
|
||||
const res = await fetch("/api/v1/repairs/" + repair.id, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", ...authHeaders() },
|
||||
body: JSON.stringify({ status: step.to }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as {
|
||||
error?: string;
|
||||
};
|
||||
throw new Error(body.error ?? "Could not update this repair.");
|
||||
}
|
||||
setReloadToken((n) => n + 1);
|
||||
} else if (step.action === "create_customer_quote") {
|
||||
// The customer quote is priced in the office — this screen shows
|
||||
// no prices at all — so the button says where the job goes next
|
||||
// instead of pretending to do it.
|
||||
setNotice({
|
||||
text: "The customer quote is made in the office. Ask the office to quote this repair for the customer.",
|
||||
link: "/repairs/" + repair.id,
|
||||
});
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Something went wrong.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadQuote(file: File) {
|
||||
if (!repair) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("quote", file, file.name);
|
||||
const res = await fetch("/api/v1/repairs/" + repair.id + "/files/quote", {
|
||||
method: "POST",
|
||||
// Multipart: the browser sets the Content-Type boundary, so only
|
||||
// the session header goes here.
|
||||
headers: authHeaders(),
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw new Error("The quote did not upload.");
|
||||
setNotice({ text: "The supplier's quote is on the repair." });
|
||||
setReloadToken((n) => n + 1);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "The quote did not upload.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadPhoto(file: File) {
|
||||
if (!repair) return;
|
||||
setPhotoError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("photos", file, file.name);
|
||||
const res = 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 (!res.ok) throw new Error("The photo did not upload.");
|
||||
setReloadToken((n) => n + 1);
|
||||
} catch (err: unknown) {
|
||||
setPhotoError(
|
||||
err instanceof Error ? err.message : "The photo did not upload.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a document with the browser session.
|
||||
*
|
||||
* The file proxy requires a session and neither an `<a href>` nor an
|
||||
* `<img src>` can carry an Authorization header — so the bytes are
|
||||
* fetched here and handed to the browser as a blob URL.
|
||||
*/
|
||||
async function openDocument(filename: string) {
|
||||
if (!repair) return;
|
||||
try {
|
||||
const res = await fetch(repairFileUrl(repair.id, filename), {
|
||||
headers: authHeaders(),
|
||||
});
|
||||
if (!res.ok) throw new Error("Could not open the file.");
|
||||
const blobUrl = URL.createObjectURL(await res.blob());
|
||||
window.open(blobUrl, "_blank", "noopener,noreferrer");
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
|
||||
} catch (err: unknown) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not open the file.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (error && !repair) {
|
||||
return <p className="p-4 text-sm">{error}</p>;
|
||||
}
|
||||
if (!repair) {
|
||||
return <p className="p-4 text-sm text-muted-foreground">Loading…</p>;
|
||||
}
|
||||
|
||||
const step = nextStepFor(repair.status);
|
||||
const customerName = repair.expand?.customer?.customer ?? "";
|
||||
const supplierName = repair.expand?.supplier?.suppliername ?? "";
|
||||
const photoFiles = repair.photos ?? [];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-2xl flex-col gap-4 p-3">
|
||||
<input
|
||||
ref={quoteRef}
|
||||
type="file"
|
||||
accept="application/pdf,image/*"
|
||||
className="sr-only"
|
||||
aria-label="Upload the supplier's quote"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadQuote(file);
|
||||
// Let the same file be picked again.
|
||||
if (quoteRef.current) quoteRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h1 className="text-2xl font-semibold">{repair.repair_number}</h1>
|
||||
<Badge variant={REPAIR_STATUS_VARIANT[repair.status] || "secondary"}>
|
||||
{REPAIR_STATUS_LABEL[repair.status] || repair.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{step ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="min-h-[56px] w-full text-base"
|
||||
onClick={() => void takeNextStep()}
|
||||
disabled={busy}
|
||||
>
|
||||
{step.label}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{notice ? (
|
||||
<div className="rounded-md border border-primary/40 p-3 text-sm">
|
||||
<p>{notice.text}</p>
|
||||
{notice.link ? (
|
||||
<Link href={notice.link} className="mt-1 inline-block underline">
|
||||
Open this repair in the office
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive/40 p-3 text-sm"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<section className="rounded-lg border p-3">
|
||||
<h2 className="font-medium">{repair.item_description}</h2>
|
||||
<dl className="mt-2 space-y-1 text-sm">
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-muted-foreground">Whose machine</dt>
|
||||
<dd>{customerName}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-muted-foreground">Serial number</dt>
|
||||
<dd>{repair.serial_no}</dd>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-muted-foreground">Customer says</dt>
|
||||
<dd>{repair.fault_description}</dd>
|
||||
</div>
|
||||
{repair.supplier_scope ? (
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-muted-foreground">Supplier to look at</dt>
|
||||
<dd>{repair.supplier_scope}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
{supplierName ? (
|
||||
<div className="flex gap-2">
|
||||
<dt className="text-muted-foreground">Supplier</dt>
|
||||
<dd>{supplierName}</dd>
|
||||
</div>
|
||||
) : null}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-medium">Photos</h2>
|
||||
{photoFiles.length > 0 ? (
|
||||
<ul className="mt-2 grid grid-cols-3 gap-2">
|
||||
{photoFiles.map((name) => (
|
||||
<li
|
||||
key={name}
|
||||
className="aspect-square overflow-hidden rounded border bg-muted"
|
||||
>
|
||||
{photoUrls[name] ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={photoUrls[name]}
|
||||
alt={name}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : failedPhotos.includes(name) ? (
|
||||
<span className="flex h-full w-full items-center justify-center px-1 text-center text-[10px] text-destructive">
|
||||
Could not load this photo
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex h-full w-full items-center justify-center text-[10px] text-muted-foreground">
|
||||
Loading…
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No photos yet</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<Label htmlFor="repair-photo">Add a photo of the machine</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Take a photo of the machine now, if you are still at the site.
|
||||
</p>
|
||||
<input
|
||||
ref={photoRef}
|
||||
id="repair-photo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="sr-only"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void uploadPhoto(file);
|
||||
// Let the same file be picked again.
|
||||
if (photoRef.current) photoRef.current.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="min-h-[56px] w-full text-base"
|
||||
onClick={() => photoRef.current?.click()}
|
||||
disabled={busy}
|
||||
>
|
||||
Take a photo
|
||||
</Button>
|
||||
</div>
|
||||
{photoError ? (
|
||||
<p role="alert" className="mt-2 text-sm text-destructive">
|
||||
{photoError}
|
||||
</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-medium">Documents</h2>
|
||||
<ul className="mt-1 space-y-2 text-sm">
|
||||
{DOCUMENTS.map((doc) => {
|
||||
const files = repair[doc.field] ?? [];
|
||||
return (
|
||||
<li key={doc.field}>
|
||||
<p className="font-medium">{doc.label}</p>
|
||||
{files.length === 0 ? (
|
||||
<p className="text-muted-foreground">None yet</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{files.map((name) => (
|
||||
<li key={name}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void openDocument(name)}
|
||||
className="text-left underline"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="font-medium">Progress</h2>
|
||||
<ol className="mt-2 space-y-1">
|
||||
{REPAIR_STATUS.map((s: RepairStatus) => (
|
||||
<li
|
||||
key={s}
|
||||
className={
|
||||
s === repair.status
|
||||
? "font-medium"
|
||||
: "text-sm text-muted-foreground"
|
||||
}
|
||||
>
|
||||
{REPAIR_STATUS_LABEL[s]}
|
||||
{s === repair.status ? " — now" : ""}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue