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 <noreply@anthropic.com>
This commit is contained in:
Dave 2026-10-31 09:55:35 +02:00
parent 4ae45cb3aa
commit 45529731a7
14 changed files with 1680 additions and 0 deletions

View file

@ -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.

View file

@ -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 / <driver>").
## 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.

View file

@ -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",

View file

@ -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<string, unknown> & { 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);
});

View file

@ -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<DeliveryAssignmentState, string> = {
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 (
<span
data-od-id="delivery-assignment-badge"
className={cn("inline-flex items-center rounded-full px-2 py-0.5 text-xs font-semibold", BADGE_CLASS[state])}
>
{label}
</span>
);
}
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<HTMLInputElement>(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 (
<li
data-od-id={`delivery-invoice-${invoice.invoiceNumber}`}
className={cn("rounded-lg border bg-card", selected && "ring-2 ring-primary/50")}
>
<div className="flex flex-wrap items-center gap-3 px-3 py-2.5">
<input
type="checkbox"
className="size-4 shrink-0"
checked={selected}
onChange={onToggleSelected}
aria-label={`Select ${invoice.invoiceNumber}`}
/>
<button
type="button"
onClick={onToggleExpanded}
aria-expanded={expanded}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
>
{expanded ? (
<ChevronDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
) : (
<ChevronRight className="size-4 shrink-0 text-muted-foreground" aria-hidden />
)}
<span className="font-semibold tabular-nums">{invoice.invoiceNumber}</span>
<span className="truncate text-xs text-muted-foreground">
{[
invoice.dnNumbers.join(", "),
invoice.orderNumbers.join(", "),
`${invoice.lines.length} ${invoice.lines.length === 1 ? "line" : "lines"}`,
invoice.invoicedDate ? `invoiced ${formatYmd(invoice.invoicedDate)}` : "",
]
.filter(Boolean)
.join(" · ")}
</span>
</button>
<span className="text-sm font-medium tabular-nums">{formatMoney(invoice.total)}</span>
<AssignmentBadge invoice={invoice} todayYmd={todayYmd} driverName={driverName} />
<div className="flex flex-wrap items-center gap-1.5">
{pdfHref && (
<a
href={pdfHref}
target="_blank"
rel="noreferrer"
title="Open invoice PDF"
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
<FileText className="size-4" aria-hidden />
<span className="sr-only">Invoice PDF</span>
</a>
)}
<Button variant="outline" size="sm" disabled={busy} onClick={onAssign}>
{assigned ? "Reschedule" : "Assign"}
</Button>
{assigned && (
<Button variant="ghost" size="sm" disabled={busy} onClick={onUnassign} title="Unassign driver">
<UserX className="size-4" aria-hidden />
<span className="sr-only">Unassign</span>
</Button>
)}
<Button size="sm" disabled={busy} onClick={() => fileInput.current?.click()}>
<Upload className="size-4" aria-hidden />
Signed copy
</Button>
<input
ref={fileInput}
type="file"
accept="application/pdf,image/*"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (file) onSignedCopyPicked(file);
}}
/>
</div>
</div>
{expanded && (
<table className="w-full border-t text-sm">
<thead className="text-xs text-muted-foreground">
<tr>
<th className="px-3 py-1.5 text-left font-medium">Item</th>
<th className="px-3 py-1.5 text-right font-medium">Qty</th>
<th className="px-3 py-1.5 text-right font-medium">Price</th>
<th className="px-3 py-1.5 text-right font-medium">Total</th>
</tr>
</thead>
<tbody>
{invoice.lines.map((line) => (
<tr key={line.id} className="border-t">
<td className="px-3 py-1.5">
{line.description || "(no description)"}
{line.order_id && (
<a
href={`/orders/${line.order_id}`}
className="ml-2 text-xs text-muted-foreground hover:underline"
>
{line.order_number}
</a>
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">{line.quantity}</td>
<td className="px-3 py-1.5 text-right tabular-nums">{formatMoney(line.selling_price)}</td>
<td className="px-3 py-1.5 text-right tabular-nums">
{formatMoney(line.quantity * line.selling_price)}
</td>
</tr>
))}
</tbody>
</table>
)}
</li>
);
}
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 (
<section data-od-id={`delivery-company-${company.key}`} className="border-b last:border-b-0">
<header className="sticky top-0 z-10 flex flex-wrap items-center gap-3 border-b bg-background px-4 py-2.5">
<div className="pointer-events-none absolute inset-0 bg-muted/30" aria-hidden />
<input
type="checkbox"
className="relative size-4"
checked={allSelected}
ref={(el) => {
if (el) el.indeterminate = selectedCount > 0 && !allSelected;
}}
onChange={onToggleAllSelected}
aria-label={`Select every invoice for ${company.name}`}
/>
<button
type="button"
onClick={onToggleCollapsed}
aria-expanded={!collapsed}
className="relative flex items-center gap-2 text-left"
>
{collapsed ? (
<ChevronRight className="size-4 text-muted-foreground" aria-hidden />
) : (
<ChevronDown className="size-4 text-muted-foreground" aria-hidden />
)}
<h3 className="text-sm font-semibold text-foreground">{company.name}</h3>
</button>
<span className="relative text-xs text-muted-foreground">
· {count} {count === 1 ? "invoice" : "invoices"} · {formatMoney(company.total)}
</span>
<div className="relative ml-auto">
<Button variant="outline" size="sm" onClick={onBulkAssign}>
{assignLabel}
</Button>
</div>
</header>
{!collapsed && <ul className="space-y-2 px-4 py-3">{children}</ul>}
</section>
);
}

View file

@ -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<string, string> {
const token = getPocketBase().authStore.token;
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function readError(res: Response): Promise<string> {
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<DeliveryPoolCompany[] | null>(null);
const [startDate, setStartDate] = useState("");
const [loadError, setLoadError] = useState<string | null>(null);
const [filterText, setFilterText] = useState("");
const [driverFilter, setDriverFilter] = useState<DeliveryPoolDriverFilter>("all");
const [driversById, setDriversById] = useState<Record<string, string>>({});
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false);
const [assignTarget, setAssignTarget] = useState<AssignTarget | null>(null);
const [signedCopyTarget, setSignedCopyTarget] = useState<SignedCopyTarget | null>(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<string, string> = {};
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<string>();
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<React.SetStateAction<Set<string>>>, 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 (
<div className="space-y-6">
<header className="flex items-start justify-between gap-4">
<div>
<h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight">
<Truck className="h-5 w-5 text-muted-foreground" />
Delivery Pool
</h1>
<p className="mt-1 text-sm text-muted-foreground">
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.</>}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => void load()}>
Refresh
</Button>
</header>
<div className="rounded-xl border bg-card">
<div className="flex flex-col gap-3 px-4 py-3 lg:flex-row lg:items-center lg:justify-between">
<div className="flex min-w-0 items-center gap-3 rounded-lg border bg-background px-3 py-2 lg:min-w-[22rem] lg:max-w-xl lg:flex-1">
<Search className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
<input
type="search"
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
placeholder="Search company, invoice, DN, order or item..."
value={filterText}
onChange={(e) => setFilterText(e.target.value)}
aria-label="Search delivery pool"
/>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Driver</span>
<div role="radiogroup" aria-label="Filter by driver" className="inline-flex flex-wrap rounded-lg border bg-background p-1">
{[
{ id: "all", label: "All" },
{ id: "unassigned", label: "Unassigned" },
...assignedDrivers.map((id) => ({ id, label: driversById[id] ?? `Driver ${id.slice(0, 6)}` })),
].map((opt) => (
<button
key={opt.id}
type="button"
role="radio"
aria-checked={driverFilter === opt.id}
onClick={() => setDriverFilter(opt.id)}
className={cn(
"rounded-md px-3 py-1 text-xs font-medium transition-colors",
driverFilter === opt.id
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
<div className="border-t px-4 py-2 text-xs text-muted-foreground">
{totals.invoices} {totals.invoices === 1 ? "invoice" : "invoices"} · {totals.unassigned} unassigned ·{" "}
{formatMoney(totals.value)} ex VAT
</div>
<div className="border-t">
{companies === null ? (
<p className="px-4 py-8 text-sm text-muted-foreground">Loading…</p>
) : loadError ? (
<p className="px-4 py-8 text-sm text-destructive">Couldn&apos;t load the delivery pool: {loadError}</p>
) : visible.length === 0 ? (
<p className="px-4 py-8 text-sm text-muted-foreground">
{companies.length === 0 ? "Nothing waiting to be delivered." : "No invoices match this filter."}
</p>
) : (
visible.map((company) => {
const keys = company.invoices.map((i) => i.invoiceNumber);
const selectedHere = keys.filter((k) => selected.has(k));
return (
<CompanyGroup
key={company.key}
company={company}
collapsed={collapsed.has(company.key)}
selectedCount={selectedHere.length}
onToggleCollapsed={() => 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) => (
<InvoiceCard
key={invoice.invoiceNumber}
invoice={invoice}
todayYmd={today}
driverName={driversById[invoice.driverId]}
expanded={expanded.has(invoice.invoiceNumber)}
selected={selected.has(invoice.invoiceNumber)}
busy={busy}
onToggleExpanded={() => 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 })}
/>
))}
</CompanyGroup>
);
})
)}
</div>
</div>
<AssignDriverDialog
open={assignTarget !== null}
onOpenChange={(open) => {
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);
}}
/>
<ModalShell.Dialog
open={signedCopyTarget !== null}
onOpenChange={(open) => {
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={
<>
<Button variant="outline" disabled={busy} onClick={() => setSignedCopyTarget(null)}>
Cancel
</Button>
<Button disabled={busy || !signedCopyTarget} onClick={() => signedCopyTarget && void uploadSignedCopy(signedCopyTarget)}>
{busy ? "Uploading…" : "Upload & complete"}
</Button>
</>
}
>
{signedCopyTarget && (
<div className="space-y-1 text-sm">
<p>
File: <span className="font-medium">{signedCopyTarget.file.name}</span>
</p>
<p className="text-muted-foreground">
{signedCopyTarget.invoice.lines.length} {signedCopyTarget.invoice.lines.length === 1 ? "line" : "lines"} ·{" "}
{formatMoney(signedCopyTarget.invoice.total)}
{signedCopyTarget.invoice.dnNumbers.length > 0 && <> · {signedCopyTarget.invoice.dnNumbers.join(", ")}</>}
</p>
</div>
)}
</ModalShell.Dialog>
</div>
);
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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 });
}
}

View file

@ -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" },

View file

@ -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>): 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"]);
});
});

180
src/lib/delivery-pool.ts Normal file
View file

@ -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<string, DeliveryPoolLine[]>();
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<string, DeliveryPoolCompany>();
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<DeliveryPoolInvoice, "driverId" | "assignedAt" | "mixedAssignment">,
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;
}

View file

@ -0,0 +1,158 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type Row = Record<string, unknown> & { 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>): 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);
});
});

View file

@ -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<ReturnType<typeof getAdminPocketBase>>;
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<void> {
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<Map<string, RawDn>> {
const out = new Map<string, RawDn>();
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<RawDn>({
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<string, RawDn> }> {
const rows = await pb.collection(getOrderItemsCollectionName()).getFullList<RawLine>({
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 };
}