MEGAINFRA-WEBAPP/src/app/dashboard/purchasing/page.tsx
DEls 0cdf385df2 refactor(schema): rename received_items and timesheets to their real names
RECEIVED_ITEMS -> DELIVERY_NOTE_ITEMS (55 rows): they are the lines on a
delivery note; the old name described an event, not a thing.

TIMESHEETS -> TIMESHEET_ENTRIES (1367 rows): this collection is the time
log, not a timesheet. The name is freed for the uploaded-document
collection created in Task 6.

PocketBase stores relations by collection id, so every relation survived
untouched; row counts asserted intact on both renames.

Two call sites the plan missed: src/app/api/timesheets/import/route.ts
and src/lib/ocr-result-tier.ts. Deliberately preserved the "timesheets"
string literals in pb-types.ts and the import route — those are
data_import_batches.batch_type select values, not collection names, and
changing them would have violated the select constraint.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 11:01:40 +02:00

195 lines
6.8 KiB
TypeScript

import { pbAdmin } from "@/lib/pb";
import { Topbar } from "@/components/layout/sidebar";
import { UploadDeliveryNoteButton } from "@/components/upload-delivery-note-button";
import { UploadPurchaseOrderButton } from "@/components/upload-purchase-order-button";
import { PurchasingView, type PoRow, type PurchasingKpis } from "./purchasing-view";
import type { NeedsReviewDn, PoChoice } from "@/components/needs-review-dns";
export const dynamic = "force-dynamic";
export default async function PurchasingPage() {
const pb = await pbAdmin();
const [pos, items, dns, needsReviewDns, sites, receivedAll] = await Promise.all([
pb.collection("purchase_orders").getFullList({ expand: "site" }),
pb.collection("purchase_order_items").getFullList(),
pb.collection("delivery_notes").getFullList(),
// DNs that need a human to pick the PO because OCR couldn't match
// NOTE: sort by `-id` not `-created`. This PB instance 400s on
// `sort=created` / `sort=-created` when the collection's listRule
// is `@request.auth.id != ''`. IDs are auto-generated so `-id` gives
// the same chronological order.
pb.collection("delivery_notes").getList(1, 200, {
filter: "purchase_order='' && processing_status='needs-review'",
sort: "-id",
}),
pb.collection("sites").getFullList({ sort: "name" }),
// Authoritative source for received quantities. Use getFullList so
// there is no silent truncation if `delivery_note_items` ever exceeds
// 500 rows — earlier this used `getList(1, 500, ...)` which capped
// and produced 0% fulfillment whenever low-id POs were dropped.
pb.collection("delivery_note_items").getFullList({
fields: "id,purchase_order_item,qty_received",
}),
]);
const itemsByPo = new Map<string, typeof items>();
for (const it of items) {
const list = itemsByPo.get(it.purchase_order) ?? [];
list.push(it);
itemsByPo.set(it.purchase_order, list);
}
const dnsByPo = new Map<string, typeof dns>();
for (const dn of dns) {
const list = dnsByPo.get(dn.purchase_order) ?? [];
list.push(dn);
dnsByPo.set(dn.purchase_order, list);
}
// Sum delivery_note_items.qty_received per PO item so we can compute
// per-PO fulfilment % from authoritative data instead of the stale
// denormalized field on purchase_order_items. Rows without a
// purchase_order_item are collected as orphans (they can't roll up to
// any PO) and surfaced with their IDs so a dev can query PB directly.
const receivedByItem = new Map<string, number>();
const orphanIds: string[] = [];
for (const r of receivedAll) {
const poi = (r as { purchase_order_item?: string }).purchase_order_item;
if (!poi) {
orphanIds.push(String((r as { id?: string }).id ?? ""));
continue;
}
receivedByItem.set(
poi,
(receivedByItem.get(poi) ?? 0) +
((r as { qty_received?: number }).qty_received ?? 0),
);
}
if (orphanIds.length > 0) {
console.warn(
`[purchasing] ${orphanIds.length} delivery_note_items row(s) have empty purchase_order_item and are excluded from fulfilment. ids=${orphanIds.join(",")}`,
);
}
let totalOrdered = 0;
let totalReceived = 0;
for (const p of pos) {
const its = itemsByPo.get(p.id) ?? [];
for (const it of its) {
totalOrdered += it.qty ?? 0;
totalReceived += Math.min(receivedByItem.get(it.id) ?? 0, it.qty ?? 0);
}
}
const rows: PoRow[] = pos
.map((p) => {
const its = itemsByPo.get(p.id) ?? [];
const dnsForPo = dnsByPo.get(p.id) ?? [];
const pendingDnCount = dnsForPo.filter(
(d) =>
d.processing_status === "pending" ||
d.processing_status === "needs-review",
).length;
let ord = 0;
let rcv = 0;
for (const it of its) {
const o = it.qty ?? 0;
const r = receivedByItem.get(it.id) ?? 0;
ord += o;
rcv += Math.min(r, o);
}
const fulfillmentPct = ord > 0 ? (rcv / ord) * 100 : 0;
const site = (p.expand as { site?: { name?: string } } | undefined)?.site;
return {
id: p.id,
poNumber: p.purchase_order_number,
supplier: p.supplier,
projectCode: p.project_code,
projectName: site?.name ?? p.project_code,
requiredByDate: p.required_by_date,
orderedBy: p.ordered_by,
isVerified: !!p.is_verified,
totalValue: p.total_value,
itemCount: its.length,
deliveryNoteCount: dnsForPo.length,
deliveryNoteVerified: dnsForPo.filter((d) => d.is_verified).length,
pendingDnCount,
fulfillmentPct,
totalOrdered: ord,
totalReceived: rcv,
};
})
.sort((a, b) => {
const ap = a.pendingDnCount > 0 ? 0 : 1;
const bp = b.pendingDnCount > 0 ? 0 : 1;
if (ap !== bp) return ap - bp;
return a.poNumber.localeCompare(b.poNumber);
});
const siteOptions = sites
.map((s) => ({ id: s.id, name: s.name, project_code: s.project_code }))
.sort((a, b) => a.name.localeCompare(b.name));
const posForAssign: PoChoice[] = rows.map((r) => ({
id: r.id,
poNumber: r.poNumber,
projectCode: r.projectCode,
supplier: r.supplier,
}));
const needsReviewList: NeedsReviewDn[] = (
needsReviewDns as { items?: Array<Record<string, unknown>> }
).items
? (
(needsReviewDns as unknown as { items: Array<Record<string, unknown>> })
.items
).map((d) => ({
id: String(d.id ?? ""),
delivery_note_number: String(d.delivery_note_number ?? ""),
project_code: String(d.project_code ?? ""),
site: String(d.site ?? ""),
received_by_name: String(d.received_by_name ?? ""),
slipImage: String(d.slip_image ?? ""),
raw_ocr_data:
(d.raw_ocr_data as { source?: string; fileName?: string } | null) ?? null,
}))
: [];
const kpis: PurchasingKpis = {
activePos: pos.length,
committedSpend: pos.reduce((s, p) => s + (p.total_value ?? 0), 0),
fulfillmentPct:
totalOrdered > 0 ? (totalReceived / totalOrdered) * 100 : 0,
pendingPoCount: pos.filter((p) => !p.is_verified).length,
pendingDnCount: dns.filter(
(d) =>
d.processing_status === "pending" ||
d.processing_status === "needs-review",
).length,
};
return (
<>
<Topbar
title="Purchasing & Deliveries"
subtitle="Every PO, every slip, every rand committed — from PocketBase."
right={
<div className="flex flex-wrap items-center gap-2">
<UploadPurchaseOrderButton />
<UploadDeliveryNoteButton sites={siteOptions} />
</div>
}
/>
<PurchasingView
rows={rows}
projectCodes={Array.from(new Set(rows.map((r) => r.projectCode))).sort()}
kpis={kpis}
needsReviewDns={needsReviewList}
posForAssign={posForAssign}
/>
</>
);
}