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>
195 lines
6.8 KiB
TypeScript
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}
|
|
/>
|
|
</>
|
|
);
|
|
}
|