chore: update AGENTS.md
This commit is contained in:
parent
e3b1c09704
commit
058803d90f
14 changed files with 657 additions and 277 deletions
29
AGENTS.md
29
AGENTS.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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/*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
36
src/app/(app)/stock/stock-status-badge.tsx
Normal file
36
src/app/(app)/stock/stock-status-badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ const ALLOWED_FIELDS: RepairFileField[] = [
|
|||
"photos",
|
||||
"quote",
|
||||
"purchase_order",
|
||||
"delivery_note",
|
||||
"repair_request",
|
||||
];
|
||||
|
||||
function isAllowedField(value: string): value is RepairFileField {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/v1/repairs/[id]/generate-repair-request/route.ts
Normal file
38
src/app/api/v1/repairs/[id]/generate-repair-request/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
347
src/lib/pdf/repair-request-document.tsx
Normal file
347
src/lib/pdf/repair-request-document.tsx
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue