From 45529731a7dcaa1e594ec46846adaebde441f65d Mon Sep 17 00:00:00 2001 From: Dave Date: Sat, 31 Oct 2026 09:55:35 +0200 Subject: [PATCH] feat(delivery-pool): assign invoiced deliveries to drivers, close with the signed copy New /delivery-pool page, a clone of the Collection Pool for deliveries with no pending stage. Invoiced lines are grouped per company, one card per customer invoice; a card expands to its lines. Assign a driver and day per invoice (or bulk per company); uploading the signed copy adds it to every line, marks the lines completed + delivered_at, and closes a delivery note once none of its lines is still open. - Driver lives in new order_items.delivery_driver / delivery_assigned_at (npm run pb:migrate:delivery-pool), never drv_id (collection driver). - Pool starts at DELIVERY_POOL_START (2026-10-30); lines whose DN is already completed/cancelled (POS counter sales) are left out. - Service + /api/v1/delivery-pool routes (browser session); the service 503s until the migration has run. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 19 + .../specs/2026-10-30-delivery-pool-design.md | 115 ++++++ package.json | 1 + .../2026-10-30-add-delivery-pool-fields.ts | 67 ++++ src/app/(app)/delivery-pool/components.tsx | 274 +++++++++++++ src/app/(app)/delivery-pool/page.tsx | 371 ++++++++++++++++++ .../delivery-pool/[invoice]/assign/route.ts | 31 ++ .../[invoice]/signed-copy/route.ts | 34 ++ src/app/api/v1/delivery-pool/route.ts | 23 ++ src/components/sidebar.tsx | 5 + src/lib/delivery-pool.test.ts | 122 ++++++ src/lib/delivery-pool.ts | 180 +++++++++ src/lib/server/delivery-pool-service.test.ts | 158 ++++++++ src/lib/server/delivery-pool-service.ts | 280 +++++++++++++ 14 files changed, 1680 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-30-delivery-pool-design.md create mode 100644 scripts/migrations/2026-10-30-add-delivery-pool-fields.ts create mode 100644 src/app/(app)/delivery-pool/components.tsx create mode 100644 src/app/(app)/delivery-pool/page.tsx create mode 100644 src/app/api/v1/delivery-pool/[invoice]/assign/route.ts create mode 100644 src/app/api/v1/delivery-pool/[invoice]/signed-copy/route.ts create mode 100644 src/app/api/v1/delivery-pool/route.ts create mode 100644 src/lib/delivery-pool.test.ts create mode 100644 src/lib/delivery-pool.ts create mode 100644 src/lib/server/delivery-pool-service.test.ts create mode 100644 src/lib/server/delivery-pool-service.ts diff --git a/AGENTS.md b/AGENTS.md index 0f2b307..17f4813 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -330,3 +330,22 @@ The preview popup is REUSED, not rebuilt: the service emits `UniversalHit`s and the page hands them to the existing `SearchPreviewDialog` (`src/app/(app)/search/search-preview.tsx`), so the manager sees the same quick-overview the desk sees on /search. + +# Delivery Pool (`/delivery-pool`) — invoiced lines waiting to go out + +Design: `docs/superpowers/specs/2026-10-30-delivery-pool-design.md`. Files: +`src/lib/delivery-pool.ts` (pure grouping/filters), `src/lib/server/delivery-pool-service.ts`, +routes under `/api/v1/delivery-pool` (browser session), page `src/app/(app)/delivery-pool/`. + +- Per company, **one card per customer invoice** (`customer_invoice_number`, normalised + `30352` = `INV30352`). Never key on `order_item_details.invoice.number` — Collection + Confirm writes the *supplier* invoice there. +- In the pool: `invoiced`, `invoiced_date >= DELIVERY_POOL_START`, `delivered_at` empty, + not `exclude_from_invoice`, and its DN (if any) not `completed`/`cancelled` (drops POS + counter sales). +- The delivery driver lives in `delivery_driver` / `delivery_assigned_at` — **never write + `drv_id`** here, that's the collection driver. Status stays `invoiced` until the signed copy. +- Signed copy: ADDED to every line (`addAttachmentForm`), lines → `completed` + `delivered_at`, + a DN is closed only when none of its lines is still open. +- Manual live step: `npm run pb:migrate:delivery-pool` before deploying (the service 503s + until the fields exist). The old `/deliveries` page stays until the office switches over. diff --git a/docs/superpowers/specs/2026-10-30-delivery-pool-design.md b/docs/superpowers/specs/2026-10-30-delivery-pool-design.md new file mode 100644 index 0000000..3690937 --- /dev/null +++ b/docs/superpowers/specs/2026-10-30-delivery-pool-design.md @@ -0,0 +1,115 @@ +# Delivery Pool — design + +Date: 2026-10-30 · Status: draft for Dave's approval + +## Why + +The current `/deliveries` page tracks paper (To Load → Back from Driver → +Checked In) but never says **which driver** has a delivery or **when** it +goes out, and the signed copy is left to an n8n job. The office wants +deliveries to work like the Collection Pool: one list of everything that +must go out, assign it to a driver for a day, and close it when the signed +copy comes back. + +## What it is (Dave's words) + +- A clone of the Collection Pool for deliveries, **without the pending + stage** — everything in the pool is ready to go. +- Grouped **per company**, and under each company **one card per invoice + number**. Expanding a card shows the lines on that invoice. +- Assign a card to a driver; the driver delivers; when the paper comes back + the office uploads the **signed copy** and **every line on that invoice + becomes `completed`**. + +## Goals + +- New page `/delivery-pool` ("Delivery Pool" in the sidebar). +- Company sections (collapsible, count + total value), invoice cards inside. +- Card shows: invoice number, DN number(s) if any, order number, line count, + value ex VAT, driver + date badge (Unassigned / Today / future date). +- Card actions: **Assign / Reschedule driver** (same dialog as the Collection + Pool), **Unassign**, **Upload signed copy**, open invoice PDF. +- Bulk select cards in a company section → assign them all to one driver/date. +- Filter box (company / invoice / DN / order / item text) and a driver filter + ("All / Unassigned / "). + +## Non-goals + +- The driver app (`WFDASHDRIVER-APP`) — drivers do not see deliveries there + yet. Assigning is office-side. Separate follow-up. +- Map / nearby sort (the Collection Pool's supplier map). Not in v1. +- The old `/deliveries` page and its `with_driver` / `checked_in` rows stay + as they are until the new pool is trusted; then it is retired. + +## Anti-goals + +- Must not overwrite the **collection** driver (`order_items.drv_id`, + `drv_collected_at`) — that history feeds Collection Confirm and stats. +- Must not drop un-invoiced lines off the Invoicing page. +- Must not flood the pool with months of already-delivered invoices. + +## Rules + +**What is in the pool** — an `order_items` line where all hold: +- `item_status = "invoiced"` +- `customer_invoice_number != ""` (the customer's QB invoice — NOT + `order_item_details.invoice.number`, which Collection Confirm also uses + for the *supplier* invoice) +- `invoiced_date >= DELIVERY_POOL_START` (constant, set to go-live day) +- `delivered_at = ""` (not yet delivered) +- not `exclude_from_invoice` +- its delivery note (if any) is not `completed` / `cancelled` — this drops + POS counter sales, which close their DN with a signed copy at the till. + +**Card key** = `customer_invoice_number`, normalised (`30352` and +`INV30352` are the same invoice; shown as `INV30352`). + +**Company** = the order's company; for lines without an order (POS) the +DN's company; else `order_item_details.invoice.invoiceCompany`; else +"Unknown company". + +**Lines on a DN but not yet invoiced** (`item_status = "delivery_note"`) +are *not* in the pool — the card is per invoice. They join the pool when +they are invoiced. *(My call, from "per invoice number" — say if a DN-only +delivery must go out before invoicing.)* + +## Data + +Two new fields on `order_items` (migration script +`npm run pb:migrate:delivery-pool`, manual live step): +- `delivery_driver` — relation → users, single +- `delivery_assigned_at` — date + +Existing fields reused: `delivered_at` (stamped when the signed copy is +uploaded; currently set on only 2 rows), `item_status` → `completed`. + +Item status does **not** change on assign — the line stays `invoiced` +until the signed copy is in. Both new fields go in nothing noisy, so the +audit hook records who assigned what. + +## Server + +`src/lib/server/delivery-pool-service.ts`: +- `listDeliveryPool()` → `{ companies: [{ id, name, invoices: [{ invoiceNumber, + dnNumbers, orderNumbers, driverId, assignedAt, lines[], total }] }] }` +- `assignInvoice({ invoiceNumber, driverId, date })` — writes both fields on + every pool line of that invoice; empty driverId = unassign. +- `completeInvoice({ invoiceNumber, file })` — adds the file to each line's + `order_item_attachements` (with `addAttachmentForm`, ADD never replace), + stores it as `signed_copy` on each linked DN that has none and sets that DN + `completed`, stamps `delivered_at`, sets lines `completed`. + +Routes (browser-session auth, like the other office pages): +- `GET /api/v1/delivery-pool` +- `PATCH /api/v1/delivery-pool/{invoice}/assign` +- `POST /api/v1/delivery-pool/{invoice}/signed-copy` (multipart) + +## Page + +`src/app/(app)/delivery-pool/` — `page.tsx`, `components.tsx`, +`view-model.ts` (grouping, badges, filters — pure + tested), reusing +`AssignDriverDialog` and `/api/v1/users/lookup` from the Collection Pool. + +## Left to the builder + +Styling details, empty states, toasts, test layout. diff --git a/package.json b/package.json index 5436a88..e91bf46 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "pb:migrate:pos-sales-delivery-note": "npx tsx scripts/migrations/2026-10-03-add-pos-sales-delivery-note.ts", "pb:migrate:audit-log": "npx tsx scripts/migrations/2026-09-25-create-audit-log.ts", "pb:migrate:rep-trip-legs": "npx tsx scripts/migrations/2026-09-28-create-rep-trip-legs.ts", + "pb:migrate:delivery-pool": "npx tsx scripts/migrations/2026-10-30-add-delivery-pool-fields.ts", "pb:migrate:quote-assistant": "npx tsx scripts/migrations/2026-10-16-add-quote-assistant-fields.ts", "pb:migrate:collection-ai": "npx tsx scripts/migrations/2026-10-17-add-collection-ai.ts", "pb:migrate:pool-pending": "npx tsx scripts/migrations/2026-10-30-add-stock-pool-pending.ts", diff --git a/scripts/migrations/2026-10-30-add-delivery-pool-fields.ts b/scripts/migrations/2026-10-30-add-delivery-pool-fields.ts new file mode 100644 index 0000000..def10cc --- /dev/null +++ b/scripts/migrations/2026-10-30-add-delivery-pool-fields.ts @@ -0,0 +1,67 @@ +/** + * One-shot migration: fields for the Delivery Pool. + * + * order_items.delivery_driver relation → users (single) + * order_items.delivery_assigned_at date + * + * Kept apart from `drv_id` / `assigned_at` so assigning a delivery never + * overwrites the driver who collected the goods. + * + * Run with: + * + * npm run pb:migrate:delivery-pool + * + * Idempotent — fields that already exist are skipped. Manual step on the + * live PB (like select values and pb_hooks), BEFORE deploying the page. + * + * Rollback: delete the fields in the PB admin UI. + */ +import "./_env-loader"; +import PocketBase from "pocketbase"; + +const PB_URL = process.env.NEXT_PUBLIC_POCKETBASE_URL || process.env.PB_URL || "http://127.0.0.1:8090"; +const ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL; +const ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD; + +type FieldDef = Record & { name: string }; + +async function main() { + if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { + throw new Error("Set PB_ADMIN_EMAIL and PB_ADMIN_PASSWORD (e.g. from .env.local)."); + } + const pb = new PocketBase(PB_URL); + await pb.collection("_superusers").authWithPassword(ADMIN_EMAIL, ADMIN_PASSWORD); + console.log(`[delivery-pool] Authenticated against ${PB_URL}`); + + const users = await pb.collections.getOne("users"); + const wanted: FieldDef[] = [ + { + name: "delivery_driver", + type: "relation", + required: false, + presentable: false, + collectionId: users.id, + cascadeDelete: false, + minSelect: 0, + maxSelect: 1, + }, + { name: "delivery_assigned_at", type: "date", required: false, presentable: false }, + ]; + + const collection = await pb.collections.getOne("order_items"); + const existing = new Set(collection.fields.map((f: { name: string }) => f.name)); + const missing = wanted.filter((f) => !existing.has(f.name)); + for (const f of wanted) { + if (existing.has(f.name)) console.log(`[delivery-pool] order_items.${f.name} already exists — skipping.`); + } + if (missing.length > 0) { + await pb.collections.update(collection.id, { fields: [...collection.fields, ...missing] }); + for (const f of missing) console.log(`[delivery-pool] order_items.${f.name} added.`); + } + console.log("[delivery-pool] Done."); +} + +main().catch((err) => { + console.error("[delivery-pool] Migration failed:", err); + process.exit(1); +}); diff --git a/src/app/(app)/delivery-pool/components.tsx b/src/app/(app)/delivery-pool/components.tsx new file mode 100644 index 0000000..3539fb5 --- /dev/null +++ b/src/app/(app)/delivery-pool/components.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useRef } from "react"; +import { ChevronDown, ChevronRight, FileText, Upload, UserX } from "lucide-react"; + +import { Button, buttonVariants } from "@/components/ui/button"; +import { + deliveryAssignmentState, + type DeliveryAssignmentState, + type DeliveryPoolCompany, + type DeliveryPoolInvoice, +} from "@/lib/delivery-pool"; +import { cn } from "@/lib/utils"; + +const money = new Intl.NumberFormat("en-ZA", { style: "currency", currency: "ZAR" }); + +export function formatMoney(value: number): string { + return money.format(value); +} + +function formatYmd(ymd: string): string { + if (!ymd) return ""; + const d = new Date(`${ymd}T00:00:00`); + return Number.isNaN(d.getTime()) + ? ymd + : d.toLocaleDateString("en-ZA", { weekday: "short", day: "numeric", month: "short" }); +} + +const BADGE_CLASS: Record = { + unassigned: "bg-muted text-muted-foreground", + today: "bg-emerald-600 text-white", + future: "bg-blue-600 text-white", + overdue: "bg-amber-500 text-white", + mixed: "bg-destructive text-destructive-foreground", +}; + +export function AssignmentBadge({ + invoice, + todayYmd, + driverName, +}: { + invoice: DeliveryPoolInvoice; + todayYmd: string; + driverName?: string; +}) { + const state = deliveryAssignmentState(invoice, todayYmd); + const who = driverName || (invoice.driverId ? `Driver ${invoice.driverId.slice(0, 6)}` : ""); + const label = + state === "unassigned" + ? "Unassigned" + : state === "mixed" + ? "Lines on different drivers" + : state === "today" + ? `${who} · today` + : state === "overdue" + ? `${who} · ${formatYmd(invoice.assignedAt)} (late)` + : `${who} · ${formatYmd(invoice.assignedAt)}`; + return ( + + {label} + + ); +} + +export function InvoiceCard({ + invoice, + todayYmd, + driverName, + expanded, + selected, + busy, + onToggleExpanded, + onToggleSelected, + onAssign, + onUnassign, + onSignedCopyPicked, +}: { + invoice: DeliveryPoolInvoice; + todayYmd: string; + driverName?: string; + expanded: boolean; + selected: boolean; + busy: boolean; + onToggleExpanded: () => void; + onToggleSelected: () => void; + onAssign: () => void; + onUnassign: () => void; + onSignedCopyPicked: (file: File) => void; +}) { + const fileInput = useRef(null); + const pdfHref = invoice.invoicePdf + ? `/api/v1/files/order_items/${invoice.invoicePdf.lineId}/${encodeURIComponent(invoice.invoicePdf.filename)}` + : null; + const assigned = Boolean(invoice.driverId) || invoice.mixedAssignment; + + return ( +
  • +
    + + + {formatMoney(invoice.total)} + +
    + {pdfHref && ( + + + Invoice PDF + + )} + + {assigned && ( + + )} + + { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) onSignedCopyPicked(file); + }} + /> +
    +
    + {expanded && ( + + + + + + + + + + + {invoice.lines.map((line) => ( + + + + + + + ))} + +
    ItemQtyPriceTotal
    + {line.description || "(no description)"} + {line.order_id && ( + + {line.order_number} + + )} + {line.quantity}{formatMoney(line.selling_price)} + {formatMoney(line.quantity * line.selling_price)} +
    + )} +
  • + ); +} + +export function CompanyGroup({ + company, + collapsed, + selectedCount, + onToggleCollapsed, + onToggleAllSelected, + onBulkAssign, + children, +}: { + company: DeliveryPoolCompany; + collapsed: boolean; + selectedCount: number; + onToggleCollapsed: () => void; + onToggleAllSelected: () => void; + onBulkAssign: () => void; + children: React.ReactNode; +}) { + const count = company.invoices.length; + const allSelected = selectedCount === count; + const assignLabel = + selectedCount > 0 && !allSelected ? `Assign ${selectedCount} selected` : `Assign all ${count}`; + return ( +
    +
    +
    + { + if (el) el.indeterminate = selectedCount > 0 && !allSelected; + }} + onChange={onToggleAllSelected} + aria-label={`Select every invoice for ${company.name}`} + /> + + + · {count} {count === 1 ? "invoice" : "invoices"} · {formatMoney(company.total)} + +
    + +
    +
    + {!collapsed &&
      {children}
    } +
    + ); +} diff --git a/src/app/(app)/delivery-pool/page.tsx b/src/app/(app)/delivery-pool/page.tsx new file mode 100644 index 0000000..35119e3 --- /dev/null +++ b/src/app/(app)/delivery-pool/page.tsx @@ -0,0 +1,371 @@ +"use client"; + +/** + * Delivery Pool — invoiced customer lines waiting to go out, per company, + * one card per invoice. Assign a driver + day per invoice; uploading the + * signed copy completes every line on it. + * + * Design: docs/superpowers/specs/2026-10-30-delivery-pool-design.md. + * Data comes from `/api/v1/delivery-pool` (delivery-pool-service.ts). + */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Search, Truck } from "lucide-react"; +import { toast } from "sonner"; + +import { Button } from "@/components/ui/button"; +import { AssignDriverDialog, type AssignDriverSubmitPayload } from "@/components/ui/assign-driver-dialog"; +import { ModalShell } from "@/components/ui/modal-shell"; +import { getPocketBase } from "@/lib/pocketbase"; +import { + filterDeliveryPool, + type DeliveryPoolCompany, + type DeliveryPoolDriverFilter, + type DeliveryPoolInvoice, +} from "@/lib/delivery-pool"; +import { cn } from "@/lib/utils"; +import { CompanyGroup, InvoiceCard, formatMoney } from "./components"; + +type AssignTarget = { invoices: DeliveryPoolInvoice[]; companyName: string }; +type SignedCopyTarget = { invoice: DeliveryPoolInvoice; file: File }; + +function todayYmd(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; +} + +function authHeaders(): Record { + const token = getPocketBase().authStore.token; + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function readError(res: Response): Promise { + const body = (await res.json().catch(() => null)) as { error?: string } | null; + return body?.error || `HTTP ${res.status}`; +} + +export default function DeliveryPoolPage() { + const [companies, setCompanies] = useState(null); + const [startDate, setStartDate] = useState(""); + const [loadError, setLoadError] = useState(null); + const [filterText, setFilterText] = useState(""); + const [driverFilter, setDriverFilter] = useState("all"); + const [driversById, setDriversById] = useState>({}); + const [collapsed, setCollapsed] = useState>(new Set()); + const [expanded, setExpanded] = useState>(new Set()); + const [selected, setSelected] = useState>(new Set()); + const [busy, setBusy] = useState(false); + const [assignTarget, setAssignTarget] = useState(null); + const [signedCopyTarget, setSignedCopyTarget] = useState(null); + const today = todayYmd(); + + const load = useCallback(async () => { + setLoadError(null); + try { + const res = await fetch("/api/v1/delivery-pool", { headers: authHeaders(), cache: "no-store" }); + if (!res.ok) throw new Error(await readError(res)); + const body = (await res.json()) as { companies: DeliveryPoolCompany[]; startDate: string }; + setCompanies(body.companies); + setStartDate(body.startDate); + setSelected(new Set()); + } catch (err) { + setLoadError(err instanceof Error ? err.message : String(err)); + setCompanies([]); + } + }, []); + + useEffect(() => { + const handle = window.setTimeout(() => void load(), 0); + return () => window.clearTimeout(handle); + }, [load]); + + useEffect(() => { + let cancelled = false; + fetch("/api/v1/users/lookup") + .then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`)))) + .then((data: { id: string; name?: string; email?: string }[]) => { + if (cancelled) return; + const next: Record = {}; + for (const user of data) { + if (user.id) next[user.id] = user.name || user.email || `Driver ${user.id.slice(0, 8)}`; + } + setDriversById(next); + }) + .catch(() => { + // Best-effort: badges fall back to a short id. + }); + return () => { + cancelled = true; + }; + }, []); + + const visible = useMemo( + () => (companies ? filterDeliveryPool(companies, filterText, driverFilter) : []), + [companies, filterText, driverFilter], + ); + + const assignedDrivers = useMemo(() => { + const ids = new Set(); + for (const c of companies ?? []) for (const inv of c.invoices) if (inv.driverId) ids.add(inv.driverId); + return [...ids].sort((a, b) => (driversById[a] ?? a).localeCompare(driversById[b] ?? b)); + }, [companies, driversById]); + + const totals = useMemo(() => { + const invoices = visible.flatMap((c) => c.invoices); + return { + invoices: invoices.length, + unassigned: invoices.filter((i) => !i.driverId && !i.mixedAssignment).length, + value: invoices.reduce((s, i) => s + i.total, 0), + }; + }, [visible]); + + const toggleIn = (setter: React.Dispatch>>, key: string) => + setter((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + + const assign = useCallback( + async (invoices: DeliveryPoolInvoice[], payload: AssignDriverSubmitPayload) => { + setBusy(true); + let failed = 0; + try { + for (const inv of invoices) { + const res = await fetch(`/api/v1/delivery-pool/${encodeURIComponent(inv.invoiceNumber)}/assign`, { + method: "PATCH", + headers: { ...authHeaders(), "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + if (!res.ok) { + failed += 1; + toast.error(`${inv.invoiceNumber}: ${await readError(res)}`); + } + } + const done = invoices.length - failed; + if (done > 0) { + toast.success( + payload.driverId + ? `${done} ${done === 1 ? "invoice" : "invoices"} assigned` + : `${done} ${done === 1 ? "invoice" : "invoices"} unassigned`, + ); + } + setAssignTarget(null); + await load(); + } finally { + setBusy(false); + } + }, + [load], + ); + + const uploadSignedCopy = useCallback( + async ({ invoice, file }: SignedCopyTarget) => { + setBusy(true); + try { + const form = new FormData(); + form.append("file", file); + const res = await fetch(`/api/v1/delivery-pool/${encodeURIComponent(invoice.invoiceNumber)}/signed-copy`, { + method: "POST", + headers: authHeaders(), + body: form, + }); + if (!res.ok) throw new Error(await readError(res)); + const body = (await res.json()) as { completed: number }; + toast.success(`${invoice.invoiceNumber} delivered — ${body.completed} ${body.completed === 1 ? "line" : "lines"} completed`); + setSignedCopyTarget(null); + await load(); + } catch (err) { + toast.error(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }, + [load], + ); + + const assignInitial = assignTarget?.invoices.length === 1 ? assignTarget.invoices[0] : null; + + return ( +
    +
    +
    +

    + + Delivery Pool +

    +

    + Invoiced orders waiting to go out, per company and invoice. Assign a driver, then upload the signed copy + when the paper comes back. + {startDate && <> Invoices from {startDate} onward.} +

    +
    + +
    + +
    +
    +
    + + setFilterText(e.target.value)} + aria-label="Search delivery pool" + /> +
    +
    + Driver +
    + {[ + { id: "all", label: "All" }, + { id: "unassigned", label: "Unassigned" }, + ...assignedDrivers.map((id) => ({ id, label: driversById[id] ?? `Driver ${id.slice(0, 6)}` })), + ].map((opt) => ( + + ))} +
    +
    +
    +
    + {totals.invoices} {totals.invoices === 1 ? "invoice" : "invoices"} · {totals.unassigned} unassigned ·{" "} + {formatMoney(totals.value)} ex VAT +
    + +
    + {companies === null ? ( +

    Loading…

    + ) : loadError ? ( +

    Couldn't load the delivery pool: {loadError}

    + ) : visible.length === 0 ? ( +

    + {companies.length === 0 ? "Nothing waiting to be delivered." : "No invoices match this filter."} +

    + ) : ( + visible.map((company) => { + const keys = company.invoices.map((i) => i.invoiceNumber); + const selectedHere = keys.filter((k) => selected.has(k)); + return ( + toggleIn(setCollapsed, company.key)} + onToggleAllSelected={() => + setSelected((prev) => { + const next = new Set(prev); + const all = keys.every((k) => next.has(k)); + for (const k of keys) { + if (all) next.delete(k); + else next.add(k); + } + return next; + }) + } + onBulkAssign={() => + setAssignTarget({ + companyName: company.name, + invoices: + selectedHere.length > 0 + ? company.invoices.filter((i) => selected.has(i.invoiceNumber)) + : company.invoices, + }) + } + > + {company.invoices.map((invoice) => ( + toggleIn(setExpanded, invoice.invoiceNumber)} + onToggleSelected={() => toggleIn(setSelected, invoice.invoiceNumber)} + onAssign={() => setAssignTarget({ companyName: company.name, invoices: [invoice] })} + onUnassign={() => void assign([invoice], { driverId: "", date: today })} + onSignedCopyPicked={(file) => setSignedCopyTarget({ invoice, file })} + /> + ))} + + ); + }) + )} +
    +
    + + { + if (!open) setAssignTarget(null); + }} + bulkCount={assignTarget?.invoices.length ?? 1} + busy={busy} + eyebrow={ + assignTarget + ? `${assignTarget.companyName} · ${assignTarget.invoices.map((i) => i.invoiceNumber).join(", ")}` + : undefined + } + initialDriverId={assignInitial?.driverId ?? ""} + initialDriverLabel={assignInitial?.driverId ? driversById[assignInitial.driverId] : undefined} + initialDate={assignInitial?.assignedAt || undefined} + onSubmit={async (payload) => { + if (assignTarget) await assign(assignTarget.invoices, payload); + }} + /> + + { + if (!open && !busy) setSignedCopyTarget(null); + }} + eyebrow={signedCopyTarget?.invoice.companyName} + title={signedCopyTarget ? `Complete ${signedCopyTarget.invoice.invoiceNumber}?` : "Complete invoice?"} + description="The signed copy is attached and every line on this invoice is marked completed. It then leaves the pool." + footer={ + <> + + + + } + > + {signedCopyTarget && ( +
    +

    + File: {signedCopyTarget.file.name} +

    +

    + {signedCopyTarget.invoice.lines.length} {signedCopyTarget.invoice.lines.length === 1 ? "line" : "lines"} ·{" "} + {formatMoney(signedCopyTarget.invoice.total)} + {signedCopyTarget.invoice.dnNumbers.length > 0 && <> · {signedCopyTarget.invoice.dnNumbers.join(", ")}} +

    +
    + )} +
    +
    + ); +} diff --git a/src/app/api/v1/delivery-pool/[invoice]/assign/route.ts b/src/app/api/v1/delivery-pool/[invoice]/assign/route.ts new file mode 100644 index 0000000..de86f96 --- /dev/null +++ b/src/app/api/v1/delivery-pool/[invoice]/assign/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { assignInvoice } from "@/lib/server/delivery-pool-service"; + +/** + * PATCH /api/v1/delivery-pool/{invoice}/assign + * + * Body `{ driverId, date }` — assigns every pool line of the invoice to the + * driver for that day. An empty `driverId` unassigns. Auth: browser session. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function PATCH(req: NextRequest, context: { params: Promise<{ invoice: string }> }) { + try { + await getBrowserSessionUser(req); + const { invoice } = await context.params; + const body = (await req.json().catch(() => ({}))) as { driverId?: unknown; date?: unknown }; + return NextResponse.json( + await assignInvoice({ + invoiceNumber: decodeURIComponent(invoice), + driverId: typeof body.driverId === "string" ? body.driverId : "", + date: typeof body.date === "string" ? body.date : "", + }), + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Assign failed"; + const status = (err as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/v1/delivery-pool/[invoice]/signed-copy/route.ts b/src/app/api/v1/delivery-pool/[invoice]/signed-copy/route.ts new file mode 100644 index 0000000..37212e4 --- /dev/null +++ b/src/app/api/v1/delivery-pool/[invoice]/signed-copy/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { completeInvoice } from "@/lib/server/delivery-pool-service"; + +/** + * POST /api/v1/delivery-pool/{invoice}/signed-copy (multipart, field `file`) + * + * Uploads the signed copy and completes every line on the invoice (see + * `completeInvoice`). Auth: browser session. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function POST(req: NextRequest, context: { params: Promise<{ invoice: string }> }) { + try { + await getBrowserSessionUser(req); + const { invoice } = await context.params; + let form: FormData; + try { + form = await req.formData(); + } catch { + return NextResponse.json({ error: "Request body must be multipart/form-data." }, { status: 400 }); + } + const file = form.get("file"); + if (!file || typeof file === "string") { + return NextResponse.json({ error: "No file part in the request (expected field 'file')." }, { status: 400 }); + } + return NextResponse.json(await completeInvoice({ invoiceNumber: decodeURIComponent(invoice), file })); + } catch (err) { + const message = err instanceof Error ? err.message : "Signed copy upload failed"; + const status = (err as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/v1/delivery-pool/route.ts b/src/app/api/v1/delivery-pool/route.ts new file mode 100644 index 0000000..203c817 --- /dev/null +++ b/src/app/api/v1/delivery-pool/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getBrowserSessionUser } from "@/lib/server/browser-session"; +import { listDeliveryPool } from "@/lib/server/delivery-pool-service"; + +/** + * GET /api/v1/delivery-pool + * + * The Delivery Pool page's data: company sections of invoice cards + * (`{ companies, startDate }`). Auth: browser session. + */ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + try { + await getBrowserSessionUser(req); + return NextResponse.json(await listDeliveryPool()); + } catch (err) { + const message = err instanceof Error ? err.message : "Delivery pool failed"; + const status = (err as Error & { status?: number }).status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/components/sidebar.tsx b/src/components/sidebar.tsx index 6f4d6f9..1a14cec 100644 --- a/src/components/sidebar.tsx +++ b/src/components/sidebar.tsx @@ -125,6 +125,10 @@ const operationsItems: NavEntry[] = [ // reference number. n8n will eventually auto-complete // these (docs/deliveries-n8n-workflow.md). { href: "/deliveries", label: "Deliveries", icon: Truck }, + // Invoiced lines waiting to go out, one card per invoice: assign a + // driver, upload the signed copy to complete. Replaces /deliveries + // once the office trusts it (docs/superpowers/specs/2026-10-30-delivery-pool-design.md). + { href: "/delivery-pool", label: "Delivery Pool", icon: Truck }, // Counter-side scanner for the printed delivery-note QR. Separate // standalone PWA at /scan (it declares its own manifest), linked // here so the office can find it from the CRM too. @@ -478,6 +482,7 @@ const MOBILE_TITLE_MAP: { prefix: string; label: string }[] = [ { prefix: "/collection-map", label: "Collection Map" }, { prefix: "/collection-pool", label: "Collection Pool" }, { prefix: "/delivery-notes", label: "Delivery Notes" }, + { prefix: "/delivery-pool", label: "Delivery Pool" }, { prefix: "/stock-orders", label: "Stock Orders" }, { prefix: "/orders/sprint", label: "Sprint" }, { prefix: "/orders/assign", label: "Assign Driver" }, diff --git a/src/lib/delivery-pool.test.ts b/src/lib/delivery-pool.test.ts new file mode 100644 index 0000000..04ba9a5 --- /dev/null +++ b/src/lib/delivery-pool.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from "vitest"; +import { + deliveryAssignmentState, + filterDeliveryPool, + groupDeliveryPool, + normalizeInvoiceNumber, + type DeliveryPoolLine, +} from "./delivery-pool"; + +function line(over: Partial): DeliveryPoolLine { + return { + id: "l1", + description: "M20 nut", + quantity: 1, + selling_price: 10, + customer_invoice_number: "INV100", + delivery_note_number: "", + invoiced_date: "2026-10-30", + delivery_driver: "", + delivery_assigned_at: "", + order_id: "o1", + order_number: "ORD-1", + company_id: "c1", + company_name: "Acme", + invoice_pdf: "", + ...over, + }; +} + +describe("normalizeInvoiceNumber", () => { + it("treats bare digits and the INV prefix as the same invoice", () => { + expect(normalizeInvoiceNumber("30352")).toBe("INV30352"); + expect(normalizeInvoiceNumber("inv 30352")).toBe("INV30352"); + expect(normalizeInvoiceNumber("INV-30352")).toBe("INV30352"); + }); + it("keeps non-numeric numbers and blanks", () => { + expect(normalizeInvoiceNumber("ABC-9")).toBe("ABC-9"); + expect(normalizeInvoiceNumber(" ")).toBe(""); + expect(normalizeInvoiceNumber(null)).toBe(""); + }); +}); + +describe("groupDeliveryPool", () => { + it("groups per company, then one card per invoice", () => { + const groups = groupDeliveryPool([ + line({ id: "a", customer_invoice_number: "30352", quantity: 2, selling_price: 5 }), + line({ id: "b", customer_invoice_number: "INV30352", delivery_note_number: "DN1" }), + line({ id: "c", customer_invoice_number: "INV1", company_id: "c2", company_name: "Bolt Co" }), + ]); + expect(groups.map((g) => g.name)).toEqual(["Acme", "Bolt Co"]); + expect(groups[0].invoices).toHaveLength(1); + const inv = groups[0].invoices[0]; + expect(inv.invoiceNumber).toBe("INV30352"); + expect(inv.lines.map((l) => l.id)).toEqual(["a", "b"]); + expect(inv.total).toBe(20); + expect(inv.dnNumbers).toEqual(["DN1"]); + expect(groups[0].total).toBe(20); + }); + + it("skips lines without an invoice number and puts unknown companies last", () => { + const groups = groupDeliveryPool([ + line({ id: "x", customer_invoice_number: "" }), + line({ id: "u", customer_invoice_number: "INV9", company_id: "", company_name: "" }), + line({ id: "z", customer_invoice_number: "INV8", company_id: "c9", company_name: "Zulu" }), + ]); + expect(groups.map((g) => g.name)).toEqual(["Zulu", "Unknown company"]); + }); + + it("reports a shared assignment, or mixed when the lines disagree", () => { + const same = groupDeliveryPool([ + line({ id: "a", delivery_driver: "d1", delivery_assigned_at: "2026-10-31" }), + line({ id: "b", delivery_driver: "d1", delivery_assigned_at: "2026-10-31" }), + ])[0].invoices[0]; + expect(same).toMatchObject({ driverId: "d1", assignedAt: "2026-10-31", mixedAssignment: false }); + + const mixed = groupDeliveryPool([ + line({ id: "a", delivery_driver: "d1", delivery_assigned_at: "2026-10-31" }), + line({ id: "b" }), + ])[0].invoices[0]; + expect(mixed).toMatchObject({ driverId: "", mixedAssignment: true }); + }); + + it("orders invoices newest first and finds the invoice PDF", () => { + const [company] = groupDeliveryPool([ + line({ id: "a", customer_invoice_number: "INV1", invoiced_date: "2026-10-30" }), + line({ id: "b", customer_invoice_number: "INV2", invoiced_date: "2026-10-31", invoice_pdf: "inv2_abc.pdf" }), + ]); + expect(company.invoices.map((i) => i.invoiceNumber)).toEqual(["INV2", "INV1"]); + expect(company.invoices[0].invoicePdf).toEqual({ lineId: "b", filename: "inv2_abc.pdf" }); + }); +}); + +describe("deliveryAssignmentState", () => { + const today = "2026-10-30"; + it("covers every state", () => { + expect(deliveryAssignmentState({ driverId: "", assignedAt: "", mixedAssignment: false }, today)).toBe("unassigned"); + expect(deliveryAssignmentState({ driverId: "d", assignedAt: today, mixedAssignment: false }, today)).toBe("today"); + expect(deliveryAssignmentState({ driverId: "d", assignedAt: "2026-10-29", mixedAssignment: false }, today)).toBe("overdue"); + expect(deliveryAssignmentState({ driverId: "d", assignedAt: "2026-11-02", mixedAssignment: false }, today)).toBe("future"); + expect(deliveryAssignmentState({ driverId: "", assignedAt: "", mixedAssignment: true }, today)).toBe("mixed"); + }); +}); + +describe("filterDeliveryPool", () => { + const groups = groupDeliveryPool([ + line({ id: "a", customer_invoice_number: "INV1", delivery_driver: "d1", delivery_assigned_at: "2026-10-30" }), + line({ id: "b", customer_invoice_number: "INV2", description: "Hex bolt" }), + line({ id: "c", customer_invoice_number: "INV3", company_id: "c2", company_name: "Bolt Co" }), + ]); + + it("matches company, invoice and line text", () => { + expect(filterDeliveryPool(groups, "hex", "all")[0].invoices.map((i) => i.invoiceNumber)).toEqual(["INV2"]); + expect(filterDeliveryPool(groups, "bolt co", "all").map((c) => c.name)).toEqual(["Bolt Co"]); + expect(filterDeliveryPool(groups, "inv3", "all")).toHaveLength(1); + }); + + it("filters by driver and drops empty companies", () => { + expect(filterDeliveryPool(groups, "", "d1").map((c) => c.invoices.length)).toEqual([1]); + const unassigned = filterDeliveryPool(groups, "", "unassigned"); + expect(unassigned.flatMap((c) => c.invoices.map((i) => i.invoiceNumber)).sort()).toEqual(["INV2", "INV3"]); + }); +}); diff --git a/src/lib/delivery-pool.ts b/src/lib/delivery-pool.ts new file mode 100644 index 0000000..332eca7 --- /dev/null +++ b/src/lib/delivery-pool.ts @@ -0,0 +1,180 @@ +/** + * Delivery Pool — pure, client-safe grouping + display logic. + * + * The pool is every invoiced customer line still waiting to go out + * (design: docs/superpowers/specs/2026-10-30-delivery-pool-design.md). + * The office sees it per company, one card per customer invoice; a + * driver is assigned per invoice and the signed copy completes every + * line on it. The server (`delivery-pool-service.ts`) fetches the lines + * and calls `groupDeliveryPool`; the page only renders the result. + */ + +/** Invoices dated before this never enter the pool (they went out on the old flow). */ +export const DELIVERY_POOL_START = "2026-10-30"; + +export type DeliveryPoolLine = { + id: string; + description: string; + quantity: number; + selling_price: number; + customer_invoice_number: string; + delivery_note_number: string; + /** YYYY-MM-DD or "". */ + invoiced_date: string; + delivery_driver: string; + /** YYYY-MM-DD or "". */ + delivery_assigned_at: string; + order_id: string; + order_number: string; + company_id: string; + company_name: string; + /** Stored file name of the QB invoice PDF on this line, if any. */ + invoice_pdf: string; +}; + +export type DeliveryPoolInvoice = { + /** Normalised invoice number, e.g. "INV30352" — the card key and route param. */ + invoiceNumber: string; + companyKey: string; + companyName: string; + dnNumbers: string[]; + orderNumbers: string[]; + orderIds: string[]; + invoicedDate: string; + /** Shared driver of every line, "" when unassigned. */ + driverId: string; + /** Shared YYYY-MM-DD, "" when unassigned. */ + assignedAt: string; + /** True when the lines disagree on driver/date (someone edited one line). */ + mixedAssignment: boolean; + /** Sum of quantity × selling price (ex VAT). */ + total: number; + invoicePdf: { lineId: string; filename: string } | null; + lines: DeliveryPoolLine[]; +}; + +export type DeliveryPoolCompany = { + key: string; + name: string; + total: number; + invoices: DeliveryPoolInvoice[]; +}; + +/** + * "30352", "inv 30352" and "INV30352" are the same QuickBooks invoice. + * Returns "" for a blank value so callers can skip the line. + */ +export function normalizeInvoiceNumber(raw: string | null | undefined): string { + const compact = String(raw ?? "").trim().toUpperCase().replace(/\s+/g, ""); + if (!compact) return ""; + const digits = compact.replace(/^INV-?/, ""); + return /^\d+$/.test(digits) ? `INV${digits}` : compact; +} + +function uniq(values: string[]): string[] { + return [...new Set(values.filter(Boolean))].sort(); +} + +const UNKNOWN_COMPANY = "Unknown company"; + +/** Group pool lines into company sections of invoice cards, newest invoices first. */ +export function groupDeliveryPool(lines: DeliveryPoolLine[]): DeliveryPoolCompany[] { + const byInvoice = new Map(); + for (const line of lines) { + const key = normalizeInvoiceNumber(line.customer_invoice_number); + if (!key) continue; + const bucket = byInvoice.get(key); + if (bucket) bucket.push(line); + else byInvoice.set(key, [line]); + } + + const companies = new Map(); + for (const [invoiceNumber, invLines] of byInvoice) { + const named = invLines.find((l) => l.company_name) ?? invLines[0]; + const companyName = named.company_name || UNKNOWN_COMPANY; + const companyKey = named.company_id || `name:${companyName.toLowerCase()}`; + const assignments = new Set(invLines.map((l) => `${l.delivery_driver}|${l.delivery_assigned_at}`)); + const first = invLines[0]; + const pdfLine = invLines.find((l) => l.invoice_pdf); + + const invoice: DeliveryPoolInvoice = { + invoiceNumber, + companyKey, + companyName, + dnNumbers: uniq(invLines.map((l) => l.delivery_note_number)), + orderNumbers: uniq(invLines.map((l) => l.order_number)), + orderIds: uniq(invLines.map((l) => l.order_id)), + invoicedDate: invLines.map((l) => l.invoiced_date).filter(Boolean).sort().pop() ?? "", + driverId: assignments.size === 1 ? first.delivery_driver : "", + assignedAt: assignments.size === 1 ? first.delivery_assigned_at : "", + mixedAssignment: assignments.size > 1, + total: Math.round(invLines.reduce((sum, l) => sum + l.quantity * l.selling_price, 0) * 100) / 100, + invoicePdf: pdfLine ? { lineId: pdfLine.id, filename: pdfLine.invoice_pdf } : null, + lines: invLines, + }; + + const company = companies.get(companyKey); + if (company) { + company.invoices.push(invoice); + company.total += invoice.total; + } else { + companies.set(companyKey, { key: companyKey, name: companyName, total: invoice.total, invoices: [invoice] }); + } + } + + const result = [...companies.values()]; + for (const c of result) { + c.total = Math.round(c.total * 100) / 100; + c.invoices.sort((a, b) => b.invoicedDate.localeCompare(a.invoicedDate) || b.invoiceNumber.localeCompare(a.invoiceNumber)); + } + return result.sort((a, b) => { + if (a.name === UNKNOWN_COMPANY) return 1; + if (b.name === UNKNOWN_COMPANY) return -1; + return a.name.localeCompare(b.name); + }); +} + +export type DeliveryAssignmentState = "unassigned" | "overdue" | "today" | "future" | "mixed"; + +export function deliveryAssignmentState( + invoice: Pick, + todayYmd: string, +): DeliveryAssignmentState { + if (invoice.mixedAssignment) return "mixed"; + if (!invoice.driverId) return "unassigned"; + if (!invoice.assignedAt || invoice.assignedAt === todayYmd) return "today"; + return invoice.assignedAt < todayYmd ? "overdue" : "future"; +} + +export type DeliveryPoolDriverFilter = "all" | "unassigned" | string; + +/** Text + driver filter. Empty companies are dropped. */ +export function filterDeliveryPool( + companies: DeliveryPoolCompany[], + text: string, + driver: DeliveryPoolDriverFilter, +): DeliveryPoolCompany[] { + const needle = text.trim().toLowerCase(); + const out: DeliveryPoolCompany[] = []; + for (const c of companies) { + const invoices = c.invoices.filter((inv) => { + if (driver === "unassigned" && (inv.driverId || inv.mixedAssignment)) return false; + if (driver !== "all" && driver !== "unassigned" && inv.driverId !== driver) return false; + if (!needle) return true; + const hay = [ + c.name, + inv.invoiceNumber, + ...inv.dnNumbers, + ...inv.orderNumbers, + ...inv.lines.map((l) => l.description), + ] + .join(" ") + .toLowerCase(); + return hay.includes(needle); + }); + if (invoices.length > 0) { + out.push({ ...c, invoices, total: Math.round(invoices.reduce((s, i) => s + i.total, 0) * 100) / 100 }); + } + } + return out; +} diff --git a/src/lib/server/delivery-pool-service.test.ts b/src/lib/server/delivery-pool-service.test.ts new file mode 100644 index 0000000..db8be89 --- /dev/null +++ b/src/lib/server/delivery-pool-service.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type Row = Record & { id: string }; + +const state = { + fields: ["delivery_driver", "delivery_assigned_at"], + lines: [] as Row[], + dns: [] as Row[], + updates: [] as Array<{ collection: string; id: string; data: unknown }>, + filters: [] as string[], +}; + +function fakePb() { + return { + collections: { + getOne: vi.fn(async () => ({ fields: state.fields.map((name) => ({ name })) })), + }, + collection: (name: string) => ({ + getFullList: vi.fn(async ({ filter }: { filter: string }) => { + state.filters.push(filter); + if (name === "delivery_notes") { + return state.dns.filter((dn) => filter.includes(`"${dn.delivery_note_number}"`)); + } + return state.lines.filter((l) => + !filter.includes("customer_invoice_number = ") || + filter.includes(`customer_invoice_number = "${l.customer_invoice_number}"`), + ); + }), + getList: vi.fn(async (_p: number, _n: number, { filter }: { filter: string }) => { + const dnNumber = /delivery_note_number = "([^"]+)"/.exec(filter)?.[1]; + const open = state.lines.filter((l) => l.delivery_note_number === dnNumber && l.item_status !== "completed"); + return { totalItems: open.length, items: open }; + }), + update: vi.fn(async (id: string, data: unknown) => { + state.updates.push({ collection: name, id, data }); + if (name !== "delivery_notes" && data instanceof FormData && data.get("item_status")) { + const line = state.lines.find((l) => l.id === id); + if (line) line.item_status = data.get("item_status"); + } + return { id }; + }), + }), + }; +} + +vi.mock("./pocketbase-admin", () => ({ getAdminPocketBase: vi.fn(async () => fakePb()) })); +vi.mock("./orders-service", () => ({ getOrderItemsCollectionName: () => "order_items" })); + +const svc = await import("./delivery-pool-service"); + +function line(over: Partial): Row { + return { + id: "l1", + description: "M20 nut", + quantity: 2, + selling_price: 5, + customer_invoice_number: "INV100", + delivery_note_number: "", + invoiced_date: "2026-10-30 00:00:00.000Z", + item_status: "invoiced", + order: "o1", + order_item_attachements: ["driver_photo_abcdefghij.jpg"], + expand: { order: { id: "o1", order_number: "ORD-1", company: "c1", expand: { company: { id: "c1", company: "Acme" } } } }, + ...over, + }; +} + +beforeEach(() => { + state.fields = ["delivery_driver", "delivery_assigned_at"]; + state.lines = []; + state.dns = []; + state.updates = []; + state.filters = []; + svc.resetDeliveryPoolFieldCheck(); +}); + +describe("listDeliveryPool", () => { + it("refuses to run before the migration", async () => { + state.fields = []; + await expect(svc.listDeliveryPool()).rejects.toMatchObject({ status: 503 }); + }); + + it("filters to undelivered invoiced lines and drops lines whose DN is already closed", async () => { + state.lines = [ + line({ id: "a", customer_invoice_number: "30352" }), + line({ id: "pos", customer_invoice_number: "INV9", delivery_note_number: "DN1" }), + ]; + state.dns = [{ id: "dn1", delivery_note_number: "DN1", status: "completed" }]; + const { companies } = await svc.listDeliveryPool(); + expect(state.filters[0]).toContain('item_status = "invoiced"'); + expect(state.filters[0]).toContain('delivered_at = ""'); + expect(companies).toHaveLength(1); + expect(companies[0].name).toBe("Acme"); + expect(companies[0].invoices.map((i) => i.invoiceNumber)).toEqual(["INV30352"]); + }); +}); + +describe("assignInvoice", () => { + it("writes the delivery driver fields on every line, never drv_id", async () => { + state.lines = [line({ id: "a" }), line({ id: "b" })]; + const result = await svc.assignInvoice({ invoiceNumber: "INV100", driverId: "abcdefghij12345", date: "2026-10-31" }); + expect(result.updated).toBe(2); + expect(state.updates.map((u) => u.data)).toEqual([ + { delivery_driver: "abcdefghij12345", delivery_assigned_at: "2026-10-31T00:00:00.000Z" }, + { delivery_driver: "abcdefghij12345", delivery_assigned_at: "2026-10-31T00:00:00.000Z" }, + ]); + }); + + it("unassigns with an empty driver and rejects a bad date", async () => { + state.lines = [line({ id: "a" })]; + await svc.assignInvoice({ invoiceNumber: "INV100", driverId: "", date: "" }); + expect(state.updates[0].data).toEqual({ delivery_driver: "", delivery_assigned_at: "" }); + await expect( + svc.assignInvoice({ invoiceNumber: "INV100", driverId: "abcdefghij12345", date: "31/10/2026" }), + ).rejects.toMatchObject({ status: 400 }); + }); + + it("404s an invoice that is not in the pool", async () => { + await expect(svc.assignInvoice({ invoiceNumber: "INV404", driverId: "", date: "" })).rejects.toMatchObject({ + status: 404, + }); + }); +}); + +describe("completeInvoice", () => { + const file = new File(["pdf"], "scan.pdf", { type: "application/pdf" }); + + it("adds the signed copy, completes every line and closes a DN with nothing left open", async () => { + state.lines = [ + line({ id: "a", delivery_note_number: "DN5" }), + line({ id: "b", delivery_note_number: "DN5" }), + ]; + state.dns = [{ id: "dn5", delivery_note_number: "DN5", status: "sent", signed_copy: "" }]; + const result = await svc.completeInvoice({ invoiceNumber: "INV100", file }); + expect(result).toEqual({ invoiceNumber: "INV100", completed: 2, deliveryNotes: ["DN5"] }); + + const lineForm = state.updates[0].data as FormData; + expect(lineForm.get("order_item_attachements")).toBeNull(); // never the replacing bare field + expect((lineForm.get("order_item_attachements+") as File).name).toBe("signed_INV100.pdf"); + expect(lineForm.getAll("order_item_attachements-")).toEqual([]); // driver photo kept + expect(lineForm.get("item_status")).toBe("completed"); + expect(lineForm.get("delivered_at")).toBeTruthy(); + + const dnUpdate = state.updates.find((u) => u.collection === "delivery_notes"); + expect((dnUpdate?.data as FormData).get("status")).toBe("completed"); + }); + + it("leaves a DN open while it still carries lines from another invoice", async () => { + state.lines = [ + line({ id: "a", delivery_note_number: "DN6" }), + line({ id: "other", customer_invoice_number: "INV200", delivery_note_number: "DN6" }), + ]; + state.dns = [{ id: "dn6", delivery_note_number: "DN6", status: "sent" }]; + const result = await svc.completeInvoice({ invoiceNumber: "INV100", file }); + expect(result.deliveryNotes).toEqual([]); + expect(state.updates.some((u) => u.collection === "delivery_notes")).toBe(false); + }); +}); diff --git a/src/lib/server/delivery-pool-service.ts b/src/lib/server/delivery-pool-service.ts new file mode 100644 index 0000000..347e659 --- /dev/null +++ b/src/lib/server/delivery-pool-service.ts @@ -0,0 +1,280 @@ +/** + * Delivery Pool — every invoiced customer line still waiting to go out, + * one card per customer invoice (design: + * docs/superpowers/specs/2026-10-30-delivery-pool-design.md). + * + * listDeliveryPool → company sections of invoice cards + * assignInvoice → driver + date on every pool line of one invoice + * completeInvoice → signed copy in, every line `completed` + * + * The delivery driver lives in its own fields (`delivery_driver`, + * `delivery_assigned_at`) so the collection driver in `drv_id` is never + * overwritten. Item status stays `invoiced` until the signed copy is in. + */ +import { getAdminPocketBase } from "./pocketbase-admin"; +import { getOrderItemsCollectionName } from "./orders-service"; +import { addAttachmentForm, attachmentList } from "./order-item-files"; +import { assignedDateToIso } from "./order-item-status"; +import { + DELIVERY_POOL_START, + groupDeliveryPool, + normalizeInvoiceNumber, + type DeliveryPoolCompany, + type DeliveryPoolLine, +} from "@/lib/delivery-pool"; + +type AdminPb = Awaited>; + +const DELIVERY_NOTES = "delivery_notes"; +/** DN statuses that mean the paper is already closed — POS counter sales land here. */ +const CLOSED_DN_STATUSES = new Set(["completed", "cancelled"]); +const POOL_FIELDS = ["delivery_driver", "delivery_assigned_at"]; + +// ─── Schema guard ─────────────────────────────────────────────────── + +let poolFieldsConfirmed = false; + +/** + * PocketBase silently drops unknown fields on update and rejects filters on + * them. Refuse to run until `npm run pb:migrate:delivery-pool` has added the + * fields. A positive answer is cached. + */ +async function assertPoolFields(pb: AdminPb): Promise { + if (poolFieldsConfirmed) return; + const collection = (await pb.collections.getOne(getOrderItemsCollectionName())) as { + fields?: Array<{ name: string }>; + schema?: Array<{ name: string }>; + }; + const names = new Set((collection.fields ?? collection.schema ?? []).map((f) => f.name)); + const missing = POOL_FIELDS.filter((f) => !names.has(f)); + if (missing.length > 0) { + throw Object.assign( + new Error(`order_items is missing ${missing.join(", ")} — run npm run pb:migrate:delivery-pool first.`), + { status: 503 }, + ); + } + poolFieldsConfirmed = true; +} + +/** Test hook: forget the cached schema check. */ +export function resetDeliveryPoolFieldCheck(): void { + poolFieldsConfirmed = false; +} + +// ─── Reading the pool ─────────────────────────────────────────────── + +type RawLine = { + id: string; + description?: string; + quantity?: number; + selling_price?: number; + customer_invoice_number?: string; + delivery_note_number?: string; + invoiced_date?: string; + delivery_driver?: string; + delivery_assigned_at?: string; + order?: string; + qb_invoice_pdf?: unknown; + order_item_attachements?: unknown; + order_item_details?: { invoice?: { invoiceCompany?: string; qbInvoiceId?: string } } | null; + expand?: { + order?: { + id: string; + order_number?: string; + company?: string; + expand?: { company?: { id: string; company?: string } }; + }; + }; +}; + +type RawDn = { + id: string; + delivery_note_number: string; + status: string; + company?: string; + signed_copy?: unknown; + expand?: { company?: { id: string; company?: string } }; +}; + +export const POOL_FILTER = [ + `item_status = "invoiced"`, + `customer_invoice_number != ""`, + `invoiced_date >= "${DELIVERY_POOL_START} 00:00:00.000Z"`, + `delivered_at = ""`, + `exclude_from_invoice != true`, +].join(" && "); + +function ymd(value: string | undefined): string { + return value ? value.slice(0, 10) : ""; +} + +function quote(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +async function fetchDns(pb: AdminPb, numbers: string[]): Promise> { + const out = new Map(); + const unique = [...new Set(numbers.filter(Boolean))]; + for (let i = 0; i < unique.length; i += 40) { + const chunk = unique.slice(i, i + 40); + const rows = await pb.collection(DELIVERY_NOTES).getFullList({ + filter: chunk.map((n) => `delivery_note_number = ${quote(n)}`).join(" || "), + expand: "company", + }); + for (const dn of rows) out.set(dn.delivery_note_number, dn); + } + return out; +} + +function toPoolLine(raw: RawLine, dn: RawDn | undefined): DeliveryPoolLine { + const order = raw.expand?.order; + const orderCompany = order?.expand?.company; + const dnCompany = dn?.expand?.company; + // `order_item_details.invoice` is the customer invoice only when it came + // from QuickBooks — Collection Confirm writes the supplier's invoice there. + const qbCompany = raw.order_item_details?.invoice?.qbInvoiceId + ? raw.order_item_details.invoice.invoiceCompany ?? "" + : ""; + return { + id: raw.id, + description: raw.description ?? "", + quantity: Number(raw.quantity) || 0, + selling_price: Number(raw.selling_price) || 0, + customer_invoice_number: raw.customer_invoice_number ?? "", + delivery_note_number: raw.delivery_note_number ?? "", + invoiced_date: ymd(raw.invoiced_date), + delivery_driver: raw.delivery_driver ?? "", + delivery_assigned_at: ymd(raw.delivery_assigned_at), + order_id: order?.id ?? raw.order ?? "", + order_number: order?.order_number ?? "", + company_id: orderCompany?.id ?? order?.company ?? dnCompany?.id ?? dn?.company ?? "", + company_name: orderCompany?.company ?? dnCompany?.company ?? qbCompany, + invoice_pdf: attachmentList(raw.qb_invoice_pdf).at(-1) ?? "", + }; +} + +async function fetchPoolLines(pb: AdminPb, extraFilter?: string): Promise<{ raw: RawLine[]; dns: Map }> { + const rows = await pb.collection(getOrderItemsCollectionName()).getFullList({ + filter: extraFilter ? `${POOL_FILTER} && (${extraFilter})` : POOL_FILTER, + sort: "-invoiced_date", + expand: "order.company", + }); + const dns = await fetchDns(pb, rows.map((r) => r.delivery_note_number ?? "")); + // A line whose DN is already closed went out another way (POS counter sale). + const raw = rows.filter((r) => { + const dn = r.delivery_note_number ? dns.get(r.delivery_note_number) : undefined; + return !dn || !CLOSED_DN_STATUSES.has(dn.status); + }); + return { raw, dns }; +} + +export async function listDeliveryPool(): Promise<{ companies: DeliveryPoolCompany[]; startDate: string }> { + const pb = await getAdminPocketBase(); + await assertPoolFields(pb); + const { raw, dns } = await fetchPoolLines(pb); + const lines = raw.map((r) => toPoolLine(r, r.delivery_note_number ? dns.get(r.delivery_note_number) : undefined)); + return { companies: groupDeliveryPool(lines), startDate: DELIVERY_POOL_START }; +} + +/** Every pool line on one invoice ("30352" and "INV30352" both match). */ +async function invoiceLines(pb: AdminPb, invoiceNumber: string) { + const key = normalizeInvoiceNumber(invoiceNumber); + if (!key) throw Object.assign(new Error("Invoice number is required."), { status: 400 }); + const digits = key.replace(/^INV/, ""); + const variants = [...new Set([key, digits, `INV-${digits}`, `inv${digits}`])]; + const { raw, dns } = await fetchPoolLines( + pb, + variants.map((v) => `customer_invoice_number = ${quote(v)}`).join(" || "), + ); + const lines = raw.filter((r) => normalizeInvoiceNumber(r.customer_invoice_number) === key); + if (lines.length === 0) { + throw Object.assign(new Error(`Invoice ${key} is not in the delivery pool.`), { status: 404 }); + } + return { key, lines, dns }; +} + +// ─── Assign ───────────────────────────────────────────────────────── + +const YMD = /^\d{4}-\d{2}-\d{2}$/; + +export async function assignInvoice(input: { + invoiceNumber: string; + /** Empty string unassigns. */ + driverId: string; + /** YYYY-MM-DD; required when assigning. */ + date: string; +}): Promise<{ invoiceNumber: string; updated: number; driverId: string; assignedAt: string }> { + const driverId = input.driverId.trim(); + if (driverId && !/^[a-z0-9]{15}$/.test(driverId)) { + throw Object.assign(new Error("driverId must be a user record id."), { status: 400 }); + } + const iso = driverId ? (YMD.test(input.date) ? assignedDateToIso(input.date) : null) : ""; + if (iso === null) { + throw Object.assign(new Error(`Invalid date: expected YYYY-MM-DD, got "${input.date}".`), { status: 400 }); + } + + const pb = await getAdminPocketBase(); + await assertPoolFields(pb); + const { key, lines } = await invoiceLines(pb, input.invoiceNumber); + const collection = pb.collection(getOrderItemsCollectionName()); + for (const line of lines) { + await collection.update(line.id, { delivery_driver: driverId, delivery_assigned_at: iso }); + } + return { invoiceNumber: key, updated: lines.length, driverId, assignedAt: driverId ? input.date : "" }; +} + +// ─── Signed copy ──────────────────────────────────────────────────── + +function signedCopyName(invoiceNumber: string, file: File): string { + const dot = file.name.lastIndexOf("."); + const ext = dot > 0 ? file.name.slice(dot).toLowerCase() : ""; + return `signed_${invoiceNumber}${ext || ".pdf"}`; +} + +/** + * Upload the signed copy and close the invoice: the file is ADDED to every + * line's attachments, stored on each linked DN that has no signed copy yet + * (that DN is set `completed`), every line gets `delivered_at` and + * `item_status = "completed"`. + */ +export async function completeInvoice(input: { + invoiceNumber: string; + file: File; +}): Promise<{ invoiceNumber: string; completed: number; deliveryNotes: string[] }> { + if (!input.file || input.file.size === 0) { + throw Object.assign(new Error("A signed copy file is required."), { status: 400 }); + } + const pb = await getAdminPocketBase(); + await assertPoolFields(pb); + const { key, lines, dns } = await invoiceLines(pb, input.invoiceNumber); + const filename = signedCopyName(key, input.file); + const now = new Date().toISOString(); + const collection = pb.collection(getOrderItemsCollectionName()); + + for (const line of lines) { + const form = addAttachmentForm(input.file, filename, attachmentList(line.order_item_attachements)); + form.append("delivered_at", now); + form.append("item_status", "completed"); + await collection.update(line.id, form); + } + + const closedDns: string[] = []; + const dnNumbers = [...new Set(lines.map((l) => l.delivery_note_number ?? "").filter(Boolean))]; + for (const number of dnNumbers) { + const dn = dns.get(number); + if (!dn || CLOSED_DN_STATUSES.has(dn.status)) continue; + // Only close the DN when every one of its lines is now done — a DN can + // carry lines from another invoice that is still out. + const open = await pb.collection(getOrderItemsCollectionName()).getList(1, 1, { + filter: `delivery_note_number = ${quote(number)} && item_status != "completed" && item_status != "cancelled" && item_status != "removed"`, + }); + if (open.totalItems > 0) continue; + const form = new FormData(); + if (attachmentList(dn.signed_copy).length === 0) form.append("signed_copy", input.file, filename); + form.append("status", "completed"); + await pb.collection(DELIVERY_NOTES).update(dn.id, form); + closedDns.push(number); + } + + return { invoiceNumber: key, completed: lines.length, deliveryNotes: closedDns }; +}