WFDASH-WEBAPP/src/lib/server/repairs-service.ts
Dave fb283765fb fix(repair-details): update file handling and type definitions
- Modified the file handling in the repair details page to use the correct repair ID and simplified the key assignment for photo files.
- Updated the API route to cast the status parameter to the new RepairStatus type for improved type safety.
- Expanded the RepairFileField type to include "delivery_note" for better file management.
- Removed unused helper functions to clean up the codebase and improve maintainability.
2026-08-21 01:13:44 +02:00

628 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<RepairStatus, RepairStatus[]> = {
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<ListRepairsResult> {
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<Repair>(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<Repair> {
const pb = await getAdminPocketBase();
return pb.collection(REPAIRS).getOne<Repair>(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<Repair> {
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<string, unknown> = {
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<Repair>(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<Repair> {
const pb = await getAdminPocketBase();
const existing = await pb.collection(REPAIRS).getOne<Repair>(id);
const patch: Record<string, unknown> = {};
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<Repair>(id, patch);
}
/* ============================================================================
* Repair number generator (server-side, atomic-ish)
* ========================================================================== */
export async function generateRepairNumberServer(
now: Date = new Date(),
): Promise<string> {
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<Repair>(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<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;
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<GenerateRepairDeliveryNoteResult> {
const pb = await getAdminPocketBase();
const repair = await pb.collection(REPAIRS).getOne<Repair>(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<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 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<Repair> {
const pb = await getAdminPocketBase();
const existing = await pb.collection(REPAIRS).getOne<Repair>(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<Repair>(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<Repair> {
const pb = await getAdminPocketBase();
const existing = await pb.collection(REPAIRS).getOne<Repair>(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<Repair>(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
* `<CustomerSearch>` 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<Customer[]> {
const pb = await getAdminPocketBase();
const result = await pb.collection(CUSTOMERS).getList<Customer>(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<Supplier>(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,
};
}