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:
parent
4ae45cb3aa
commit
45529731a7
14 changed files with 1680 additions and 0 deletions
19
AGENTS.md
19
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.
|
||||
|
|
|
|||
115
docs/superpowers/specs/2026-10-30-delivery-pool-design.md
Normal file
115
docs/superpowers/specs/2026-10-30-delivery-pool-design.md
Normal 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.
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
67
scripts/migrations/2026-10-30-add-delivery-pool-fields.ts
Normal file
67
scripts/migrations/2026-10-30-add-delivery-pool-fields.ts
Normal 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);
|
||||
});
|
||||
274
src/app/(app)/delivery-pool/components.tsx
Normal file
274
src/app/(app)/delivery-pool/components.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
371
src/app/(app)/delivery-pool/page.tsx
Normal file
371
src/app/(app)/delivery-pool/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
31
src/app/api/v1/delivery-pool/[invoice]/assign/route.ts
Normal file
31
src/app/api/v1/delivery-pool/[invoice]/assign/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
34
src/app/api/v1/delivery-pool/[invoice]/signed-copy/route.ts
Normal file
34
src/app/api/v1/delivery-pool/[invoice]/signed-copy/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
23
src/app/api/v1/delivery-pool/route.ts
Normal file
23
src/app/api/v1/delivery-pool/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
122
src/lib/delivery-pool.test.ts
Normal file
122
src/lib/delivery-pool.test.ts
Normal 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
180
src/lib/delivery-pool.ts
Normal 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;
|
||||
}
|
||||
158
src/lib/server/delivery-pool-service.test.ts
Normal file
158
src/lib/server/delivery-pool-service.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
280
src/lib/server/delivery-pool-service.ts
Normal file
280
src/lib/server/delivery-pool-service.ts
Normal 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 };
|
||||
}
|
||||
Loading…
Reference in a new issue