643 lines
22 KiB
TypeScript
643 lines
22 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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";
|
|||
|
|
|
|||
|
|
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,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ============================================================================
|
|||
|
|
* Misc helpers
|
|||
|
|
* ========================================================================== */
|
|||
|
|
|
|||
|
|
/** Resolve the user id of the operator (for the audit stamp). */
|
|||
|
|
export async function getMe(): Promise<{ id: string; name?: string } | null> {
|
|||
|
|
const pb = await getAdminPocketBase();
|
|||
|
|
try {
|
|||
|
|
const me = await pb.collection(USERS).getAuthRecord();
|
|||
|
|
return me ? { id: (me as User).id, name: (me as User).name } : null;
|
|||
|
|
} catch {
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
}
|