/** * Server-side repairs service. * * Owns the write surface for the `repairs` collection (the legacy * `pb-helpers.ts` only has the bare `getRepairs` / `createRepair` * client-side helpers — they're fine for the list page, but every * write that needs admin credentials + cross-collection writes * belongs here). * * The repair lifecycle: * * new → assessing → quoted → approved → in_progress → completed * └────► 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`) * ‑ "quoted" : supplier returned a quote (uploaded to * `repairs.quote`) * ‑ "approved" : customer approved the supplier's quote (and the * office cut a supplier PO — uploaded to * `repairs.purchase_order`) * ‑ "in_progress": supplier is working on the repair * ‑ "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 * `upload*` helpers that re-PATCH the row with the new file list, * mirroring the multi-file pattern PocketBase uses everywhere else. */ import { getAdminPocketBase } from "./pocketbase-admin"; import { renderDeliveryNotePdf } from "@/lib/pdf/render"; import { deliveryNoteLetterheadCode, fetchLetterhead, loadLetterheadLogoDataUri, } from "@/lib/letterhead"; import type { Customer, Repair, Supplier, User, } from "@/lib/types"; 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" | "assessing" | "quoted" | "approved" | "in_progress" | "completed" | "cancelled"; /** Allowed manual transitions. Centralised so the route + UI agree. */ const ALLOWED_TRANSITIONS: Record = { new: ["assessing", "quoted", "cancelled"], assessing: ["quoted", "cancelled"], quoted: ["approved", "in_progress", "cancelled"], approved: ["in_progress", "cancelled"], in_progress: ["completed", "cancelled"], completed: [], cancelled: [], }; function rejectTransition(from: RepairStatus, to: RepairStatus): never { const allowed = ALLOWED_TRANSITIONS[from] ?? []; const list = allowed.length > 0 ? allowed.join(", ") : "none"; throw new Error( `Cannot transition repair from "${from}" to "${to}" (allowed: ${list}).`, ); } function assertTransition(from: RepairStatus, to: RepairStatus): void { if (!ALLOWED_TRANSITIONS[from].includes(to)) rejectTransition(from, to); } /* ============================================================================ * List + read * ========================================================================== */ export interface ListRepairsOptions { page?: number; perPage?: number; search?: string; status?: RepairStatus | ""; sort?: string; } export interface ListRepairsResult { items: Repair[]; totalItems: number; totalPages: number; page: number; perPage: number; } /** * Searchable columns on the `repairs` collection. Mirrors the * legacy `getRepairs` filter in `pb-helpers.ts:1485` but expanded * with the new fields (serial_no, notes) so the operator can find * a repair by typing any of the human-readable bits. PB's `~` * operator is case-insensitive substring. */ const SEARCHABLE_COLUMNS = [ "repair_number", "item_description", "fault_description", "notes", "serial_no", ]; function buildSearchFilter(search: string): string | null { const trimmed = search.trim(); if (!trimmed) return null; const escaped = trimmed.replace(/"/g, '\\"'); return SEARCHABLE_COLUMNS.map((c) => `${c} ~ "${escaped}"`).join(" || "); } export async function listRepairs( options: ListRepairsOptions = {}, ): Promise { const pb = await getAdminPocketBase(); const page = options.page ?? 1; const perPage = options.perPage ?? 20; const sort = options.sort ?? "-created"; const clauses: string[] = []; const searchClause = buildSearchFilter(options.search ?? ""); if (searchClause) clauses.push(searchClause); if (options.status) { clauses.push(`status = "${options.status}"`); } const filter = clauses.join(" && "); const result = await pb.collection(REPAIRS).getList(page, perPage, { filter, sort, expand: "customer,company,order,supplier", }); return { items: result.items, totalItems: result.totalItems, totalPages: result.totalPages, page: result.page, perPage: result.perPage, }; } export async function getRepair(id: string): Promise { const pb = await getAdminPocketBase(); return pb.collection(REPAIRS).getOne(id, { expand: "customer,company,order,supplier", }); } /* ============================================================================ * Create / update * ========================================================================== */ export interface CreateRepairInput { customer?: string | null; company?: string | null; order?: string | null; supplier?: string | null; item_description: string; fault_description: string; notes?: string | null; serial_no?: string | null; photos?: string[]; } export async function createRepair( input: CreateRepairInput, ): Promise { const pb = await getAdminPocketBase(); if (!input.item_description?.trim()) { throw new Error("item_description is required."); } if (!input.fault_description?.trim()) { throw new Error("fault_description is required."); } const repairNumber = await generateRepairNumberServer(); const create: Record = { repair_number: repairNumber, status: "new", item_description: input.item_description.trim(), fault_description: input.fault_description.trim(), notes: input.notes ?? "", customer: input.customer || null, company: input.company || null, order: input.order || null, supplier: input.supplier || null, serial_no: input.serial_no ?? "", }; if (input.photos && input.photos.length > 0) { create.photos = input.photos; } return pb.collection(REPAIRS).create(create); } export interface UpdateRepairInput { status?: RepairStatus; notes?: string | null; fault_description?: string; item_description?: string; serial_no?: string | null; customer?: string | null; company?: string | null; order?: string | null; supplier?: string | null; } export async function updateRepair( id: string, input: UpdateRepairInput, ): Promise { const pb = await getAdminPocketBase(); const existing = await pb.collection(REPAIRS).getOne(id); const patch: Record = {}; if (input.status !== undefined) { assertTransition(existing.status, input.status); patch.status = input.status; } if (input.notes !== undefined) patch.notes = input.notes ?? ""; if (input.item_description !== undefined) patch.item_description = input.item_description; if (input.fault_description !== undefined) patch.fault_description = input.fault_description; if (input.serial_no !== undefined) patch.serial_no = input.serial_no ?? ""; if (input.customer !== undefined) patch.customer = input.customer || null; if (input.company !== undefined) patch.company = input.company || null; if (input.order !== undefined) patch.order = input.order || null; if (input.supplier !== undefined) patch.supplier = input.supplier || null; return pb.collection(REPAIRS).update(id, patch); } /* ============================================================================ * Repair number generator (server-side, atomic-ish) * ========================================================================== */ export async function generateRepairNumberServer( now: Date = new Date(), ): Promise { const pb = await getAdminPocketBase(); const year = now.getFullYear(); const month = String(now.getMonth() + 1).padStart(2, "0"); const prefix = `REP-${year}-${month}-`; const existing = await pb.collection(REPAIRS).getList(1, 1, { filter: `repair_number ~ "${prefix}"`, sort: "-repair_number", }); let seq = 1; if (existing.items.length > 0) { const last = existing.items[0].repair_number ?? ""; const lastSeq = parseInt(last.split("-").pop() || "0", 10); if (Number.isFinite(lastSeq) && lastSeq > 0) seq = lastSeq + 1; } return `${prefix}${String(seq).padStart(3, "0")}`; } /* ============================================================================ * "Send to supplier" — generate the DN 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; filename: string; } /** * Generate a delivery note for sending the machine to the supplier * for analysis. Three 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 * 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. */ export async function generateRepairDeliveryNote( repairId: string, ): Promise { const pb = await getAdminPocketBase(); const repair = await pb.collection(REPAIRS).getOne(repairId, { expand: "supplier,customer,company", }); if (!repair.supplier) { throw new Error( "Repair has no supplier set. Set a supplier on the repair before generating a delivery note.", ); } 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 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, }, ], letterhead, logoDataUri, }); const filename = `${deliveryNoteNumber}.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 repairForm = new FormData(); for (const f of previousFiles) repairForm.append("delivery_note", f); repairForm.append("delivery_note", blob, filename); await pb.collection(REPAIRS).update(repairId, repairForm); // ── 4. Status flip: new → assessing ────────────────────────── 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) * ========================================================================== */ export type RepairFileField = "photos" | "quote" | "purchase_order" | "delivery_note"; export async function uploadRepairFile( repairId: string, field: RepairFileField, file: File | Blob, filename: string, ): Promise { const pb = await getAdminPocketBase(); const existing = await pb.collection(REPAIRS).getOne(repairId); const previousFiles = Array.isArray(existing[field]) ? (existing[field] as string[]) : []; const form = new FormData(); for (const f of previousFiles) form.append(field, f); form.append(field, file, filename); const updated = await pb.collection(REPAIRS).update(repairId, form); // Auto-bump status: uploading a quote moves new/assessing → quoted. if (field === "quote" && (existing.status === "new" || existing.status === "assessing")) { assertTransition(existing.status, "quoted"); await pb.collection(REPAIRS).update(repairId, { status: "quoted" }); } return updated; } /** * Remove a single file from a multi-file field (PB stores the * field as a string[] of filenames). Used by the detail page's * "delete" button on each attachment thumbnail. */ export async function removeRepairFile( repairId: string, field: RepairFileField, filename: string, ): Promise { const pb = await getAdminPocketBase(); const existing = await pb.collection(REPAIRS).getOne(repairId); const files = Array.isArray(existing[field]) ? (existing[field] as string[]) : []; const filtered = files.filter((f) => f !== filename); if (filtered.length === files.length) { // Nothing to delete — return the row unchanged. return existing; } const form = new FormData(); for (const f of filtered) form.append(field, f); return pb.collection(REPAIRS).update(repairId, form); } /* ============================================================================ * Read helpers for the UI (small) * ========================================================================== */ /** * Lightweight list of customers for the "New Repair" dropdown. * Distinct from the full customer lookup — just enough to show the * name + company in the combobox. Pulled via the admin client; the * where-used UI is the office-side repairs page (server-authenticated). * * The `customer.expand.company` expand is required so the * `` combobox can render its "{Company} · {Customer}" * triplet. Without this expand the company column falls back to * "(no company)" for every row and the picker becomes useless on * mobile (the operator can't disambiguate "John Smith at Acme" from * "John Smith at Globex"). */ export async function listCustomersForRepairForm(): Promise { const pb = await getAdminPocketBase(); const result = await pb.collection(CUSTOMERS).getList(1, 500, { fields: "id,customer,email,number,company,expand", sort: "customer", expand: "company", }); return result.items; } export async function listSuppliersForRepairForm(): Promise< Array<{ id: string; suppliername: string }> > { const pb = await getAdminPocketBase(); const result = await pb.collection(SUPPLIERS).getList(1, 500, { fields: "id,suppliername", sort: "suppliername", }); return result.items.map((s) => ({ id: s.id, suppliername: s.suppliername, })); } /** * Order lookup used by the OrderSearch combobox. Mirrors the * /api/v1/quotes/lookup shape — search by order_number / customer / * company. We only return the fields the picker renders, with the * customer + company expanded so the dropdown shows * "{Company} · {Customer} · {order #}" (matches the sprint modal's * QuoteSearch shape). */ export async function searchOrdersForRepairForm( q: string, page = 1, perPage = 25, ): Promise<{ items: Array<{ id: string; order_number: string; customer_name: string; company_name: string; }>; totalItems: number; totalPages: number; page: number; perPage: number; }> { const pb = await getAdminPocketBase(); const trimmed = q.trim(); const filters: string[] = []; if (trimmed.length > 0) { const safe = trimmed.replace(/["()*]/g, "").slice(0, 64); if (safe.length > 0) { const escaped = safe.replace(/\\/g, "\\\\"); const ors = [ `order_number ~ "${escaped}"`, `expand.customer.customer ~ "${escaped}"`, `expand.company.company ~ "${escaped}"`, ]; filters.push(`(${ors.join(" || ")})`); } } const result = await pb .collection("orders") .getList(page, perPage, { filter: filters.join(" && "), sort: "-created", fields: "id,order_number,customer,company,expand", expand: "customer,company", }) .catch((err) => { console.error("[searchOrdersForRepairForm] failed:", err); return { items: [], page: 1, perPage, totalItems: 0, totalPages: 0 }; }); const items = result.items.map((o) => { const expand = (o as { expand?: { customer?: { customer?: string }; company?: { company?: string } } }).expand; return { id: o.id, order_number: (o as { order_number?: string }).order_number ?? "", customer_name: expand?.customer?.customer ?? "", company_name: expand?.company?.company ?? "", }; }); return { items, totalItems: result.totalItems, totalPages: result.totalPages, page: result.page, perPage: result.perPage, }; }