Files
mrp-qrcode/app/admin/projects/[id]/purchase-orders/PurchaseOrdersClient.tsx
T
jason 5847a175af
Build and Push Docker Image / build (push) Successful in 1m11s
stage 5-6
2026-04-21 13:14:27 -05:00

381 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<string, "slate" | "blue" | "green" | "amber" | "red"> = {
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 (
<div className="mx-auto max-w-6xl px-4 py-8">
<nav className="mb-3 text-sm text-slate-500">
<Link href="/admin/projects" className="hover:underline">
Projects
</Link>
<span className="mx-1"></span>
<Link href={`/admin/projects/${project.id}`} className="hover:underline">
{project.code}
</Link>
<span className="mx-1"></span>
<span>Purchase orders</span>
</nav>
<PageHeader
title={<span>Purchase orders {project.code}</span>}
description={
<span className="text-slate-500">
Draft sent partial received. One PO per vendor, cancel anything pre-terminal.
</span>
}
actions={
<div className="flex items-center gap-2">
<Link
href={`/admin/projects/${project.id}/fasteners`}
className="inline-flex items-center rounded-md bg-white border border-slate-300 text-slate-900 text-sm px-3 py-1.5 hover:border-slate-900"
>
Fasteners
</Link>
<Button
onClick={() => setNewOpen(true)}
disabled={fastenerOptions.length === 0}
>
New PO
</Button>
</div>
}
/>
<Card>
<table className="w-full text-sm">
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-4 py-2 font-medium">Vendor</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Created</th>
<th className="px-4 py-2 font-medium">Sent</th>
<th className="px-4 py-2 font-medium text-right">Lines</th>
<th className="px-4 py-2 font-medium text-right">Qty (recv/total)</th>
<th className="px-4 py-2 font-medium text-right">Total</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{initial.map((po) => (
<tr key={po.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3 font-medium">{po.vendor}</td>
<td className="px-4 py-3">
<Badge tone={STATUS_TONE[po.status] ?? "slate"}>{po.status}</Badge>
</td>
<td className="px-4 py-3 text-slate-600">{formatDate(po.createdAt)}</td>
<td className="px-4 py-3 text-slate-600">{formatDate(po.sentAt)}</td>
<td className="px-4 py-3 text-right tabular-nums">{po.lineCount}</td>
<td className="px-4 py-3 text-right tabular-nums">
{po.totalReceived}/{po.totalQty}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{po.totalCost > 0 ? po.totalCost.toFixed(2) : "—"}
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/admin/projects/${project.id}/purchase-orders/${po.id}`}
className="text-sm text-blue-600 hover:underline"
>
Open
</Link>
</td>
</tr>
))}
{initial.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
No purchase orders yet.
{fastenerOptions.length === 0 ? (
<>
{" "}
Add fasteners first (
<Link
href={`/admin/projects/${project.id}/fasteners`}
className="text-blue-600 hover:underline"
>
go to fasteners
</Link>
).
</>
) : (
" Start a draft to build a vendor PO."
)}
</td>
</tr>
)}
</tbody>
</table>
</Card>
{newOpen && (
<NewPOModal
projectId={project.id}
fastenerOptions={fastenerOptions}
onClose={() => setNewOpen(false)}
onSaved={(poId) => {
setNewOpen(false);
router.push(`/admin/projects/${project.id}/purchase-orders/${poId}`);
}}
/>
)}
</div>
);
}
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<LineDraft[]>(
() =>
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<LineDraft[]>(
seeded.length > 0 ? seeded : [{ fastenerId: "", qty: "1", unitCost: "" }],
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(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<LineDraft>) {
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 (
<Modal
open
onClose={onClose}
title="New purchase order"
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="po-form" disabled={busy}>
{busy ? "Creating…" : "Create draft"}
</Button>
</>
}
>
<form id="po-form" onSubmit={submit} className="space-y-4">
<Field label="Vendor" required>
<Input value={vendor} onChange={(e) => setVendor(e.target.value)} required autoFocus />
</Field>
<Field label="Notes">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold text-sm">Lines</h3>
<Button type="button" variant="secondary" size="sm" onClick={addLine}>
+ Line
</Button>
</div>
<div className="space-y-2">
{lines.map((l, i) => {
const f = fastenerOptions.find((x) => x.id === l.fastenerId);
return (
<div key={i} className="grid grid-cols-12 gap-2 items-start">
<select
className="col-span-6 rounded-md border border-slate-300 px-2 py-1.5 text-sm"
value={l.fastenerId}
onChange={(e) => {
const next = fastenerOptions.find((x) => x.id === e.target.value);
updateLine(i, {
fastenerId: e.target.value,
qty: next && next.unresolved > 0 ? String(next.unresolved) : l.qty,
unitCost:
next?.unitCost != null && !l.unitCost ? String(next.unitCost) : l.unitCost,
});
}}
>
<option value=""> select fastener </option>
{fastenerOptions.map((f2) => (
<option key={f2.id} value={f2.id}>
{f2.partNumber} {f2.description}
{f2.supplier ? ` (${f2.supplier})` : ""}
</option>
))}
</select>
<Input
className="col-span-2"
type="number"
min={1}
value={l.qty}
onChange={(e) => updateLine(i, { qty: e.target.value })}
placeholder="Qty"
/>
<Input
className="col-span-3"
type="number"
min={0}
step="0.01"
value={l.unitCost}
onChange={(e) => updateLine(i, { unitCost: e.target.value })}
placeholder="Unit cost"
/>
<button
type="button"
className="col-span-1 text-slate-400 hover:text-red-600 text-sm"
onClick={() => removeLine(i)}
aria-label="Remove line"
>
</button>
{f && f.unresolved > 0 && Number(l.qty) !== f.unresolved ? (
<div className="col-span-12 text-[11px] text-amber-700 -mt-1">
Unresolved need for {f.partNumber}: {f.unresolved}.
</div>
) : null}
</div>
);
})}
</div>
{total > 0 ? (
<div className="mt-3 text-right text-sm text-slate-600">
Estimated total: <span className="font-semibold">{total.toFixed(2)}</span>
</div>
) : null}
</div>
<ErrorBanner message={error} />
</form>
</Modal>
);
}