chore: update AGENTS.md

This commit is contained in:
Dave 2026-09-02 01:17:05 +02:00
parent e3b1c09704
commit 058803d90f
14 changed files with 657 additions and 277 deletions

View file

@ -40,6 +40,35 @@ Pattern in code:
disambiguate from other attachments on the same item
<!-- END:pb-attachments-pattern -->
<!-- BEGIN:file-url-paths -->
# 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 `<a href={...}>` 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.
<!-- END:file-url-paths -->
<!-- BEGIN:data-layer-discipline -->
# Data access layer discipline

View file

@ -114,7 +114,7 @@ export default function RepairDetailPage({
status: "new" as (typeof ALL_STATUSES)[number],
});
const dnUploadRef = useRef<HTMLInputElement>(null);
const repairRequestUploadRef = useRef<HTMLInputElement>(null);
const quoteUploadRef = useRef<HTMLInputElement>(null);
const poUploadRef = useRef<HTMLInputElement>(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<string, string> = {
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({
</div>
<p className="text-muted-foreground">{repair.item_description}</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{repair.status === "new" && (
<Button onClick={handleSendToSupplier} disabled={generating}>
{generating ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Truck className="mr-2 h-4 w-4" />
)}
Send to supplier
</Button>
)}
{(repair.status === "approved" || repair.status === "in_progress") && (
<Button
onClick={async () => {
setEdit((p) => ({ ...p, status: "completed" }));
await handleSave();
}}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Mark complete
</Button>
)}
{repair.status !== "completed" && repair.status !== "cancelled" && (
<Button
variant="outline"
onClick={async () => {
if (!confirm("Cancel this repair?")) return;
setEdit((p) => ({ ...p, status: "cancelled" }));
await handleSave();
}}
>
<XCircle className="mr-2 h-4 w-4" />
Cancel
</Button>
)}
<Button
variant={editing ? "default" : "outline"}
onClick={() => (editing ? handleSave() : setEditing(true))}
disabled={saving}
>
{saving ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : editing ? (
<Save className="mr-2 h-4 w-4" />
) : (
<Pencil className="mr-2 h-4 w-4" />
<div className="flex flex-col items-end gap-1">
<div className="flex flex-wrap items-center gap-2">
{repair.status === "new" && (
<Button onClick={handleSendToSupplier} disabled={generating}>
{generating ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : (
<Truck className="mr-2 h-4 w-4" />
)}
Generate Repair Request PDF
</Button>
)}
{editing ? "Save" : "Edit"}
</Button>
{(repair.status === "approved" || repair.status === "in_progress") && (
<Button
onClick={async () => {
setEdit((p) => ({ ...p, status: "completed" }));
await handleSave();
}}
>
<CheckCircle2 className="mr-2 h-4 w-4" />
Mark complete
</Button>
)}
{repair.status !== "completed" && repair.status !== "cancelled" && (
<Button
variant="outline"
onClick={async () => {
if (!confirm("Cancel this repair?")) return;
setEdit((p) => ({ ...p, status: "cancelled" }));
await handleSave();
}}
>
<XCircle className="mr-2 h-4 w-4" />
Cancel
</Button>
)}
<Button
variant={editing ? "default" : "outline"}
onClick={() => (editing ? handleSave() : setEditing(true))}
disabled={saving}
>
{saving ? (
<RefreshCw className="mr-2 h-4 w-4 animate-spin" />
) : editing ? (
<Save className="mr-2 h-4 w-4" />
) : (
<Pencil className="mr-2 h-4 w-4" />
)}
{editing ? "Save" : "Edit"}
</Button>
</div>
{repair.status === "new" ? (
<p className="text-xs text-muted-foreground">
Print the generated PDF and send it to the supplier with the machine.
</p>
) : null}
</div>
</div>
@ -574,13 +581,13 @@ export default function RepairDetailPage({
</CardHeader>
<CardContent className="space-y-4">
<DocumentRow
field="delivery_note"
label="Delivery note (driver to supplier)"
files={deliveryNoteFiles}
field="repair_request"
label="Repair Request (print and send to supplier)"
files={repairRequestFiles}
repairId={repair.id}
inputRef={dnUploadRef}
onUpload={(file) => handleUploadFile("delivery_note", file)}
onDelete={(f) => handleDeleteFile("delivery_note", f)}
inputRef={repairRequestUploadRef}
onUpload={(file) => handleUploadFile("repair_request", file)}
onDelete={(f) => handleDeleteFile("repair_request", f)}
/>
<Separator />
<DocumentRow
@ -700,7 +707,7 @@ function DocumentRow({
emailSubject,
emailBody,
}: {
field: "delivery_note" | "quote" | "purchase_order";
field: "repair_request" | "quote" | "purchase_order";
label: string;
files: string[];
repairId: string;
@ -738,7 +745,7 @@ function DocumentRow({
ref={inputRef}
type="file"
accept={
field === "delivery_note" || field === "quote" || field === "purchase_order"
field === "repair_request" || field === "quote" || field === "purchase_order"
? "application/pdf,image/*"
: "image/*"
}

View file

@ -341,8 +341,8 @@ export default function RepairsPage() {
label: "Docs",
render: (item: RepairsListItem) => {
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) => (
<a
key={f.field + f.name}
href={`/api/files/repairs/${item.id}/${f.name}`}
href={`/api/v1/files/repairs/${item.id}/${f.name}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex h-7 w-7 items-center justify-center rounded hover:bg-muted"

View file

@ -47,7 +47,7 @@ import {
getStockStatus,
STOCK_STATUS_STYLES,
} from "@/lib/stock-status";
import { StockStatusBadge } from "../page";
import { StockStatusBadge } from "../stock-status-badge";
import { STOCK_REASON_VALUES, type StockReason, type StockPurchaseItem } from "@/lib/types";
import type { StockItem, StockLedger } from "@/lib/types";

View file

@ -739,33 +739,10 @@ function PricingEditCells({
}
/* ============================================================================
* 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.
* (Kept local to this file for now — promote to `components/ui/` when
* a third caller appears.)
* StockStatusBadge moved to `./stock-status-badge.tsx` so this file only
* exports `default` (the page component) and stays compatible with
* Next 16's Page-export type check.
* ========================================================================== */
export function StockStatusBadge({ status }: { status: StockStatus }) {
const style = STOCK_STATUS_STYLES[status];
return (
<Badge
className="text-xs font-medium gap-1"
style={{
background: `${style.hex}1F`, // ~12% alpha
color: style.hex,
boxShadow: `inset 0 0 0 1px ${style.ring}`,
}}
>
<span
aria-hidden
className="h-1.5 w-1.5 rounded-full"
style={{ background: style.hex }}
/>
{style.label}
</Badge>
);
}
/* ============================================================================
* CreateStockItemModal — minimal "new stock item" form.

View file

@ -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 (
<Badge
className="text-xs font-medium gap-1"
style={{
background: `${style.hex}1F`, // ~12% alpha
color: style.hex,
boxShadow: `inset 0 0 0 1px ${style.ring}`,
}}
>
<span
aria-hidden
className="h-1.5 w-1.5 rounded-full"
style={{ background: style.hex }}
/>
{style.label}
</Badge>
);
}

View file

@ -12,7 +12,7 @@ const ALLOWED_FIELDS: RepairFileField[] = [
"photos",
"quote",
"purchase_order",
"delivery_note",
"repair_request",
];
function isAllowedField(value: string): value is RepairFileField {

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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

View file

@ -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 `<QuoteDocument>` 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 `<RepairRequestDocument>` 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<Buffer> {
const instance = pdf(<RepairRequestDocument {...props} />);
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

View file

@ -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 (
<Document
title={`Repair Request ${repairNumber}`}
author={letterhead.display_name}
subject={`Repair Request for ${supplierName || "(supplier)"}`}
>
<Page size="A4" style={styles.page}>
{/* Header — same pattern as the quote / PO / DN: company info
on the left, logo absolutely-positioned on the right. */}
<View style={styles.headerCompany}>
<Text style={styles.companyName}>{letterhead.display_name} (Pty) Ltd</Text>
<Text style={styles.body}>{letterhead.address}</Text>
<Text style={styles.body}>{letterhead.phone}</Text>
<Text style={styles.body}>{letterhead.email}</Text>
<Text style={styles.body}>{`VAT: ${letterhead.vat_number}`}</Text>
{letterhead.registration_number ? (
<Text style={styles.body}>{`Business ID: ${letterhead.registration_number}`}</Text>
) : null}
</View>
{logoDataUri ? (
<View style={styles.logoAbsolute}>
<Image src={logoDataUri} style={styles.logo} />
</View>
) : null}
{/* Big "REPAIR REQUEST" title. Sibling to QUOTATION / PURCHASE
ORDER / DELIVERY NOTE on the family of documents. */}
<Text style={styles.title}>REPAIR REQUEST</Text>
{/* Supplier block — prominent because it's the recipient of
the request. Mirrors the sample's "SUPPLIER: <name>"
block. */}
<View style={styles.supplierBlock}>
<Text style={styles.supplierLabel}>SUPPLIER:</Text>
<Text style={styles.supplierName}>{supplierName || "(no supplier)"}</Text>
</View>
{/* Repair No / Date / Received — three-up row. Sample uses
these labels verbatim. */}
<View style={styles.metaRow}>
<View style={styles.metaCol}>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Repair No: </Text>
{repairNumber}
</Text>
</View>
<View style={styles.metaCol}>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Date: </Text>
{formatDateLong(dateIso)}
</Text>
</View>
<View style={[styles.metaCol, styles.metaColRight]}>
<Text style={styles.metaLine}>
<Text style={styles.bold}>Received: </Text>
{formatDateLong(receivedIso)}
</Text>
</View>
</View>
{/* 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. */}
<Text style={styles.sectionTitle}>REPAIR ITEM DETAILS</Text>
<View style={styles.detailTable}>
<View style={[styles.detailHeaderRow, { backgroundColor: accent }]}>
<Text style={[styles.detailHeaderCell, styles.colItem]}>Item</Text>
<Text style={[styles.detailHeaderCell, styles.colDetails]}>Details</Text>
</View>
<View style={styles.detailRow}>
<Text style={[styles.detailCell, styles.colItem]}>Description</Text>
<Text style={[styles.detailCell, styles.colDetails, styles.preWrap]}>
{itemDescription || "(no description)"}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={[styles.detailCell, styles.colItem]}>Serial Number</Text>
<Text style={[styles.detailCell, styles.colDetails]}>
{serialNumber || "Not provided"}
</Text>
</View>
<View style={styles.detailRow}>
<Text style={[styles.detailCell, styles.colItem]}>Problem Description</Text>
<Text style={[styles.detailCell, styles.colDetails, styles.preWrap]}>
{problemDescription || "(no problem description)"}
</Text>
</View>
<View style={[styles.detailRow, styles.detailRowLast]}>
<Text style={[styles.detailCell, styles.colItem]}>Status</Text>
<Text style={[styles.detailCell, styles.colDetails]}>{status || "(no status)"}</Text>
</View>
</View>
{/* 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. */}
<Text style={styles.sectionTitle}>SERVICE REQUEST</Text>
<Text style={styles.body}>
We kindly request you to examine the item described above and provide a detailed
quotation for repair. Please include:
</Text>
<View style={styles.numberedList}>
<Text style={styles.numberedItem}>1. Detailed assessment of the problem</Text>
<Text style={styles.numberedItem}>2. Parts required for repair</Text>
<Text style={styles.numberedItem}>3. Labor costs</Text>
<Text style={styles.numberedItem}>4. Estimated timeline for completion</Text>
<Text style={styles.numberedItem}>5. Warranty information</Text>
</View>
<Text style={[styles.body, styles.thankYou]}>
Thank you for your prompt attention to this matter.
</Text>
{/* 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". */}
<View style={styles.contactStrip}>
<Text style={styles.contactLabel}>CONTACT</Text>
<Text style={styles.body}>
{`For queries: ${contactName || "the office"} | Email: ${contactEmail || "—"}`}
</Text>
</View>
{/* Letterhead footer (e.g. "Thank you for your business.").
Same pattern as the quote / PO / DN. */}
{letterhead.footer_text ? (
<Text style={styles.footerText}>{letterhead.footer_text}</Text>
) : null}
</Page>
</Document>
);
}
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,
},
});

View file

@ -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<string> {
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<GenerateRepairDeliveryNoteResult> {
): Promise<GenerateRepairRequestResult> {
const pb = await getAdminPocketBase();
const repair = await pb.collection(REPAIRS).getOne<Repair>(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<Supplier>(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<Customer>(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<string, string> = {
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,

View file

@ -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;