diff --git a/AGENTS.md b/AGENTS.md index 72a7b63..066577f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,6 +40,35 @@ Pattern in code: disambiguate from other attachments on the same item + +# File URLs go through `/api/v1/files/`, never `/api/files/` + +The WFDASH file-serving proxy lives at +`/api/v1/files/{collection}/{recordId}/{filename}` (route file: +`src/app/api/v1/files/[collection]/[recordId]/[filename]/route.ts`). +Every `` and every fetch from a client component that +opens a repair/quote/order attachment must use the `/api/v1/` prefix. + +`/api/files/{collection}/{recordId}/{filename}` (no `v1/`) 404s — +that's the *PocketBase* convention, not a route in this app. The +PB instance serves files at that path, and the WFDASH proxy +forwards to it server-side, but a client that points directly at +`/api/files/...` gets the Next.js 404 page. + +**Gotcha:** PocketBase record responses (e.g. `record.delivery_note` +as a `string[]` of filenames) sometimes come back as fully-qualified +PB URLs like `http://pb-host/api/files/...` — those should be +re-written to `/api/v1/files/...` on the client before rendering. +The existing detail page builds the path from scratch from the +record id + filename, which sidesteps the issue. Don't blindly +trust the URL PB hands back. + +The `src/app/api/invoicing/delivery-note-pdf/route.test.ts` test +file intentionally matches `/api/files/...` in an expect — that's +asserting the *raw PB response* shape, not the URL the client +should use. Different concern. + + # Data access layer discipline diff --git a/src/app/(app)/repairs/[id]/page.tsx b/src/app/(app)/repairs/[id]/page.tsx index 3843c9c..0a38718 100644 --- a/src/app/(app)/repairs/[id]/page.tsx +++ b/src/app/(app)/repairs/[id]/page.tsx @@ -114,7 +114,7 @@ export default function RepairDetailPage({ status: "new" as (typeof ALL_STATUSES)[number], }); - const dnUploadRef = useRef(null); + const repairRequestUploadRef = useRef(null); const quoteUploadRef = useRef(null); const poUploadRef = useRef(null); @@ -216,7 +216,7 @@ export default function RepairDetailPage({ setGenerating(true); try { const res = await fetch( - `/api/v1/repairs/${repairId}/generate-delivery-note`, + `/api/v1/repairs/${repairId}/generate-repair-request`, { method: "POST" }, ); if (!res.ok) { @@ -225,18 +225,18 @@ export default function RepairDetailPage({ } const data = await res.json(); toast.success( - `Delivery note ${data.deliveryNoteNumber} generated — driver carries this to the supplier.`, + `Repair Request ${data.filename} generated — print and send with the machine.`, ); fetchRepair(); } catch (err: unknown) { - toast.error(err instanceof Error ? err.message : "Failed to generate DN"); + toast.error(err instanceof Error ? err.message : "Failed to generate Repair Request"); } finally { setGenerating(false); } }; const handleUploadFile = async ( - field: "delivery_note" | "quote" | "purchase_order", + field: "repair_request" | "quote" | "purchase_order", file: File, ) => { if (!repairId) return; @@ -252,7 +252,7 @@ export default function RepairDetailPage({ throw new Error(err.error ?? `HTTP ${res.status}`); } const labels: Record = { - delivery_note: "Delivery note", + repair_request: "Repair Request", quote: "Supplier quote", purchase_order: "Purchase order", }; @@ -264,7 +264,7 @@ export default function RepairDetailPage({ }; const handleDeleteFile = async ( - field: "delivery_note" | "quote" | "purchase_order", + field: "repair_request" | "quote" | "purchase_order", filename: string, ) => { if (!repairId) return; @@ -328,7 +328,7 @@ export default function RepairDetailPage({ } const customerEmail = repair.expand?.customer?.email ?? ""; - const deliveryNoteFiles = repair.delivery_note ?? []; + const repairRequestFiles = repair.repair_request ?? []; const quoteFiles = repair.quote ?? []; const poFiles = repair.purchase_order ?? []; const photoFiles = repair.photos ?? []; @@ -355,55 +355,62 @@ export default function RepairDetailPage({

{repair.item_description}

-
- {repair.status === "new" && ( - - )} - {(repair.status === "approved" || repair.status === "in_progress") && ( - - )} - {repair.status !== "completed" && repair.status !== "cancelled" && ( - - )} - )} - {editing ? "Save" : "Edit"} - + {(repair.status === "approved" || repair.status === "in_progress") && ( + + )} + {repair.status !== "completed" && repair.status !== "cancelled" && ( + + )} + +
+ {repair.status === "new" ? ( +

+ Print the generated PDF and send it to the supplier with the machine. +

+ ) : null} @@ -574,13 +581,13 @@ export default function RepairDetailPage({ handleUploadFile("delivery_note", file)} - onDelete={(f) => handleDeleteFile("delivery_note", f)} + inputRef={repairRequestUploadRef} + onUpload={(file) => handleUploadFile("repair_request", file)} + onDelete={(f) => handleDeleteFile("repair_request", f)} /> { const files = [ - ...(item.delivery_note ?? []).map((f) => ({ - field: "delivery_note" as const, + ...(item.repair_request ?? []).map((f) => ({ + field: "repair_request" as const, name: f, })), ...(item.quote ?? []).map((f) => ({ field: "quote" as const, name: f })), @@ -359,7 +359,7 @@ export default function RepairsPage() { {files.map((f) => (
- - {style.label} - - ); -} /* ============================================================================ * CreateStockItemModal — minimal "new stock item" form. diff --git a/src/app/(app)/stock/stock-status-badge.tsx b/src/app/(app)/stock/stock-status-badge.tsx new file mode 100644 index 0000000..ee982bd --- /dev/null +++ b/src/app/(app)/stock/stock-status-badge.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Badge } from "@/components/ui/badge"; +import { STOCK_STATUS_STYLES, type StockStatus } from "@/lib/stock-status"; + +/** + * StockStatusBadge — coloured pill keyed by `StockStatus`. + * + * Renders the same hex/ring/label triple everywhere, so the list, the + * detail header, and the create modal can all share this component. + * + * Extracted out of `src/app/(app)/stock/page.tsx` because Next 16's + * Page-export type check rejects any named export that isn't on the + * page-config allowlist (default, metadata, generateMetadata, + * revalidate, dynamic, …). + */ +export function StockStatusBadge({ status }: { status: StockStatus }) { + const style = STOCK_STATUS_STYLES[status]; + return ( + + + {style.label} + + ); +} diff --git a/src/app/api/v1/repairs/[id]/files/[field]/route.ts b/src/app/api/v1/repairs/[id]/files/[field]/route.ts index a8331a6..e32325d 100644 --- a/src/app/api/v1/repairs/[id]/files/[field]/route.ts +++ b/src/app/api/v1/repairs/[id]/files/[field]/route.ts @@ -12,7 +12,7 @@ const ALLOWED_FIELDS: RepairFileField[] = [ "photos", "quote", "purchase_order", - "delivery_note", + "repair_request", ]; function isAllowedField(value: string): value is RepairFileField { diff --git a/src/app/api/v1/repairs/[id]/generate-delivery-note/route.ts b/src/app/api/v1/repairs/[id]/generate-delivery-note/route.ts deleted file mode 100644 index 9305e08..0000000 --- a/src/app/api/v1/repairs/[id]/generate-delivery-note/route.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { generateRepairDeliveryNote } from "@/lib/server/repairs-service"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -/** - * POST /api/v1/repairs/[id]/generate-delivery-note - * - * Generates a delivery note that the driver takes with the machine - * to the supplier for analysis. The supplier is read from the - * repair record (must be set first). The PDF is attached to the - * repair's `delivery_note` file field. The repair is flipped to - * status `assessing` if it was previously `new`. - * - * Returns: 200 — { deliveryNoteId, deliveryNoteNumber, filename } - * 400 — no supplier set on repair - * 500 — render / upload failure - */ -export async function POST( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> }, -) { - const { id } = await params; - try { - const result = await generateRepairDeliveryNote(id); - return NextResponse.json(result); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Failed to generate delivery note"; - const status = /no supplier set/i.test(message) ? 400 : 500; - return NextResponse.json({ error: message }, { status }); - } -} diff --git a/src/app/api/v1/repairs/[id]/generate-repair-request/route.ts b/src/app/api/v1/repairs/[id]/generate-repair-request/route.ts new file mode 100644 index 0000000..73c3fcf --- /dev/null +++ b/src/app/api/v1/repairs/[id]/generate-repair-request/route.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { generateRepairRequest } from "@/lib/server/repairs-service"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** + * POST /api/v1/repairs/[id]/generate-repair-request + * + * Renders the Repair Request PDF the office prints and sends to the + * supplier with the machine for analysis. The supplier is read from + * the repair record (must be set first). The PDF is attached to the + * repair's `repair_request` file field. The repair is flipped to + * status `assessing` if it was previously `new`. + * + * Not a delivery note — the document is a paper request-for-quote + * addressed to the supplier. No `delivery_notes` header or + * `order_items` row is created; the customer-DN flow is not part of + * the repair pipeline. + * + * Returns: 200 — { filename } + * 400 — no supplier set on repair + * 500 — render / upload failure + */ +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id } = await params; + try { + const result = await generateRepairRequest(id); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to generate repair request"; + const status = /no supplier set/i.test(message) ? 400 : 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/lib/letterhead.ts b/src/lib/letterhead.ts index fcaece8..00fa9e2 100644 --- a/src/lib/letterhead.ts +++ b/src/lib/letterhead.ts @@ -44,6 +44,24 @@ export function deliveryNoteLetterheadCode(): LetterheadCode { return DELIVERY_NOTE_LETTERHEAD_CODE; } +/** + * Letterhead for Repair Request PDFs (the "send to supplier for + * analysis" document). Always CNL — a repair request is a paper + * document the office hands to the supplier with the machine, and + * it identifies the *sender* (the office), not the recipient. + * Centralised so the service layer + the (future) any-other + * Repair Request renderer can't drift apart. + */ +export const REPAIR_REQUEST_LETTERHEAD_CODE: LetterheadCode = "CNL"; + +/** + * Helper that returns the canonical Repair Request letterhead code. + * Mirror of `deliveryNoteLetterheadCode()`. + */ +export function repairRequestLetterheadCode(): LetterheadCode { + return REPAIR_REQUEST_LETTERHEAD_CODE; +} + /** * Pick the letterhead code that matches a given company name. The * invoice / delivery-note flows don't always carry an explicit diff --git a/src/lib/pdf/render.tsx b/src/lib/pdf/render.tsx index 3be6034..31951d2 100644 --- a/src/lib/pdf/render.tsx +++ b/src/lib/pdf/render.tsx @@ -20,6 +20,10 @@ import { StockItemStocktakeDocument, type StockItemStocktakeDocumentProps, } from "./stock-item-stocktake-document"; +import { + RepairRequestDocument, + type RepairRequestDocumentProps, +} from "./repair-request-document"; /** * Server-side render of a `` to a PDF Buffer. @@ -110,6 +114,26 @@ export async function renderDeliveryNotePdf(props: DeliveryNoteDocumentProps): P return Buffer.from(pdfString, "binary"); } +/** + * Server-side render of a `` to a PDF Buffer. + * + * Same in-process Node pipeline as the other renderers. The + * `repair-request` API route calls this right after it resolves the + * repair + supplier + letterhead, then uploads the resulting Buffer + * to the repair's `repair_request` file field (multi, maxSelect 10). + * + * The PDF is named `RR-{repairNumber}.pdf` (e.g. `RR-REP-2026-08-003.pdf`) + * so the operator can find it on the repair detail page by name + * (PB's file URLs include the encoded name). + */ +export async function renderRepairRequestPdf( + props: RepairRequestDocumentProps +): Promise { + const instance = pdf(); + const pdfString = await instance.toString(); + return Buffer.from(pdfString, "binary"); +} + /** * Server-side render of the stock-orders *list* PDF — every * purchase in one document, not one PDF per PO. Sibling of diff --git a/src/lib/pdf/repair-request-document.tsx b/src/lib/pdf/repair-request-document.tsx new file mode 100644 index 0000000..11b78b7 --- /dev/null +++ b/src/lib/pdf/repair-request-document.tsx @@ -0,0 +1,347 @@ +import { Document, Image, Page, StyleSheet, Text, View } from "@react-pdf/renderer"; + +import type { Letterhead } from "@/lib/letterhead"; +import { defaultAccentFor } from "@/lib/letterhead"; +import { formatDateLong } from "./format"; + +export type RepairRequestDocumentProps = { + /** Repair number (e.g. "REP-2026-08-003"). */ + repairNumber: string; + /** ISO timestamp the request was generated (rendered as a long-form date). */ + dateIso: string; + /** ISO timestamp the machine was originally booked in. */ + receivedIso: string; + /** Supplier name (e.g. "SHARP RITE"). Shown prominently at the top of the page. */ + supplierName: string; + /** Description of the item (free text, often multi-line for grouped items). */ + itemDescription: string; + /** Serial number — empty string renders as "Not provided". */ + serialNumber: string; + /** Problem / fault description. */ + problemDescription: string; + /** Lifecycle status as a human-readable string (e.g. "Sent for Strip & Quote"). */ + status: string; + /** Operator-facing contact for the office that issues the request. */ + contactName: string; + /** Operator-facing contact email. */ + contactEmail: string; + letterhead: Letterhead; + /** + * Logo as a `data:image/svg+xml;base64,...` URI. Same SVG workaround + * as the quote / PO / DN documents — @react-pdf v4.5.1 corrupts every + * byte ≥ 0x80 in raster image data, but SVG is text and goes through + * the SVG renderer instead. + */ + logoDataUri: string | null; +}; + +/** + * Renders a printable Repair Request PDF — the "please assess and + * quote" document the office prints and sends to the supplier with + * the machine. Same visual language as the quote / PO / DN documents + * (letterhead top-left, logo top-right, big title, two-column meta + * block, accent-coloured tables) but the body is structured as a + * request-for-quote rather than a money document: + * + * - Supplier block (prominent, since it's the recipient) + * - Repair No / Date / Received (3-up row) + * - Supplier Information (name, contact, email) + * - Repair Item Details (description, serial, problem, status) + * - Service Request (5 numbered points) + * - Thank-you line + contact strip + * + * Render with `renderRepairRequestPdf` in `src/lib/pdf/render.tsx`. + */ +export function RepairRequestDocument({ + repairNumber, + dateIso, + receivedIso, + supplierName, + itemDescription, + serialNumber, + problemDescription, + status, + contactName, + contactEmail, + letterhead, + logoDataUri, +}: RepairRequestDocumentProps) { + const accent = + letterhead.accent_color || + defaultAccentFor( + letterhead.display_name?.toUpperCase().includes("CNL") ? "CNL" : "ISHERWOOD", + ); + + return ( + + + {/* Header — same pattern as the quote / PO / DN: company info + on the left, logo absolutely-positioned on the right. */} + + {letterhead.display_name} (Pty) Ltd + {letterhead.address} + {letterhead.phone} + {letterhead.email} + {`VAT: ${letterhead.vat_number}`} + {letterhead.registration_number ? ( + {`Business ID: ${letterhead.registration_number}`} + ) : null} + + {logoDataUri ? ( + + + + ) : null} + + {/* Big "REPAIR REQUEST" title. Sibling to QUOTATION / PURCHASE + ORDER / DELIVERY NOTE on the family of documents. */} + REPAIR REQUEST + + {/* Supplier block — prominent because it's the recipient of + the request. Mirrors the sample's "SUPPLIER: " + block. */} + + SUPPLIER: + {supplierName || "(no supplier)"} + + + {/* Repair No / Date / Received — three-up row. Sample uses + these labels verbatim. */} + + + + Repair No: + {repairNumber} + + + + + Date: + {formatDateLong(dateIso)} + + + + + Received: + {formatDateLong(receivedIso)} + + + + + {/* REPAIR ITEM DETAILS — accent-coloured header row + the + four detail rows (description, serial, problem, status). + Pre-wrap so multi-line values (e.g. the "20 SHORT / 20 + LONG / 42 TOTAL" breakdown in the sample) render + naturally. */} + REPAIR ITEM DETAILS + + + Item + Details + + + Description + + {itemDescription || "(no description)"} + + + + Serial Number + + {serialNumber || "Not provided"} + + + + Problem Description + + {problemDescription || "(no problem description)"} + + + + Status + {status || "(no status)"} + + + + {/* SERVICE REQUEST — boilerplate "please quote me" text + the + 5 numbered points the supplier should cover in their + quote. Boilerplate so the operator doesn't have to type + it for every request. */} + SERVICE REQUEST + + We kindly request you to examine the item described above and provide a detailed + quotation for repair. Please include: + + + 1. Detailed assessment of the problem + 2. Parts required for repair + 3. Labor costs + 4. Estimated timeline for completion + 5. Warranty information + + + Thank you for your prompt attention to this matter. + + + {/* CONTACT — for-queries strip at the bottom. The contact + name is the office name (from the letterhead) by default; + the email is the office email. Both are best-effort — the + service layer passes reasonable fallbacks so the page + never prints "undefined". */} + + CONTACT + + {`For queries: ${contactName || "the office"} | Email: ${contactEmail || "—"}`} + + + + {/* Letterhead footer (e.g. "Thank you for your business."). + Same pattern as the quote / PO / DN. */} + {letterhead.footer_text ? ( + {letterhead.footer_text} + ) : null} + + + ); +} + +const styles = StyleSheet.create({ + page: { + paddingTop: 40, + paddingHorizontal: 40, + paddingBottom: 80, + fontFamily: "Helvetica", + fontSize: 10, + color: "#222", + }, + headerCompany: { + marginBottom: 6, + }, + companyName: { fontSize: 16, fontFamily: "Helvetica-Bold", marginBottom: 6 }, + body: { fontSize: 10, marginBottom: 2 }, + bold: { fontFamily: "Helvetica-Bold" }, + logoAbsolute: { + position: "absolute", + top: 40, + right: 40, + }, + logo: { width: 160, height: 100 }, + title: { + fontSize: 22, + fontFamily: "Helvetica-Bold", + marginTop: 24, + marginBottom: 18, + letterSpacing: 0.5, + }, + // Supplier block — big + accent-coloured so the recipient sees + // their name at the top of the page when they open the envelope. + supplierBlock: { + marginBottom: 18, + }, + supplierLabel: { + fontSize: 11, + fontFamily: "Helvetica-Bold", + color: "#475569", + marginBottom: 4, + letterSpacing: 0.5, + }, + supplierName: { + fontSize: 18, + fontFamily: "Helvetica-Bold", + }, + // Meta row (Repair No / Date / Received) — three equal columns, + // right column right-aligned so the dates line up with the rest + // of the page. + metaRow: { + flexDirection: "row", + marginBottom: 20, + borderTopWidth: 0.5, + borderBottomWidth: 0.5, + borderColor: "#cccccc", + paddingVertical: 10, + }, + metaCol: { flex: 1, paddingRight: 12 }, + metaColRight: { alignItems: "flex-end", paddingRight: 0 }, + metaLine: { fontSize: 10 }, + sectionTitle: { + fontSize: 12, + fontFamily: "Helvetica-Bold", + letterSpacing: 0.5, + marginTop: 8, + marginBottom: 8, + }, + // Repair Item Details table — accent-coloured header + 4 detail + // rows. Last row has no bottom border (the table rule below + // closes the section). + detailTable: { + width: "100%", + marginBottom: 20, + }, + detailHeaderRow: { + flexDirection: "row", + paddingVertical: 6, + paddingHorizontal: 6, + }, + detailHeaderCell: { + color: "#ffffff", + fontFamily: "Helvetica-Bold", + fontSize: 10, + }, + detailRow: { + flexDirection: "row", + paddingVertical: 6, + paddingHorizontal: 6, + borderBottomWidth: 0.5, + borderBottomColor: "#dddddd", + }, + detailRowLast: { + borderBottomWidth: 0.8, + borderBottomColor: "#222", + }, + detailCell: { fontSize: 10 }, + colItem: { flex: 1.2, fontFamily: "Helvetica-Bold" }, + colDetails: { flex: 4 }, + preWrap: { whiteSpace: "pre-wrap" }, + // Service Request — boilerplate text + numbered list. + numberedList: { + marginTop: 6, + marginBottom: 8, + paddingLeft: 8, + }, + numberedItem: { + fontSize: 10, + marginBottom: 2, + }, + thankYou: { + marginTop: 6, + }, + // Contact strip — small section at the bottom of the page. + contactStrip: { + marginTop: 20, + paddingTop: 10, + borderTopWidth: 0.5, + borderTopColor: "#cccccc", + }, + contactLabel: { + fontSize: 11, + fontFamily: "Helvetica-Bold", + letterSpacing: 0.5, + marginBottom: 4, + }, + // Letterhead footer — pinned to the page bottom (same pattern as + // the quote / PO / DN so the family of documents renders + // consistently). + footerText: { + fontSize: 8, + color: "#94a3b8", + textAlign: "center", + position: "absolute", + bottom: 16, + left: 40, + right: 40, + }, +}); diff --git a/src/lib/server/repairs-service.ts b/src/lib/server/repairs-service.ts index e5e0476..eb314d7 100644 --- a/src/lib/server/repairs-service.ts +++ b/src/lib/server/repairs-service.ts @@ -13,9 +13,10 @@ * └────► cancelled (from any pre-completed state) * * ‑ "new" : booked in, machine physically received - * ‑ "assessing" : office sent the machine to a supplier for analysis - * (a delivery-note PDF is on file under - * `repairs.delivery_note`) + * ‑ "assessing" : office generated the Repair Request PDF and + * sent it (with the machine) to the supplier for + * analysis — the PDF is on file under + * `repairs.repair_request` * ‑ "quoted" : supplier returned a quote (uploaded to * `repairs.quote`) * ‑ "approved" : customer approved the supplier's quote (and the @@ -25,17 +26,30 @@ * ‑ "completed" : machine is back with the customer * * Side-channel data lives in the file fields (`photos`, `quote`, - * `purchase_order`, `delivery_note`) — the service exposes thin + * `purchase_order`, `repair_request`) — the service exposes thin * `upload*` helpers that re-PATCH the row with the new file list, * mirroring the multi-file pattern PocketBase uses everywhere else. + * + * Repair Request vs Delivery Note: a Repair Request is a paper + * document the office prints and hands to the supplier with the + * machine. It is NOT a delivery note — repairs do not flow + * through the customer-DN pipeline at all. The previous version + * of this service wrote a `delivery_notes` header + an + * `order_items` row + a Delivery Note PDF at the + * `new → assessing` transition, which was wrong on two counts: + * (1) the recipient of the document is the supplier (not a + * customer), and (2) nothing in the repair flow needs a driver or + * a customer signature. The "deliver or invoice" step for the + * final handoff to the customer is handled separately by the + * existing /delivery-notes flow. */ import { getAdminPocketBase } from "./pocketbase-admin"; -import { renderDeliveryNotePdf } from "@/lib/pdf/render"; +import { renderRepairRequestPdf } from "@/lib/pdf/render"; import { - deliveryNoteLetterheadCode, fetchLetterhead, loadLetterheadLogoDataUri, + repairRequestLetterheadCode, } from "@/lib/letterhead"; import type { Customer, @@ -48,8 +62,6 @@ const REPAIRS = "repairs"; const CUSTOMERS = "customers"; const SUPPLIERS = "suppliers"; const USERS = "users"; -const DELIVERY_NOTES = "delivery_notes"; -const ORDER_ITEMS = "order_items"; export type RepairStatus = | "new" @@ -274,195 +286,116 @@ export async function generateRepairNumberServer( } /* ============================================================================ - * "Send to supplier" — generate the DN PDF and attach to repair + * "Generate Repair Request" — render the request PDF and attach to repair * ========================================================================== */ -const REPAIR_DN_PREFIX = "RDN"; - -/** - * Generate a server-side daily sequence for repair DNs. Distinct - * from the customer-DN sequence so a repair DN never shadows a - * customer-DN number (operator can tell them apart at a glance). - */ -async function generateRepairDeliveryNoteNumber(now: Date = new Date()): Promise { - const pb = await getAdminPocketBase(); - const yy = String(now.getFullYear()).slice(-2); - const mm = String(now.getMonth() + 1).padStart(2, "0"); - const dd = String(now.getDate()).padStart(2, "0"); - const prefix = `${REPAIR_DN_PREFIX}${yy}${mm}${dd}`; - // We use the created order's `delivery_note_number` to find the - // last sequence. The same `delivery_note_number` field is shared - // with customer DNs, so we constrain to the repair prefix. - const existing = await pb - .collection(ORDER_ITEMS) - .getList(1, 1, { - filter: `delivery_note_number ~ "${prefix}"`, - sort: "-delivery_note_number", - }) - .catch(() => null); - let seq = 1; - if (existing && existing.items.length > 0) { - const last = (existing.items[0] as { delivery_note_number?: string }).delivery_note_number ?? ""; - const lastSeq = parseInt(last.slice(prefix.length), 10); - if (Number.isFinite(lastSeq) && lastSeq > 0) seq = lastSeq + 1; - } - return `${prefix}${String(seq).padStart(2, "0")}`; -} - -export interface GenerateRepairDeliveryNoteResult { - deliveryNoteId: string; - deliveryNoteNumber: string; +export interface GenerateRepairRequestResult { + /** Filename of the generated PDF (relative to the repair's file field). */ filename: string; } /** - * Generate a delivery note for sending the machine to the supplier - * for analysis. Three steps: + * Generate a Repair Request PDF for sending the machine to the + * supplier for analysis. Two steps: * - * 1. Resolve the supplier name + address (for the PDF's "DELIVER TO" - * block) and the customer name (for the "FROM" reference line). - * 2. Create a `delivery_notes` header row + one `order_items` row - * with the repair's item description. The order_items row is - * what the driver app and the order-completion flows look at; - * one row is enough for "send this machine to supplier X". - * 3. Render the DN PDF and stamp it on the repair's - * `delivery_note` file field so the operator can download it + * 1. Resolve the supplier (for the PDF's "SUPPLIER INFORMATION" + * block) and the letterhead (CNL — the document identifies + * the office, not the recipient). + * 2. Render the Repair Request PDF and stamp it on the repair's + * `repair_request` file field so the operator can download it * from the repair detail page. * * The repair's status is bumped to `assessing` (with the standard * transition guard). The supplier must already be set on the repair * — we don't auto-resolve it. + * + * Side-channel data: this function does NOT create a `delivery_notes` + * header row, an `order_items` row, or a customer-side delivery + * note. Repairs do not flow through the customer-DN pipeline at + * all — the "send to supplier for analysis" step is a paper + * document (this PDF), not a driver-driven delivery. */ -export async function generateRepairDeliveryNote( +export async function generateRepairRequest( repairId: string, -): Promise { +): Promise { const pb = await getAdminPocketBase(); const repair = await pb.collection(REPAIRS).getOne(repairId, { - expand: "supplier,customer,company", + expand: "supplier", }); if (!repair.supplier) { throw new Error( - "Repair has no supplier set. Set a supplier on the repair before generating a delivery note.", + "Repair has no supplier set. Set a supplier on the repair before generating a Repair Request.", ); } const supplier = await pb.collection(SUPPLIERS).getOne(repair.supplier); - // Resolve the customer name (for the PDF reference line) — the - // "DELIVER TO" block uses the supplier as destination. - let customerName = ""; - if (repair.customer) { - try { - const c = await pb.collection(CUSTOMERS).getOne(repair.customer); - customerName = c.customer ?? ""; - } catch { - // Customer lookup is best-effort; the PDF falls back to the - // repair.customer relation name if the lookup fails. - customerName = repair.expand?.customer?.customer ?? ""; - } - } - - const deliveryNoteNumber = await generateRepairDeliveryNoteNumber(); - const now = new Date().toISOString(); - - // ── 1. delivery_notes header ───────────────────────────────── - // We use the same `delivery_notes` collection as customer DNs; - // the kind is distinguished by the `notes` field (which we - // stamp with a leading "[Repair send-to-supplier]" tag) and by - // the `RDN` number prefix. Customer-DN queries don't filter - // by number, so this doesn't pollute the customer-DN list. - const header = await pb.collection(DELIVERY_NOTES).create({ - delivery_note_number: deliveryNoteNumber, - customer: repair.customer || null, - company: repair.company || null, - order: null, - status: "draft", - notes: - `[Repair send-to-supplier] ${repair.repair_number} — ${repair.item_description}` + - (repair.notes ? `\n\n${repair.notes}` : ""), - created_by: null, - }); - - // ── 2. order_items row ─────────────────────────────────────── - const itemDescription = `[Repair ${repair.repair_number}] ${repair.item_description}${repair.serial_no ? ` (S/N: ${repair.serial_no})` : ""}`; - const item = await pb.collection(ORDER_ITEMS).create({ - order: null, - delivery_note_number: deliveryNoteNumber, - description: itemDescription, - quantity: 1, - order_qty: 1, - supplier: repair.supplier, - cost_price: 0, - selling_price: 0, - sku: "", - notes: repair.fault_description, - item_status: "delivery_note", - }); - - // ── 3. Render + upload the DN PDF ───────────────────────────── - const letterheadCode = deliveryNoteLetterheadCode(); + const letterheadCode = repairRequestLetterheadCode(); const letterhead = await fetchLetterhead(letterheadCode); const logoDataUri = await loadLetterheadLogoDataUri(letterheadCode); - const pdfBuffer = await renderDeliveryNotePdf({ - header: { - deliveryNoteNumber, - dateIso: now, - orderNumber: repair.repair_number, - companyName: supplier.suppliername ?? "", - customerName: customerName, - }, - lines: [ - { - id: item.id, - description: itemDescription, - qty: 1, - }, - ], + // Map the repair status → human-readable label for the PDF's + // "Status" row in the item-details table. Mirrors the labels + // used on the detail page (and the existing PDF sample) so + // the printed document and the on-screen status match. + const statusLabels: Record = { + new: "Booked in", + assessing: "Sent for Strip & Quote", + quoted: "Quote Received", + approved: "Approved", + in_progress: "In Progress", + completed: "Completed", + cancelled: "Cancelled", + }; + const statusLabel = statusLabels[repair.status] ?? repair.status; + + const pdfBuffer = await renderRepairRequestPdf({ + repairNumber: repair.repair_number, + dateIso: new Date().toISOString(), + receivedIso: repair.created, + supplierName: supplier.suppliername ?? "", + itemDescription: repair.item_description ?? "", + serialNumber: repair.serial_no ?? "", + problemDescription: repair.fault_description ?? "", + status: statusLabel, + contactName: letterhead.display_name, + contactEmail: letterhead.email, letterhead, logoDataUri, }); - const filename = `${deliveryNoteNumber}.pdf`; + const filename = `RR-${repair.repair_number}.pdf`; const blob = new Blob([new Uint8Array(pdfBuffer)], { type: "application/pdf" }); - // Attach to the item's multi-file field (so the rest of the - // existing DN-download flow works) AND to the repair's - // `delivery_note` field (so the operator finds it on the - // repair detail page). - const itemForm = new FormData(); - itemForm.append("order_item_attachements", blob, filename); - await pb.collection(ORDER_ITEMS).update(item.id, itemForm); - // For the repair, we need to preserve the existing file list // (field is multi-file, maxSelect 10) and append the new file. - const previousFiles = Array.isArray(repair.delivery_note) - ? repair.delivery_note + const previousFiles = Array.isArray(repair.repair_request) + ? repair.repair_request : []; const repairForm = new FormData(); - for (const f of previousFiles) repairForm.append("delivery_note", f); - repairForm.append("delivery_note", blob, filename); + for (const f of previousFiles) repairForm.append("repair_request", f); + repairForm.append("repair_request", blob, filename); await pb.collection(REPAIRS).update(repairId, repairForm); - // ── 4. Status flip: new → assessing ────────────────────────── + // Status flip: new → assessing. The transition guard rejects + // illegal transitions (e.g. if the operator manually flipped + // the status to something else first). if (repair.status === "new") { assertTransition("new", "assessing"); await pb.collection(REPAIRS).update(repairId, { status: "assessing" }); } return { - deliveryNoteId: header.id, - deliveryNoteNumber, filename, }; } /* ============================================================================ - * File upload helpers (photos / quote / purchase_order) + * File upload helpers (photos / quote / purchase_order / repair_request) * ========================================================================== */ -export type RepairFileField = "photos" | "quote" | "purchase_order" | "delivery_note"; +export type RepairFileField = "photos" | "quote" | "purchase_order" | "repair_request"; export async function uploadRepairFile( repairId: string, diff --git a/src/lib/types.ts b/src/lib/types.ts index e77e48b..ebbf5fe 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1388,12 +1388,16 @@ export interface Repair extends BaseRecord { */ purchase_order?: string[]; /** - * Delivery-note PDF(s) attached to the repair. Multi-file field. - * Currently holds the "send to supplier for analysis" DN (the driver - * carries this with the machine to the supplier). Future: may also - * hold the "return to customer" DN when the repair is done. + * Repair Request PDF(s) attached to the repair. Multi-file field. + * Generated when the operator hits "Generate Repair Request" on the + * detail page — the PDF is the "please assess and quote" document + * the office prints and sends to the supplier with the machine. + * + * NOT a delivery note. Repairs do not flow through the customer-DN + * pipeline at all (the final "deliver to customer" step, if any, + * is handled separately by the existing /delivery-notes flow). */ - delivery_note?: string[]; + repair_request?: string[]; expand?: { customer?: Customer; company?: Company;