"use client"; import Link from "next/link"; import { useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { Badge, Button, Card, ErrorBanner, Field, Input, Modal, PageHeader, Textarea, } from "@/components/ui"; import { apiFetch, ApiClientError } from "@/lib/client-api"; export interface PORow { id: string; vendor: string; status: string; createdAt: string; sentAt: string | null; receivedAt: string | null; notes: string | null; lineCount: number; totalQty: number; totalReceived: number; totalCost: number; } export interface FastenerOption { id: string; partNumber: string; description: string; supplier: string | null; unitCost: number | null; qty: number; unresolved: number; // qty - (on-order on non-cancelled POs) } const STATUS_TONE: Record = { draft: "slate", sent: "blue", partial: "amber", received: "green", cancelled: "red", }; function formatDate(iso: string | null) { if (!iso) return "—"; return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric", }); } export default function PurchaseOrdersClient({ project, initial, fastenerOptions, }: { project: { id: string; code: string; name: string }; initial: PORow[]; fastenerOptions: FastenerOption[]; }) { const router = useRouter(); const [newOpen, setNewOpen] = useState(false); return (
Purchase orders — {project.code}} description={ Draft → sent → partial → received. One PO per vendor, cancel anything pre-terminal. } actions={
Fasteners →
} /> {initial.map((po) => ( ))} {initial.length === 0 && ( )}
Vendor Status Created Sent Lines Qty (recv/total) Total
{po.vendor} {po.status} {formatDate(po.createdAt)} {formatDate(po.sentAt)} {po.lineCount} {po.totalReceived}/{po.totalQty} {po.totalCost > 0 ? po.totalCost.toFixed(2) : "—"} Open →
No purchase orders yet. {fastenerOptions.length === 0 ? ( <> {" "} Add fasteners first ( go to fasteners ). ) : ( " Start a draft to build a vendor PO." )}
{newOpen && ( setNewOpen(false)} onSaved={(poId) => { setNewOpen(false); router.push(`/admin/projects/${project.id}/purchase-orders/${poId}`); }} /> )}
); } interface LineDraft { fastenerId: string; qty: string; unitCost: string; } function NewPOModal({ projectId, fastenerOptions, onClose, onSaved, }: { projectId: string; fastenerOptions: FastenerOption[]; onClose: () => void; onSaved: (poId: string) => void; }) { // Pre-seed the draft with lines for every fastener that still has an // unresolved (unordered) quantity, so the common case ("put everything I // haven't bought yet on one PO") is zero-click. const seeded = useMemo( () => fastenerOptions .filter((f) => f.unresolved > 0) .map((f) => ({ fastenerId: f.id, qty: String(f.unresolved), unitCost: f.unitCost != null ? String(f.unitCost) : "", })), [fastenerOptions], ); const [vendor, setVendor] = useState(""); const [notes, setNotes] = useState(""); const [lines, setLines] = useState( seeded.length > 0 ? seeded : [{ fastenerId: "", qty: "1", unitCost: "" }], ); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const total = lines.reduce((a, l) => { const q = Number(l.qty) || 0; const c = Number(l.unitCost) || 0; return a + q * c; }, 0); function updateLine(i: number, patch: Partial) { setLines((prev) => prev.map((l, idx) => (idx === i ? { ...l, ...patch } : l))); } function addLine() { setLines((prev) => [...prev, { fastenerId: "", qty: "1", unitCost: "" }]); } function removeLine(i: number) { setLines((prev) => prev.filter((_, idx) => idx !== i)); } async function submit(e: React.FormEvent) { e.preventDefault(); setError(null); const clean = lines .filter((l) => l.fastenerId) .map((l) => ({ fastenerId: l.fastenerId, qty: Number(l.qty), unitCost: l.unitCost ? Number(l.unitCost) : null, })); if (clean.length === 0) { setError("Add at least one fastener line"); return; } setBusy(true); try { const res = await apiFetch<{ purchaseOrder: { id: string } }>( `/api/v1/projects/${projectId}/purchase-orders`, { method: "POST", body: JSON.stringify({ vendor, notes: notes || null, lines: clean }), }, ); onSaved(res.purchaseOrder.id); } catch (err) { setError(err instanceof ApiClientError ? err.message : "Create failed"); setBusy(false); } } return (
} >
setVendor(e.target.value)} required autoFocus />