/** * Server-side helpers for the /deliveries page. * * Two operations: * 1. `loadItems` — bulk flip `invoiced → with_driver` and stamp * `loaded_at` to now. Used by the "To Load" tab. * 2. `checkInItems` — bulk flip `with_driver → checked_in`, * write the typed `delivery_reference`, and stamp * `checked_in_at` + `checked_in_by` (current authed user). * Used by the "Back from Driver" tab. * * Both are tolerant: an item that's already in the target state is * silently skipped (idempotent on retry), and an item that's in an * unexpected source state is also skipped + reported in the result's * `skipped` array. The UI uses the skipped list to surface a * "some items couldn't be moved" toast so the operator can re-check. * * Why a service module instead of inline route logic: the bulk * update is a non-trivial loop over `pb.collection().update()` per * id — a single 500 from one bad row would otherwise abort the * whole batch. Centralising the loop lets us add per-row error * handling + a structured result without bloating the route file. */ import { getAdminPocketBase } from "./pocketbase-admin"; import { getOrderItemsCollectionName } from "./orders-service"; import type { DeliveryNote } from "@/lib/types"; const DELIVERY_NOTES = "delivery_notes"; export type DeliveryListTab = "to_load" | "back_from_driver" | "checked_in"; /** * The PB item_status values each tab filters on. * * to_load → invoiced (waiting for warehouse to load) * back_from_driver → with_driver (driver brought the paper back, * operator needs to type the reference) * checked_in → checked_in (operator verified, waiting for * n8n to upload the signed copy + flip to * `completed`) * * `completed` is intentionally NOT a tab here — those items are * considered "done" and the office doesn't need to act on them. * The 4th state will get its own tab once the n8n workflow ships. */ export const TAB_TO_ITEM_STATUS: Record = { to_load: ["invoiced"], back_from_driver: ["with_driver"], checked_in: ["checked_in"], }; /** * The PB delivery_notes.status values each tab filters on for the * DN rows. The DN paper tracks the same three stops as the items, * with `draft` added to the To Load tab so every DN that hasn't * been invoiced surfaces on /deliveries (the operator's request): * * to_load → draft, sent * (paper on the desk; needs to * be sent + loaded for driver) * back_from_driver → loaded, po_received * (paper is with the driver — * either on the way out or * back with the customer PO; * signed copy still pending) * checked_in → invoiced (invoice was created; signed * copy still pending) * * `invoiced` is excluded from the To Load tab — once the invoice * is created, the DN shows on the Checked In tab instead. Items * take over from there. `completed` (signed copy received) is * excluded everywhere — same reason completed items are excluded. * `cancelled` is excluded everywhere too: those DNs are out of * office scope. */ export const TAB_TO_DN_STATUS: Record = { to_load: ["draft", "sent"], back_from_driver: ["loaded", "po_received"], checked_in: ["invoiced"], }; export function isDeliveryListTab(s: string): s is DeliveryListTab { return s === "to_load" || s === "back_from_driver" || s === "checked_in"; } export type DeliveryListResult = { items: Array<{ id: string; description: string; quantity: number | null; selling_price: number | null; cost_price: number | null; item_status: string; delivery_reference: string | null; /** * Human-readable DN id (e.g. `DN26091405`) this item * belongs to. The /deliveries page's DN expand panel * joins on this field against each delivery note's * `delivery_note_number`. The PB schema's relation * field `delivery_note` exists but nothing writes to * it; the text column is the actual join key. */ delivery_note_number: string | null; /** * QuickBooks invoice number, extracted from the nested * `order_item_details.invoice.number` JSON. Null for rows * that haven't been through the QB invoicing flow yet * (older rows, or counter-pickup ad-hoc items that were * never sent to QB). */ invoice_number: string | null; loaded_at: string | null; checked_in_at: string | null; order: { id: string; order_number: string; customer_id: string; customer_name: string | null; company_id: string; company_name: string | null; } | null; checked_in_by: { id: string; name?: string; email?: string } | null; }>; /** * Delivery-note rows that ride the same tab mapping as the items * (sent / po_received / invoiced → to_load / back_from_driver / * checked_in). The operator needs to track the signed-copy return * for every DN whose paper is out — that's the whole reason this * surface exists on /deliveries. */ delivery_notes: Array<{ id: string; delivery_note_number: string; status: string; customer_po: string | null; delivery_date: string | null; signed_copy: string[] | null; order: { id: string; order_number: string; customer_id: string; customer_name: string | null; company_id: string; company_name: string | null; } | null; }>; /** * Items linked to the page's DNs regardless of `item_status`. * Feeds the DN expand panel so it shows every item on the DN * (same scope as the full /delivery-notes/{id} page). The * main items list on the page uses `items` only — `dn_items` * is never rendered as a top-level row. */ dn_items: Array<{ id: string; description: string; quantity: number | null; selling_price: number | null; cost_price: number | null; item_status: string; delivery_reference: string | null; delivery_note_number: string | null; invoice_number: string | null; loaded_at: string | null; checked_in_at: string | null; order: { id: string; order_number: string; customer_id: string; customer_name: string | null; company_id: string; company_name: string | null; } | null; checked_in_by: { id: string; name?: string; email?: string } | null; }>; totalItems: number; totalDeliveryNotes: number; page: number; perPage: number; totalPages: number; }; /** * Load the order_items for one of the three tabs, with the * expanded order (number + company + customer id) and the * operator who checked the item in (for the Checked In tab). * * Why we return a flat list instead of pre-grouping by customer: * the client already has the orders / customers collections loaded * for the dashboard; grouping on the client keeps the server * response shape simple + lets the operator re-sort the page * without a round trip. */ export async function listDeliveryItems(params: { tab: DeliveryListTab; page?: number; perPage?: number; search?: string; }): Promise { const pb = await getAdminPocketBase(); const page = params.page || 1; const perPage = params.perPage || 200; // one page covers the // office for a full week // in practice const statuses = TAB_TO_ITEM_STATUS[params.tab]; const statusFilter = statuses .map((s) => `item_status = "${s}"`) .join(" || "); // The order_items.order relation is optional as of the // 2026-07-19-delivery-notes-v3 migration (ad-hoc counter-pickup // rows have no parent order). We still want those rows on the // page — they came off the shelf with no order backing them, // and they still need to be loaded for the customer. So we // DON'T filter on `order != ""` here. // // Search: a single search box hits everything the office // might type. The fields covered are split between server-side // (where PB's filter can reach them) and client-side (where // the join is too deep for PB to filter on directly): // // Server-side (PB filter): // - `description` — text, `~` substring // - `order_item_details.invoice.number` // — JSON path, `~` substring; // the QB invoice number is // nested inside the // `order_item_details` JSON // column. PB's dot-notation // filter handles it. // - `selling_price` — number, exact match. // Activated only when the // search string parses as a // number (see below). // - `cost_price` — number, exact match. // // Client-side (the page filters again after fetch): // - `order.order_number` — 2nd-level expand; PB can't // filter on it directly in // this version. The page // does a case-insensitive // `includes` over the // already-fetched rows. // - `order.customer.customer` — same reason. The customer // name is on the `customers` // collection, 2 relations // deep from order_items. // // Why not push everything to the server: PB's `~` on JSON // paths and numbers works fine, but it can't reach across // 2 expanded relations. A `getList(200)` then a 5ms in-memory // filter is the same wire cost as a complex server filter that // PB might not be able to plan an index for. const search = (params.search || "").trim(); let searchFilter = ""; if (search) { const q = search.replace(/"/g, '\\"'); // PB `~` is case-insensitive for ASCII; for our purposes // (descriptions are mostly English / numbers) that's fine. const textClauses = [ `description ~ "${q}"`, // The invoice number lives in the `order_item_details` // JSON column. PB's dot-notation filter lets us reach // it without expanding a relation. `order_item_details.invoice.number ~ "${q}"`, ]; // If the search string parses cleanly as a number, also // match `selling_price` and `cost_price` exactly. We use // `=` (not `~`) because PB's number filter doesn't support // substring — "120" matches 120.0 but not 120.50. That's // intentional: a partial number match would be confusing // (typing "1" would match 100s of lines). // // parseFloat is permissive ("12.5abc" → 12.5); we // additionally check that the entire string is a valid // number via the regex. This avoids `selling_price = 12.5` // when the user typed "12.5 boxes" — the regex rejects it // and we skip the price match. const numericMatch = search.match(/^-?\d+(\.\d+)?$/); if (numericMatch) { textClauses.push(`selling_price = ${search}`); textClauses.push(`cost_price = ${search}`); } searchFilter = `(${textClauses.join(" || ")})`; } const filter = searchFilter ? `(${statusFilter}) && ${searchFilter}` : `(${statusFilter})`; // Two parallel queries: items (existing flow) + delivery_notes // (the new signed-copy tracking). PB doesn't have a UNION, so // we run both and merge in the service. `Promise.all` keeps // the latency at the slower of the two rather than summing. // // The DN query has its own server-side filter so the search box // can find DNs by delivery_note_number / customer_po too — same // trade-off as the items query: PB can reach text fields on the // root collection but not 2nd-level expanded joins, so the // remaining order / customer / company name filtering happens // client-side in the page. const dnStatuses = TAB_TO_DN_STATUS[params.tab]; const dnStatusFilter = dnStatuses .map((s) => `status = "${s}"`) .join(" || "); let dnSearchFilter = ""; if (search) { const q = search.replace(/"/g, '\\"'); const dnTextClauses = [ `delivery_note_number ~ "${q}"`, `customer_po ~ "${q}"`, ]; dnSearchFilter = `(${dnTextClauses.join(" || ")})`; } const dnFilter = dnSearchFilter ? `(${dnStatusFilter}) && ${dnSearchFilter}` : `(${dnStatusFilter})`; const [itemsResult, dnsResult] = await Promise.all([ pb.collection(getOrderItemsCollectionName()).getList(page, perPage, { filter, sort: "-updated", // `order.customer` and `order.company` are 2nd-level // expands: we want the order header's customer/company // relations resolved in the same fetch so the page can // render "Customer X — Order ORD-..." without a second // round trip per row. PB's expand syntax is dot-separated // paths; commas separate siblings. // // IMPORTANT: when `fields=` is set, PB also whitelists // top-level response keys — and the `expand` block is a // top-level key. If the expanded relation isn't added to // the whitelist (as `expand.`), PB silently drops the // whole `expand` object. Without `expand.order` here, every // row comes back with `r.expand` undefined and the page // falls into its "(NO ORDER) / Counter pickup" bucket for // all items. Verified live against PB 2026-08-12. expand: "order.customer,order.company,checked_in_by", fields: "id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," + // `delivery_note_number` is the human-readable DN id (e.g. // `DN26091405`). The PB schema also has a relation field // `delivery_note` but nothing currently writes to it — the // join key everywhere else (the full DN detail page, the // delivery-notes service) is the text column. We only // request the column that's actually populated. "delivery_note_number,loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," + "expand.order,expand.order.customer,expand.order.company,expand.checked_in_by", }), pb.collection(DELIVERY_NOTES).getList(page, perPage, { filter: dnFilter, sort: "-updated", expand: "order.customer,order.company", fields: "id,delivery_note_number,status,customer_po,delivery_date,signed_copy," + "order,updated,expand.order,expand.order.customer,expand.order.company", }), ]); // Query #3 — every order_item that has a `delivery_note_number` // set to one of the DNs in this response, regardless of the // item's own `item_status`. The DN expand panel needs these so // it can show every item on the DN — same scope as the full // /delivery-notes/{id} page (which joins by `delivery_note_number` // without filtering on item_status). Without this, a DN whose // items are still in `delivery_note` status (paper sent, invoice // not yet created) shows "no items loaded" on the expand panel // even though the same DN has items on the full detail page. // The main items list (query #1) is what drives the page-level // bulk actions; this third query only feeds the per-DN expand // panel — so its status-unfiltered scope doesn't leak into the // bulk-action rows. Runs after the first two so we have the DN // numbers in hand; the wire cost is one extra PB read on top // of the first two parallel queries. // // PB rejects filter strings longer than ~8 KB. ~40 chars per // `delivery_note_number = "DN..." || ` — 200 candidates keeps // us under the limit. Same pattern as // order-delivery-note-link-service.ts. const dnNumbers = (dnsResult.items as unknown as Array<{ delivery_note_number?: string }>) .map((d) => d.delivery_note_number) .filter((n): n is string => typeof n === "string" && n.length > 0); let dnItemsResult: { items: Array>; totalItems: number } = { items: [], totalItems: 0, }; if (dnNumbers.length > 0) { const batch = dnNumbers.slice(0, 200); const dnItemsFilter = batch .map((n) => `delivery_note_number = "${n.replace(/"/g, '\\"')}"`) .join(" || "); dnItemsResult = (await pb .collection(getOrderItemsCollectionName()) .getList(1, perPage, { filter: dnItemsFilter, sort: "-updated", expand: "order.customer,order.company", fields: "id,description,quantity,unit,selling_price,cost_price,item_status,delivery_reference," + "delivery_note_number,loaded_at,checked_in_at,checked_in_by,order,updated,order_item_details," + "expand.order,expand.order.customer,expand.order.company", })) as unknown as { items: Array>; totalItems: number }; } type Row = { id: string; description: string; quantity: number | null; selling_price: number | null; cost_price: number | null; item_status: string; delivery_reference: string | null; /** * Human-readable DN id (e.g. `DN26091405`). The /deliveries * page's DN expand panel uses this to filter the items that * belong to a given DN — the same join key the full DN detail * page uses (see `pb-helpers.getDeliveryNoteItems`). The * relation field `delivery_note` in the schema is never * populated; this is the column we actually care about. */ delivery_note_number: string | null; unit: string | null; loaded_at: string | null; checked_in_at: string | null; checked_in_by: string | null; order: string | null; updated: string; /** * JSON column. The shape is `{ invoice: { number, date, ... }, * print: { ... } }` — only the invoice.number is read for the * /deliveries page. Defensive-typed as `unknown` because PB * returns whatever the operator / n8n wrote; a missing or * malformed JSON should never crash the page. */ order_item_details?: unknown; expand?: { order?: { id: string; order_number: string; company: string; customer: string; expand?: { customer?: { id: string; customer?: string }; company?: { id: string; company?: string }; }; }; checked_in_by?: { id: string; name?: string; email?: string }; }; }; // Read the QB invoice number from the nested JSON. The shape // is `order_item_details.invoice.number` — we tolerate missing // keys at every level because older rows (pre-QB-integration) // don't have it, and the page renders "—" in that case. function readInvoiceNumber(details: unknown): string | null { if (!details || typeof details !== "object") return null; const inv = (details as { invoice?: unknown }).invoice; if (!inv || typeof inv !== "object") return null; const n = (inv as { number?: unknown }).number; return typeof n === "string" && n.length > 0 ? n : null; } const mapRow = (r: Row) => ({ id: r.id, description: r.description ?? "", quantity: r.quantity ?? null, selling_price: r.selling_price ?? null, cost_price: r.cost_price ?? null, item_status: r.item_status, delivery_reference: r.delivery_reference ?? null, delivery_note_number: r.delivery_note_number ?? null, unit: r.unit ?? null, invoice_number: readInvoiceNumber(r.order_item_details), loaded_at: r.loaded_at ?? null, checked_in_at: r.checked_in_at ?? null, order: r.expand?.order ? { id: r.expand.order.id, order_number: r.expand.order.order_number, // Customer + company names. `customer` / `company` on the // order row is the relation id; the expand gives us the // record. We use `?.customer` (singular) because that's // the field name on the `customers` collection (not // `name`). Same for `companies.company`. Ad-hoc rows // (no parent order) get nulls and the page renders // "Counter pickup" or similar. customer_id: r.expand.order.customer, customer_name: r.expand.order.expand?.customer?.customer ?? null, company_id: r.expand.order.company, company_name: r.expand.order.expand?.company?.company ?? null, } : null, checked_in_by: r.expand?.checked_in_by ? { id: r.expand.checked_in_by.id, name: r.expand.checked_in_by.name, email: r.expand.checked_in_by.email, } : null, }); const items = (itemsResult.items as unknown as Row[]).map(mapRow); // Map the third query (DN-linked items, any status) through the // same mapper. Deduped by item id so the per-DN expand panel // doesn't render the same row twice when an item already // appears in `items` (status-filtered) AND `dn_items` // (DN-linked, any status). A `Map` keyed by item id preserves // the first occurrence — the status-filtered one, which is the // version the page renders in the main list. The expand panel // looks up by item id via `dnItemsById` and never sees a dup. const dnItemsRaw = (dnItemsResult.items as unknown as Row[]).map(mapRow); const dnItemsById = new Map(); for (const it of items) dnItemsById.set(it.id, it); for (const it of dnItemsRaw) if (!dnItemsById.has(it.id)) dnItemsById.set(it.id, it); const dn_items = [...dnItemsById.values()]; // Map DN rows. The expand shape mirrors the items query so the // page can group both row kinds by customer → order with the // same renderer. type DNRow = { id: string; delivery_note_number: string; status: string; customer_po: string | null; delivery_date: string | null; signed_copy: string[] | null; order: string | null; expand?: { order?: { id: string; order_number: string; company: string; customer: string; expand?: { customer?: { id: string; customer?: string }; company?: { id: string; company?: string }; }; }; }; }; const delivery_notes = (dnsResult.items as unknown as DNRow[]).map((r) => ({ id: r.id, delivery_note_number: r.delivery_note_number ?? "", status: r.status, customer_po: r.customer_po ?? null, delivery_date: r.delivery_date ?? null, signed_copy: r.signed_copy ?? null, order: r.expand?.order ? { id: r.expand.order.id, order_number: r.expand.order.order_number, customer_id: r.expand.order.customer, customer_name: r.expand.order.expand?.customer?.customer ?? null, company_id: r.expand.order.company, company_name: r.expand.order.expand?.company?.company ?? null, } : null, })); return { items, delivery_notes, /** * Items linked to the page's DNs regardless of `item_status`. * Feeds the DN expand panel so it shows every item on the * DN (same scope as the full /delivery-notes/{id} page). * The main items list on the page uses `items` only — this * field is never rendered as a top-level row. */ dn_items, totalItems: itemsResult.totalItems, totalDeliveryNotes: dnsResult.totalItems, page: itemsResult.page, perPage: itemsResult.perPage, totalPages: itemsResult.totalPages, }; } export type LoadResult = { loaded: string[]; skipped: Array<{ id: string; reason: string }>; }; const MAX_BATCH = 200; /** * Bulk flip `invoiced → with_driver` and stamp `loaded_at = now()`. * Used by the "To Load" tab's "Load N selected" buttons. * * Idempotent on the source state: an item that's already * `with_driver` (or anything else other than `invoiced`) is skipped * with a `reason` instead of being moved. This makes the call safe * to retry on a flaky network without double-stamping `loaded_at`. * * We do not enforce a single source state for the whole batch — if * the operator checks 5 items and 1 of them is already in * `with_driver`, we move the 4 and report the 1. The UI uses the * `skipped` array to surface "4 loaded, 1 was already with the * driver — skipped" so the operator can re-check. */ export async function loadItems(itemIds: string[]): Promise { const pb = await getAdminPocketBase(); const collection = getOrderItemsCollectionName(); if (itemIds.length === 0) { return { loaded: [], skipped: [] }; } if (itemIds.length > MAX_BATCH) { throw new Error(`Too many items in one batch (${itemIds.length} > ${MAX_BATCH}).`); } // Snapshot the source state per id so we don't read+write the // same row from the loop (PB will return the post-update value // otherwise). The `fields` param keeps the fetch cheap. const orFilter = itemIds.map((id) => `id="${id}"`).join(" || "); const rows = await pb.collection(collection).getFullList<{ id: string; item_status: string; }>({ filter: orFilter, fields: "id,item_status", }); const stateById = new Map(); for (const r of rows) stateById.set(r.id, r.item_status); const loaded: string[] = []; const skipped: Array<{ id: string; reason: string }> = []; const now = new Date().toISOString(); for (const id of itemIds) { const current = stateById.get(id); if (current === undefined) { skipped.push({ id, reason: "Item not found" }); continue; } if (current !== "invoiced") { skipped.push({ id, reason: `Expected item_status=invoiced, found ${current || "empty"}`, }); continue; } try { await pb.collection(collection).update(id, { item_status: "with_driver", loaded_at: now, }); loaded.push(id); } catch (err) { skipped.push({ id, reason: err instanceof Error ? err.message : "Update failed", }); } } return { loaded, skipped }; } export type CheckInInput = { itemIds: string[]; /** * The invoice/DN number from the signed paper. Trimmed + * length-checked (1..64 chars to match the schema max). Empty * strings are rejected — the whole batch fails if the operator * submits an empty reference. */ reference: string; /** * The authed user id who typed the reference. Required: the * `checked_in_by` field on the item is a relation, not a free- * text audit. We pull this from the PocketBase auth record on * the request, not the body, so the client can't spoof another * user. */ checkedInByUserId: string; }; export type CheckInResult = { checkedIn: string[]; skipped: Array<{ id: string; reason: string }>; }; /** * Bulk flip `with_driver → checked_in`, write the reference, and * stamp `checked_in_at` + `checked_in_by`. Used by the "Back from * Driver" tab. * * Reference handling: trimmed. Empty/whitespace-only references * fail the whole batch with a single clear error. We don't try * to "be clever" with auto-generated fallback refs — the operator * is the one who has the paper in their hand, and a wrong * reference is worse than a rejected batch. * * Idempotency: items already in `checked_in` are skipped with a * reason rather than re-stamped. This means a partial network * failure mid-batch can be retried — already-checked-in items * stay checked in, the rest get the new reference + timestamp. */ export async function checkInItems( input: CheckInInput ): Promise { const ref = (input.reference || "").trim(); if (ref.length === 0) { throw new Error("Reference cannot be empty."); } if (ref.length > 64) { throw new Error("Reference is too long (max 64 chars)."); } if (input.itemIds.length === 0) { return { checkedIn: [], skipped: [] }; } if (input.itemIds.length > MAX_BATCH) { throw new Error(`Too many items in one batch (${input.itemIds.length} > ${MAX_BATCH}).`); } const pb = await getAdminPocketBase(); const collection = getOrderItemsCollectionName(); const orFilter = input.itemIds.map((id) => `id="${id}"`).join(" || "); const rows = await pb.collection(collection).getFullList<{ id: string; item_status: string; }>({ filter: orFilter, fields: "id,item_status", }); const stateById = new Map(); for (const r of rows) stateById.set(r.id, r.item_status); const checkedIn: string[] = []; const skipped: Array<{ id: string; reason: string }> = []; const now = new Date().toISOString(); for (const id of input.itemIds) { const current = stateById.get(id); if (current === undefined) { skipped.push({ id, reason: "Item not found" }); continue; } if (current !== "with_driver") { skipped.push({ id, reason: `Expected item_status=with_driver, found ${current || "empty"}`, }); continue; } try { await pb.collection(collection).update(id, { item_status: "checked_in", delivery_reference: ref, checked_in_at: now, checked_in_by: input.checkedInByUserId, }); checkedIn.push(id); } catch (err) { skipped.push({ id, reason: err instanceof Error ? err.message : "Update failed", }); } } return { checkedIn, skipped }; } export type SignedCopyInput = { deliveryNoteId: string; file: File; }; export type SignedCopyResult = { deliveryNoteId: string; filename: string; status: string; }; /** * Stamp the signed copy onto a delivery note AND flip its status * to `completed` in a single PATCH. */ export async function markSignedCopyReceived( input: SignedCopyInput ): Promise { const pb = await getAdminPocketBase(); const dn = await pb .collection(DELIVERY_NOTES) .getOne(input.deliveryNoteId); if (dn.status === "completed") { return { deliveryNoteId: dn.id, filename: Array.isArray(dn.signed_copy) ? dn.signed_copy[0] ?? "" : "", status: dn.status, }; } // Allow signed-copy upload on ANY active status (per the // operator's request: "regardless of the status"). The only // exception is `cancelled` — that's an abandoned DN, so // uploading a signed copy to it would be misleading. if (dn.status === "cancelled") { throw new Error( `Cannot mark signed copy received on a cancelled delivery note.`, ); } const updated = await pb.collection(DELIVERY_NOTES).update( dn.id, { signed_copy: [input.file], status: "completed", } ); const filename = (Array.isArray(updated.signed_copy) && updated.signed_copy[0]) || (input.file as { name?: string }).name || "signed-copy"; return { deliveryNoteId: updated.id, filename, status: updated.status, }; } /** * Mark a delivery note as `loaded` (paper is on the truck, going * out for delivery). * * Accepts `draft` (paper was prepared and is being loaded * directly, e.g. counter pickup) and `sent` (paper has been sent * to the customer and is now en route back via the driver). The * `loaded` state is a new lifecycle stop on the operator's * request — they want a visible "out with the driver" status * distinct from "sent" (paper at the customer) and * "po_received" (paper back with PO). * * Idempotent on `loaded`: a re-marked DN is a no-op (the * `updated` autodate timestamp already records the operator's * click). This lets the page retry safely on a flaky network * without double-stamping. * * Other transitions: `po_received`, `invoiced`, `completed` are * downstream of `loaded` and accept the signed-copy upload * separately. `cancelled` is rejected — the operator has to * un-cancel first. * * Why no `loaded_at` field: the operator opted to use PB's * built-in `updated` autodate as the timestamp instead of * adding a dedicated field. The trade-off is that any future * edit to the DN will shift the "loaded at" reading — acceptable * for an audit trail that nobody currently queries by timestamp. */ export async function markDeliveryNoteLoaded( deliveryNoteId: string, ): Promise { const pb = await getAdminPocketBase(); const dn = await pb .collection(DELIVERY_NOTES) .getOne(deliveryNoteId); // Idempotent: re-marking a loaded DN is a no-op. if (dn.status === "loaded") { return dn; } if (dn.status !== "draft" && dn.status !== "sent") { throw new Error( `Cannot mark delivery note as loaded in status "${dn.status}" (must be draft or sent).`, ); } return pb.collection(DELIVERY_NOTES).update(dn.id, { status: "loaded", }); }