feat(delivery-notes, migrations): add tabs, grouping, markup, and fix DN relations

- Add shared env loader for migration scripts to load .env and .env.local files at module init
- Add migration script to fix broken delivery note relation fields and backfill missing company data
- Update delivery notes list page: add status filter tabs, company-based collapsible grouping, increased fetch limit, and improved global search
- Add per-item cost price, markup calculations, and total summary rows for cost, selling price, markup, and margin to delivery note detail pages
This commit is contained in:
Dave 2026-08-14 09:41:14 +02:00
parent 43de97501c
commit 3b8bc0fc35
4 changed files with 535 additions and 72 deletions

View file

@ -0,0 +1,130 @@
import "./_env-loader";
import { getAdminPocketBase } from "@/lib/server/pocketbase-admin";
/**
* Migration — 2026-08-13-fix-delivery-notes-relations.ts
*
* `delivery_notes` was created with relation fields (`customer`,
* `company`, `order`, `created_by`) but the live schema has
* `collectionId: null` on those fields, so any `expand=...` query
* silently returns nothing. The list page relies on
* `expand=company.company` for per-company grouping, so all DNs
* collapse into a single "No company" bucket — and crucially, the
* records remain in the table, but grouped wrong (and reported
* missing when the operator searches by number).
*
* This migration patches the relation targets so expansion resolves,
* then backfills `delivery_notes.company` from the parent order's
* company where it's currently empty (defensive — most records
* already have the right value from the original create path).
*
* Run:
* npx tsx scripts/migrations/2026-08-13-fix-delivery-notes-relations.ts
*/
const CUSTOMERS = "customers";
const COMPANIES = "companies";
const ORDERS = "orders";
const USERS = "users";
const DELIVERY_NOTES = "delivery_notes";
async function patchRelation(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
collectionId: string,
fieldName: string,
targetCollectionId: string,
log: string[],
): Promise<void> {
const collection = await pb.collections.getOne(collectionId);
const field = (collection.fields ?? []).find((f) => f.name === fieldName);
if (!field) {
log.push(` ✗ ${fieldName} not found on ${collection.name}`);
return;
}
if (field.type !== "relation") {
log.push(` ✗ ${fieldName} is not a relation (type=${field.type})`);
return;
}
const opts = (field.options ?? {}) as { collectionId?: string | null };
if (opts.collectionId === targetCollectionId) {
log.push(` · ${fieldName}.collectionId already set — skip`);
return;
}
log.push(` → patching ${fieldName}.collectionId to ${targetCollectionId}`);
await pb.collections.update(collection.id, {
fields: [
{ ...field, options: { ...opts, collectionId: targetCollectionId } },
],
});
}
async function backfillCompany(
pb: Awaited<ReturnType<typeof getAdminPocketBase>>,
log: string[],
): Promise<{ updated: number; skipped: number; failed: number }> {
let updated = 0;
let skipped = 0;
let failed = 0;
const records = await pb.collection(DELIVERY_NOTES).getFullList<{
id: string;
company: string;
order: string;
}>({ filter: "company = '' || company = null" });
log.push(` · ${records.length} delivery note(s) need a company backfill`);
for (const dn of records) {
if (!dn.order) {
skipped += 1;
continue;
}
try {
const order = await pb.collection(ORDERS).getOne(dn.order, {
fields: "company",
});
const company = (order as unknown as { company?: string }).company;
if (!company) {
skipped += 1;
continue;
}
await pb.collection(DELIVERY_NOTES).update(dn.id, { company });
updated += 1;
} catch (err) {
failed += 1;
log.push(
` ✗ ${dn.id} (${dn.delivery_note_number || dn.id}): ${err instanceof Error ? err.message : String(err)}`,
);
}
}
return { updated, skipped, failed };
}
async function main() {
const log: string[] = [];
log.push("[migration:fix-delivery-notes-relations] starting");
const pb = await getAdminPocketBase();
const customersCol = await pb.collections.getOne(CUSTOMERS);
const companiesCol = await pb.collections.getOne(COMPANIES);
const ordersCol = await pb.collections.getOne(ORDERS);
const usersCol = await pb.collections.getOne(USERS);
log.push("[migration:fix-delivery-notes-relations] step 1 — patch relation targets");
await patchRelation(pb, DELIVERY_NOTES, "customer", customersCol.id, log);
await patchRelation(pb, DELIVERY_NOTES, "company", companiesCol.id, log);
await patchRelation(pb, DELIVERY_NOTES, "order", ordersCol.id, log);
await patchRelation(pb, DELIVERY_NOTES, "created_by", usersCol.id, log);
log.push("[migration:fix-delivery-notes-relations] step 2 — backfill empty company from order");
const { updated, skipped, failed } = await backfillCompany(pb, log);
log.push(
`[migration:fix-delivery-notes-relations] updated=${updated} skipped=${skipped} failed=${failed}`,
);
log.push("[migration:fix-delivery-notes-relations] done");
for (const line of log) console.log(line);
}
main().catch((err) => {
console.error("[migration:fix-delivery-notes-relations] fatal:", err);
process.exit(1);
});

View file

@ -0,0 +1,31 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
/**
* Inline .env/.env.local loader — must be the FIRST import in any
* migration script that reads process.env at module-init time.
* tsx's ESM hoisting means all top-level imports run before the
* body, so we load env inside the imported module's TOP-LEVEL
* SCOPE (here) instead of in the consumer's body.
*/
function loadEnv(): void {
for (const name of [".env", ".env.local"]) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
// missing file — rely on shell env
}
}
}
loadEnv();
export { loadEnv };

View file

@ -671,6 +671,15 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
(sum, it) => sum + (Number(it.selling_price) || 0) * (Number(it.quantity) || 1),
0,
);
const itemCostTotal = items.reduce(
(sum, it) => sum + (Number(it.cost_price) || 0) * (Number(it.quantity) || 1),
0,
);
const itemMarkupTotal = itemSubtotal - itemCostTotal;
const itemMarkupPercent =
itemCostTotal > 0 && itemSubtotal > 0
? ((itemSubtotal - itemCostTotal) / itemCostTotal) * 100
: 0;
return (
<div className="space-y-6">
@ -1080,26 +1089,34 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
) : (
<Table className="text-xs">
<colgroup>
<col className="w-[58%] sm:w-[48%]" />
<col className="hidden sm:table-cell sm:w-[18%]" />
<col className="w-[10%] sm:w-[8%]" />
<col className="w-[16%] sm:w-[13%]" />
<col className="w-[16%] sm:w-[13%]" />
<col className="w-[44%] sm:w-[40%]" />
<col className="w-[10%] sm:w-[10%]" />
<col className="w-[14%] sm:w-[14%]" />
<col className="w-[16%] sm:w-[18%]" />
<col className="w-[16%] sm:w-[18%]" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead className="px-2 py-1 text-[11px]">Description</TableHead>
<TableHead className="hidden px-2 py-1 text-[11px] sm:table-cell">Supplier</TableHead>
<TableHead className="px-1 py-1 text-right text-[11px]">Qty</TableHead>
<TableHead className="px-1 py-1 text-right text-[11px]">Unit</TableHead>
<TableHead className="px-1 py-1 text-right text-[11px]">Subtotal</TableHead>
<TableHead className="px-1 py-1 text-right text-[11px]">Cost</TableHead>
<TableHead className="px-1 py-1 text-right text-[11px]">Selling</TableHead>
<TableHead className="px-1 py-1 text-right text-[11px]">Markup</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((it) => {
const qty = Number(it.quantity) || 1;
const costPrice = Number(it.cost_price) || 0;
const price = Number(it.selling_price) || 0;
const subtotal = qty * price;
const costSubtotal = qty * costPrice;
const markupValue = costPrice > 0 ? price - costPrice : 0;
const markupPercent =
costPrice > 0 && price > 0
? ((price - costPrice) / costPrice) * 100
: 0;
const hasMarkup = costPrice > 0 && price > 0;
return (
<TableRow key={it.id} className="text-xs">
<TableCell className="px-2 py-1 align-top whitespace-normal">
@ -1115,23 +1132,7 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
Status: {it.item_status}
</div>
</TableCell>
<TableCell className="hidden px-2 py-1 align-top text-[11px] text-muted-foreground break-words sm:table-cell">
{it.expand?.supplier?.suppliername || "—"}
</TableCell>
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
{/*
Inline qty editor. Click the static
number to open an input; Enter or blur
saves, Escape cancels. Only available
on draft / sent DNs — once the DN is
po_received / invoiced / completed,
the cell goes read-only and the
"Qty edited" affordance goes away.
`qtySavingId` adds a brief spinner
while the PATCH is in flight; the
cell stays mounted (and disabled) so
the layout doesn't jump.
*/}
{qtyEditingId === it.id ? (
<Input
type="number"
@ -1192,12 +1193,41 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
)}
</TableCell>
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
{price ? formatCurrency(price) : (
{costPrice ? (
<div className="flex flex-col items-end leading-tight">
<span>{formatCurrency(costPrice)}</span>
<span className="text-[10px] text-muted-foreground">
Σ {formatCurrency(costSubtotal)}
</span>
</div>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
{price ? formatCurrency(subtotal) : (
{price ? (
<div className="flex flex-col items-end leading-tight">
<span>{formatCurrency(price)}</span>
<span className="text-[10px] text-muted-foreground">
Σ {formatCurrency(subtotal)}
</span>
</div>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</TableCell>
<TableCell className="px-1 py-1 align-top text-right tabular-nums text-xs">
{hasMarkup ? (
<div className="flex flex-col items-end leading-tight">
<span className={markupPercent < 0 ? "text-destructive" : ""}>
{markupPercent.toFixed(1)}%
</span>
<span className="text-[10px] text-muted-foreground">
{markupValue >= 0 ? "+" : ""}
{formatCurrency(markupValue)}
</span>
</div>
) : (
<span className="text-[10px] text-muted-foreground">—</span>
)}
</TableCell>
@ -1207,6 +1237,56 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
</TableBody>
</Table>
)}
{(itemSubtotal > 0 || itemCostTotal > 0) && (
<div className="grid grid-cols-2 gap-x-4 gap-y-1 border-t px-4 py-3 text-xs sm:grid-cols-4">
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Total cost
</div>
<div className="font-semibold tabular-nums">
{itemCostTotal > 0 ? formatCurrency(itemCostTotal) : "—"}
</div>
</div>
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Total selling
</div>
<div className="font-semibold tabular-nums">
{itemSubtotal > 0 ? formatCurrency(itemSubtotal) : "—"}
</div>
</div>
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Markup
</div>
<div
className={cn(
"font-semibold tabular-nums",
itemMarkupTotal < 0 && "text-destructive",
)}
>
{itemCostTotal > 0 && itemSubtotal > 0
? `${itemMarkupPercent.toFixed(1)}%`
: "—"}
</div>
</div>
<div className="text-right">
<div className="text-[10px] uppercase tracking-wider text-muted-foreground">
Margin
</div>
<div
className={cn(
"font-semibold tabular-nums",
itemMarkupTotal < 0 && "text-destructive",
)}
>
{itemCostTotal > 0 && itemSubtotal > 0
? `${itemMarkupTotal >= 0 ? "+" : ""}${formatCurrency(itemMarkupTotal)}`
: "—"}
</div>
</div>
</div>
)}
</CardContent>
</Card>

View file

@ -30,6 +30,9 @@ import {
import { toast } from "sonner";
import {
Banknote,
Building2,
ChevronDown,
ChevronRight,
Combine,
FileSignature,
FileText,
@ -38,6 +41,7 @@ import {
MailCheck,
Plus,
Receipt,
Search as SearchIcon,
Truck,
} from "lucide-react";
import type {
@ -46,6 +50,32 @@ import type {
Quote,
} from "@/lib/types";
/**
* Tab key for the list-page filter strip. The four buckets cover
* the full lifecycle without forcing the operator to scroll:
* - "active" → draft + sent + po_received + invoiced (anything in flight)
* - "cancelled" → cancelled only (kept separate so old cancelled DNs
* don't pollute the working list)
* - "completed" → invoiced/completed (terminal, visible but inert)
* - "all" → no filter (escape hatch when the operator
* doesn't know the status)
*/
type DnTab = "active" | "cancelled" | "completed" | "all";
const TAB_LABELS: Record<DnTab, string> = {
active: "Active",
cancelled: "Cancelled",
completed: "Completed",
all: "All",
};
const TAB_STATUSES: Record<DnTab, DeliveryNoteStatus[]> = {
active: ["draft", "sent", "po_received", "invoiced"],
cancelled: ["cancelled"],
completed: ["completed"],
all: ["draft", "sent", "po_received", "invoiced", "completed", "cancelled"],
};
/**
* Status → badge color. The lifecycle is:
* draft — created, items assembled, not yet sent
@ -92,6 +122,18 @@ export default function DeliveryNotesPage() {
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [search, setSearch] = useState("");
// List-page filter strip. Defaults to "active" so the operator
// opens the page to a working list; cancelled / completed /
// all are opt-in. Status filter is applied client-side over
// the current page of notes (the server still returns the full
// recent set so search/tab switching doesn't have to re-fetch).
const [tab, setTab] = useState<DnTab>("active");
// Per-company open state. All groups start expanded so the
// operator sees the full picture on load; the chevron on each
// card header collapses a company they don't care about. State
// lives here (not in URL) because grouping is a viewing
// preference, not a navigable page.
const [openGroups, setOpenGroups] = useState<Set<string>>(new Set());
// Per-row busy state. The "Create Quote" / "Capture PO" / "Mark
// Invoiced" buttons can each be in-flight for different rows at
@ -216,11 +258,72 @@ export default function DeliveryNotesPage() {
.filter((n): n is DeliveryNote => !!n);
}, [notes, selectedIds]);
// Per-tab counts. Drives the badge in each tab label so the
// operator can see at a glance how many DNs sit in each bucket
// before clicking. Counts are computed against the full `notes`
// array (the page of DNs the server returned) — not the
// filtered view — so the badge stays accurate when the filter
// changes.
const tabCounts = useMemo(() => {
const counts: Record<DnTab, number> = {
active: 0,
cancelled: 0,
completed: 0,
all: notes.length,
};
for (const note of notes) {
if (note.status === "cancelled") counts.cancelled += 1;
else if (note.status === "completed") counts.completed += 1;
else counts.active += 1;
}
return counts;
}, [notes]);
// Apply the tab filter. Selection / bulk actions keep working
// because we filter the visible array but keep the underlying
// `notes` untouched.
const visibleNotes = useMemo(
() => notes.filter((note) => TAB_STATUSES[tab].includes(note.status)),
[notes, tab],
);
// Group visible notes by company. The list used to be one flat
// table, but operators deal with DNs at the "what's outstanding
// for X" level — a flat list mixed 6 companies together and
// made scanning painful. Grouping brings company context into
// the table header so the operator never has to look up the
// company for a row.
//
// Empty / missing company collapses into a single "No company"
// bucket so it doesn't show up at the top of the list. Within
// each group rows are sorted by created desc — same as the
// server sort — so the most recent DN per company leads.
const groupedNotes = useMemo(() => {
const groups = new Map<string, DeliveryNote[]>();
for (const note of visibleNotes) {
const key = note.expand?.company?.company?.trim() || "No company";
const group = groups.get(key) ?? [];
group.push(note);
groups.set(key, group);
}
return Array.from(groups.entries()).sort(([a], [b]) => {
if (a === "No company") return 1;
if (b === "No company") return -1;
return a.localeCompare(b);
});
}, [visibleNotes]);
const fetchNotes = useCallback(async () => {
setLoading(true);
setError("");
try {
const result = await getDeliveryNotes(page, 20, search);
// Fetch a wide page so the client-side tab filter sees the
// full set — the previous `perPage=20` made the active
// tab silently drop records past rank 20 (the operator
// saw "no DN" when the row existed but was buried). 500
// is well over the production DN count (≈30) and still
// cheap for PB.
const result = await getDeliveryNotes(page, 500, search);
setNotes(result.items);
setTotalPages(result.totalPages);
// Batch-fetch the related quotes in one query so the Q
@ -883,52 +986,171 @@ export default function DeliveryNotesPage() {
</Button>
</div>
<DataTable
columns={columns}
data={notes}
loading={loading}
error={error}
searchPlaceholder="Search delivery notes..."
onSearch={(q) => {
setSearch(q);
setPage(1);
}}
page={page}
totalPages={totalPages}
onPageChange={setPage}
onRowClick={(item) => router.push(`/delivery-notes/${item.id}`)}
selection={{
isSelected: (item) => selectedIds.has(item.id),
onToggle: (item) => toggleSelected(item.id),
isDisabled: isSelectionDisabled,
}}
mobileCardRender={(item) => (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{item.delivery_note_number}</p>
<Badge
variant={statusColors[item.status] || "secondary"}
data-testid={`dn-status-mobile-${item.id}`}
>
{statusLabel[item.status] || item.status.replace("_", " ")}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{item.expand?.order?.order_number || "No order"} · {item.expand?.customer?.customer || "No customer"}
</p>
{item.customer_po && (
<p className="text-xs font-mono">PO: {item.customer_po}</p>
{/* Status filter strip. Active is the default landing tab so
the operator opens the page to the working list;
cancelled / completed live one click away and never
collide with the active workflow. Each tab carries a
count badge so the operator can tell at a glance how
many DNs sit in each bucket. */}
<div
role="tablist"
aria-label="Filter delivery notes by status"
className="inline-flex flex-wrap items-center gap-1 rounded-lg border bg-card p-1 shadow-sm"
>
{(Object.keys(TAB_LABELS) as DnTab[]).map((key) => {
const isActive = key === tab;
return (
<button
key={key}
type="button"
role="tab"
aria-selected={isActive}
onClick={() => setTab(key)}
data-testid={`dn-tab-${key}`}
className={cn(
"inline-flex items-center gap-2 rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
isActive
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
)}
<p className="text-xs text-muted-foreground">{formatDate(item.created)}</p>
{/* Mobile action lives inline at the bottom of the
card so the row is fully self-contained — no need
to scroll horizontally to find the button. */}
<div className="pt-1">{ActionCell(item)}</div>
</CardContent>
</Card>
)}
/>
>
{TAB_LABELS[key]}
<span
className={cn(
"rounded-full px-2 py-0.5 text-[10px] tabular-nums",
isActive
? "bg-primary-foreground/15 text-primary-foreground"
: "bg-muted text-muted-foreground",
)}
>
{tabCounts[key]}
</span>
</button>
);
})}
</div>
{/* Global search. One input filters across every company
below — operators usually know the DN number / customer
/ order, not the company. We use a plain Input here
instead of a full DataTable so the search input doesn't
render its own empty-state banner ("No records found")
that hides the actual grouped list. */}
<div className="flex flex-col sm:flex-row gap-3 items-start sm:items-center justify-between">
<div className="relative w-full sm:max-w-sm">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search delivery notes..."
value={search}
onChange={(e) => {
setSearch(e.target.value);
setPage(1);
}}
className="pl-9 border-warn/40 bg-warn/5 focus-visible:border-warn focus-visible:ring-warn/30"
data-testid="dn-search"
/>
</div>
</div>
{/* Per-company grouping. Each company is its own collapsible
card; rows stay local to that company. The card is open
by default so the operator sees everything without an
extra click, and the chevron lets them collapse a company
they don't care about right now. */}
{groupedNotes.length === 0 && !loading ? (
<div className="flex flex-col items-center justify-center rounded-md border border-dashed py-16 text-center text-muted-foreground">
<p className="text-sm font-medium">No delivery notes on this tab.</p>
<p className="text-xs">Switch tabs above or create a new delivery note.</p>
</div>
) : (
<div className="space-y-4">
{groupedNotes.map(([companyName, groupNotes]) => {
const isOpen = openGroups.has(companyName);
const toggle = () =>
setOpenGroups((prev) => {
const next = new Set(prev);
if (next.has(companyName)) next.delete(companyName);
else next.add(companyName);
return next;
});
return (
<Card key={companyName} data-testid={`dn-group-${companyName}`}>
<button
type="button"
onClick={toggle}
aria-expanded={isOpen}
aria-controls={`dn-group-rows-${companyName}`}
data-testid={`dn-group-toggle-${companyName}`}
className="flex w-full items-center justify-between gap-3 px-4 py-3 text-left hover:bg-accent/40 transition-colors"
>
<div className="flex items-center gap-2">
<Building2 className="h-3.5 w-3.5 text-muted-foreground" />
<h2 className="text-sm font-semibold uppercase tracking-widest text-muted-foreground">
{companyName}
</h2>
<span className="text-xs font-normal normal-case tracking-normal text-muted-foreground/70">
({groupNotes.length})
</span>
</div>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
)}
</button>
{isOpen ? (
<CardContent className="p-0" id={`dn-group-rows-${companyName}`}>
<DataTable
columns={columns}
data={groupNotes}
loading={loading}
error={error}
page={1}
totalPages={1}
onPageChange={() => {
// no-op: grouping bypasses pagination;
// pagination only affects the fetch below
}}
onRowClick={(item) => router.push(`/delivery-notes/${item.id}`)}
selection={{
isSelected: (item) => selectedIds.has(item.id),
onToggle: (item) => toggleSelected(item.id),
isDisabled: isSelectionDisabled,
}}
mobileCardRender={(item) => (
<Card>
<CardContent className="p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<p className="font-medium">{item.delivery_note_number}</p>
<Badge
variant={statusColors[item.status] || "secondary"}
data-testid={`dn-status-mobile-${item.id}`}
>
{statusLabel[item.status] || item.status.replace("_", " ")}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
{item.expand?.order?.order_number || "No order"} · {item.expand?.customer?.customer || "No customer"}
</p>
{item.customer_po && (
<p className="text-xs font-mono">PO: {item.customer_po}</p>
)}
<p className="text-xs text-muted-foreground">{formatDate(item.created)}</p>
{/* Mobile action lives inline at the bottom of the
card so the row is fully self-contained — no need
to scroll horizontally to find the button. */}
<div className="pt-1">{ActionCell(item)}</div>
</CardContent>
</Card>
)}
/>
</CardContent>
) : null}
</Card>
);
})}
</div>
)}
{/*
Combine-DNs bulk action. Mirrors the sticky bottom-bar