feat(suppliers): PO detail page picks a real supplier instead of free text
This commit is contained in:
parent
50bcf405a9
commit
1c3adede0d
2 changed files with 63 additions and 48 deletions
|
|
@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue