diff --git a/scripts/migrations/2026-08-13-fix-delivery-notes-relations.ts b/scripts/migrations/2026-08-13-fix-delivery-notes-relations.ts new file mode 100644 index 0000000..f54e6ea --- /dev/null +++ b/scripts/migrations/2026-08-13-fix-delivery-notes-relations.ts @@ -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>, + collectionId: string, + fieldName: string, + targetCollectionId: string, + log: string[], +): Promise { + 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>, + 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); +}); \ No newline at end of file diff --git a/scripts/migrations/_env-loader.ts b/scripts/migrations/_env-loader.ts new file mode 100644 index 0000000..a92ccb7 --- /dev/null +++ b/scripts/migrations/_env-loader.ts @@ -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 }; \ No newline at end of file diff --git a/src/app/(app)/delivery-notes/[id]/page.tsx b/src/app/(app)/delivery-notes/[id]/page.tsx index 291dcc4..4b6c3f8 100644 --- a/src/app/(app)/delivery-notes/[id]/page.tsx +++ b/src/app/(app)/delivery-notes/[id]/page.tsx @@ -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 (
@@ -1080,26 +1089,34 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { ) : ( - - - - - + + + + + Description - Supplier Qty - Unit - Subtotal + Cost + Selling + Markup {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 ( @@ -1115,23 +1132,7 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) { Status: {it.item_status} - - {it.expand?.supplier?.suppliername || "—"} - - {/* - 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 ? ( - {price ? formatCurrency(price) : ( + {costPrice ? ( +
+ {formatCurrency(costPrice)} + + Σ {formatCurrency(costSubtotal)} + +
+ ) : ( — )}
- {price ? formatCurrency(subtotal) : ( + {price ? ( +
+ {formatCurrency(price)} + + Σ {formatCurrency(subtotal)} + +
+ ) : ( + — + )} +
+ + {hasMarkup ? ( +
+ + {markupPercent.toFixed(1)}% + + + {markupValue >= 0 ? "+" : ""} + {formatCurrency(markupValue)} + +
+ ) : ( — )}
@@ -1207,6 +1237,56 @@ export default function DeliveryNoteDetailPage({ params }: PageProps) {
)} + {(itemSubtotal > 0 || itemCostTotal > 0) && ( +
+
+
+ Total cost +
+
+ {itemCostTotal > 0 ? formatCurrency(itemCostTotal) : "—"} +
+
+
+
+ Total selling +
+
+ {itemSubtotal > 0 ? formatCurrency(itemSubtotal) : "—"} +
+
+
+
+ Markup +
+
+ {itemCostTotal > 0 && itemSubtotal > 0 + ? `${itemMarkupPercent.toFixed(1)}%` + : "—"} +
+
+
+
+ Margin +
+
+ {itemCostTotal > 0 && itemSubtotal > 0 + ? `${itemMarkupTotal >= 0 ? "+" : ""}${formatCurrency(itemMarkupTotal)}` + : "—"} +
+
+
+ )} diff --git a/src/app/(app)/delivery-notes/page.tsx b/src/app/(app)/delivery-notes/page.tsx index 7dfee0d..fd9e8bd 100644 --- a/src/app/(app)/delivery-notes/page.tsx +++ b/src/app/(app)/delivery-notes/page.tsx @@ -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 = { + active: "Active", + cancelled: "Cancelled", + completed: "Completed", + all: "All", +}; + +const TAB_STATUSES: Record = { + 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("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>(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 = { + 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(); + 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() {
- { - 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) => ( - - -
-

{item.delivery_note_number}

- - {statusLabel[item.status] || item.status.replace("_", " ")} - -
-

- {item.expand?.order?.order_number || "No order"} · {item.expand?.customer?.customer || "No customer"} -

- {item.customer_po && ( -

PO: {item.customer_po}

+ {/* 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. */} +
+ {(Object.keys(TAB_LABELS) as DnTab[]).map((key) => { + const isActive = key === tab; + return ( + + ); + })} +
+ + {/* 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. */} +
+
+ + { + 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" + /> +
+
+ + {/* 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 ? ( +
+

No delivery notes on this tab.

+

Switch tabs above or create a new delivery note.

+
+ ) : ( +
+ {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 ( + + + {isOpen ? ( + + { + // 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) => ( + + +
+

{item.delivery_note_number}

+ + {statusLabel[item.status] || item.status.replace("_", " ")} + +
+

+ {item.expand?.order?.order_number || "No order"} · {item.expand?.customer?.customer || "No customer"} +

+ {item.customer_po && ( +

PO: {item.customer_po}

+ )} +

{formatDate(item.created)}

+ {/* 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. */} +
{ActionCell(item)}
+
+
+ )} + /> +
+ ) : null} +
+ ); + })} +
+ )} {/* Combine-DNs bulk action. Mirrors the sticky bottom-bar