WFDASH-WEBAPP/src/lib/server/repairs-service.ts

629 lines
21 KiB
TypeScript
Raw Normal View History

/**
* 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,
};
}