feat(suppliers): PO detail page picks a real supplier instead of free text

This commit is contained in:
DEls 2026-09-11 18:17:46 +02:00
parent 50bcf405a9
commit 1c3adede0d
2 changed files with 63 additions and 48 deletions

View file

@ -23,13 +23,13 @@ export default async function PurchaseOrderDetailPage({
let po;
try {
po = await pb.collection("purchase_orders").getOne(poId, { expand: "site" });
po = await pb.collection("purchase_orders").getOne(poId, { expand: "site,supplier_ref" });
} catch {
notFound();
}
// Pull the full graph for this PO
const [items, dns, received] = await Promise.all([
const [items, dns, received, suppliers] = await Promise.all([
pb.collection("purchase_order_items").getFullList({
filter: `purchase_order='${poId}'`,
}),
@ -40,6 +40,8 @@ export default async function PurchaseOrderDetailPage({
pb.collection("delivery_note_items").getFullList({
fields: "id,delivery_note,purchase_order_item,qty_received,condition_notes",
}),
// All suppliers, for the unknown-supplier picker on this page.
pb.collection("suppliers").getFullList({ sort: "name" }),
]);
// Outstanding is computed from the delivery lines, never stored — see
@ -116,6 +118,9 @@ export default async function PurchaseOrderDetailPage({
projectCode: poProjectCode,
projectName: po.expand?.site?.name ?? "",
supplier: po.supplier,
supplierRefId: po.supplier_ref ?? null,
supplierRefName:
(po.expand as { supplier_ref?: { name?: string } } | undefined)?.supplier_ref?.name ?? null,
requiredByDate: po.required_by_date,
orderedBy: po.ordered_by,
totalValue: po.total_value,
@ -128,6 +133,8 @@ export default async function PurchaseOrderDetailPage({
totalReceived,
};
const supplierOptions = suppliers.map((s) => ({ id: s.id, name: s.name }));
return (
<>
<Topbar
@ -142,7 +149,7 @@ export default async function PurchaseOrderDetailPage({
</Link>
}
/>
<PoDetailView detail={detail} />
<PoDetailView detail={detail} suppliers={supplierOptions} />
</>
);
}

View file

@ -18,6 +18,13 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@ -25,7 +32,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { SlipPreview } from "@/components/slip-preview";
import { cn, formatCurrency, formatNumber, isUnknownSupplier } from "@/lib/utils";
import { cn, formatCurrency, formatNumber } from "@/lib/utils";
import type { DeliveryNoteProcessingStatus } from "@/lib/pb-types";
export interface PoDetailItem {
@ -65,6 +72,10 @@ export interface PoDetail {
projectCode: string;
projectName: string;
supplier: string;
/** Resolved supplier record id, if a match was made at upload or later. */
supplierRefId: string | null;
/** Display name of the linked supplier (mirrors supplierRefId's record). */
supplierRefName: string | null;
requiredByDate: string;
orderedBy: string;
totalValue: number;
@ -134,12 +145,22 @@ function FulfilmentBar({ pct }: { pct: number }) {
);
}
export function PoDetailView({ detail }: { detail: PoDetail }) {
export function PoDetailView({
detail,
suppliers,
}: {
detail: PoDetail;
suppliers: Array<{ id: string; name: string }>;
}) {
const refresh = () => window.location.reload();
const [state, setState] = React.useState<PoDetail>(detail);
const handleSupplierSaved = React.useCallback(
(next: string) => {
setState((prev) => ({ ...prev, supplier: next }));
(supplierId: string, supplierName: string) => {
setState((prev) => ({
...prev,
supplierRefId: supplierId,
supplierRefName: supplierName,
}));
refresh();
},
[],
@ -162,14 +183,14 @@ export function PoDetailView({ detail }: { detail: PoDetail }) {
<h2 className="font-mono text-lg font-semibold tracking-tight">
{state.poNumber}
</h2>
{isUnknownSupplier(state.supplier) ? (
{state.supplierRefId ? (
<span className="text-base font-medium">{state.supplierRefName}</span>
) : (
<SupplierEditor
poId={state.id}
currentSupplier={state.supplier}
suppliers={suppliers}
onSaved={handleSupplierSaved}
/>
) : (
<span className="text-base font-medium">{state.supplier}</span>
)}
<Badge variant="outline" className="font-mono text-xs">
{state.projectCode || "—"}
@ -500,28 +521,23 @@ function DeliveryNoteCard({
function SupplierEditor({
poId,
currentSupplier,
suppliers,
onSaved,
}: {
poId: string;
currentSupplier: string;
onSaved: (next: string) => void;
suppliers: Array<{ id: string; name: string }>;
onSaved: (supplierId: string, supplierName: string) => void;
}) {
const [editing, setEditing] = React.useState(false);
const [value, setValue] = React.useState("");
const [selectedId, setSelectedId] = React.useState<string>("");
const [busy, setBusy] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
// Only render for the unknown-supplier case. Parent renders the
// plain <span> for known suppliers.
if (!isUnknownSupplier(currentSupplier)) return null;
const trimmed = value.trim();
const canSave = !busy && trimmed.length > 0 && trimmed.length <= 200;
const canSave = !busy && selectedId !== "";
const cancel = () => {
setError(null);
setValue("");
setSelectedId("");
setEditing(false);
};
@ -533,7 +549,7 @@ function SupplierEditor({
const res = await fetch(`/api/purchase-orders/${poId}/supplier`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ supplier: trimmed }),
body: JSON.stringify({ supplier_id: selectedId }),
});
if (!res.ok) {
const data: { error?: string } = await res
@ -542,11 +558,10 @@ function SupplierEditor({
setError(data.error ?? `Save failed (${res.status})`);
return;
}
const data: { supplier?: string } = await res.json();
const next = (data.supplier ?? trimmed).trim();
onSaved(next);
const data: { supplier_id: string; supplier_name: string } = await res.json();
onSaved(data.supplier_id, data.supplier_name);
setEditing(false);
setValue("");
setSelectedId("");
} catch (err) {
setError(
err instanceof Error ? err.message : "Save failed (network error)",
@ -554,14 +569,14 @@ function SupplierEditor({
} finally {
setBusy(false);
}
};
}
if (!editing) {
return (
<button
type="button"
onClick={() => setEditing(true)}
aria-label="Edit supplier name"
aria-label="Assign supplier"
className="inline-flex items-center gap-1.5 rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-amber-700 hover:bg-amber-500/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-500/40 dark:text-amber-300"
>
<AlertTriangle className="h-3.5 w-3.5" />
@ -573,25 +588,18 @@ function SupplierEditor({
return (
<div className="mt-1 flex flex-col gap-1.5">
<div className="flex flex-wrap items-center gap-2">
<Input
autoFocus
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
cancel();
} else if (e.key === "Enter" && canSave) {
e.preventDefault();
save();
}
}}
placeholder="e.g. Asphaltic (Pty) Ltd"
maxLength={200}
disabled={busy}
className="h-8 w-64 text-sm"
aria-label="Supplier name"
/>
<Select value={selectedId} onValueChange={setSelectedId}>
<SelectTrigger className="h-8 w-64 text-sm">
<SelectValue placeholder="Pick a supplier…" />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
onClick={save}