Files
mrp-qrcode/app/admin/projects/[id]/purchase-orders/[poId]/POClient.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

310 lines
10 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 { useState } from "react";
import { useRouter } from "next/navigation";
import {
Badge,
Button,
Card,
ErrorBanner,
Input,
PageHeader,
} from "@/components/ui";
import { apiFetch, ApiClientError } from "@/lib/client-api";
interface POInfo {
id: string;
vendor: string;
status: string;
createdAt: string;
sentAt: string | null;
receivedAt: string | null;
notes: string | null;
}
interface LineRow {
id: string;
fastener: {
id: string;
partNumber: string;
description: string;
supplier: string | null;
unitCost: number | null;
};
qty: number;
receivedQty: number;
unitCost: number | null;
}
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 POClient({
project,
po,
lines,
}: {
project: { id: string; code: string; name: string };
po: POInfo;
lines: LineRow[];
}) {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
// Per-line receive input. Keyed by line id so re-renders don't clobber state.
const [receipts, setReceipts] = useState<Record<string, string>>({});
const total = lines.reduce((a, l) => {
const cost = l.unitCost ?? l.fastener.unitCost ?? 0;
return a + cost * l.qty;
}, 0);
const totalReceived = lines.reduce((a, l) => a + l.receivedQty, 0);
const totalQty = lines.reduce((a, l) => a + l.qty, 0);
async function changeStatus(next: string) {
if (!confirm(`Move PO to "${next}"?`)) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/purchase-orders/${po.id}/status`, {
method: "POST",
body: JSON.stringify({ status: next }),
});
router.refresh();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Status change failed");
} finally {
setBusy(false);
}
}
async function submitReceipts() {
const payload = Object.entries(receipts)
.map(([lineId, qty]) => ({ lineId, qty: Number(qty) }))
.filter((r) => r.qty > 0);
if (payload.length === 0) {
setError("Enter at least one receipt quantity");
return;
}
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/purchase-orders/${po.id}/receive`, {
method: "POST",
body: JSON.stringify({ receipts: payload }),
});
setReceipts({});
router.refresh();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Receipt failed");
} finally {
setBusy(false);
}
}
async function deleteDraft() {
if (!confirm("Delete this draft PO? This can't be undone.")) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/purchase-orders/${po.id}`, { method: "DELETE" });
router.push(`/admin/projects/${project.id}/purchase-orders`);
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Delete failed");
setBusy(false);
}
}
// Which state transitions are offered here — mirror of PO_TRANSITIONS in the API.
const canSend = po.status === "draft";
const canCancel = po.status !== "received" && po.status !== "cancelled";
const canReceive = po.status === "sent" || po.status === "partial";
const canDeleteDraft = po.status === "draft";
return (
<div className="mx-auto max-w-5xl 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>
<Link
href={`/admin/projects/${project.id}/purchase-orders`}
className="hover:underline"
>
POs
</Link>
<span className="mx-1"></span>
<span className="font-mono text-xs">{po.id.slice(0, 8)}</span>
</nav>
<PageHeader
title={
<span className="flex items-center gap-3">
<span>{po.vendor}</span>
<Badge tone={STATUS_TONE[po.status] ?? "slate"}>{po.status}</Badge>
</span>
}
description={
<span className="flex flex-wrap items-center gap-2 text-slate-500">
<span>PO {po.id.slice(0, 8).toUpperCase()}</span>
<span className="text-slate-400">·</span>
<span>Created {formatDate(po.createdAt)}</span>
<span className="text-slate-400">·</span>
<span>Sent {formatDate(po.sentAt)}</span>
<span className="text-slate-400">·</span>
<span>Received {formatDate(po.receivedAt)}</span>
</span>
}
actions={
<div className="flex flex-wrap items-center gap-2">
<a
href={`/api/v1/purchase-orders/${po.id}/pdf`}
target="_blank"
rel="noopener"
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"
>
Download PDF
</a>
{canSend ? (
<Button onClick={() => changeStatus("sent")} disabled={busy}>
Mark sent
</Button>
) : null}
{canCancel ? (
<Button variant="secondary" onClick={() => changeStatus("cancelled")} disabled={busy}>
Cancel PO
</Button>
) : null}
{canDeleteDraft ? (
<Button variant="danger" size="sm" onClick={deleteDraft} disabled={busy}>
Delete draft
</Button>
) : null}
</div>
}
/>
{po.notes ? (
<Card className="mb-4">
<div className="p-4 text-sm text-slate-700 whitespace-pre-wrap">{po.notes}</div>
</Card>
) : null}
<ErrorBanner message={error} />
<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">Part #</th>
<th className="px-4 py-2 font-medium">Description</th>
<th className="px-4 py-2 font-medium text-right">Qty</th>
<th className="px-4 py-2 font-medium text-right">Received</th>
<th className="px-4 py-2 font-medium text-right">Unit</th>
<th className="px-4 py-2 font-medium text-right">Line total</th>
{canReceive ? (
<th className="px-4 py-2 font-medium text-right">Receive +</th>
) : null}
</tr>
</thead>
<tbody>
{lines.map((l) => {
const effectiveCost = l.unitCost ?? l.fastener.unitCost ?? null;
const lineTotal = effectiveCost !== null ? effectiveCost * l.qty : null;
const remaining = l.qty - l.receivedQty;
const full = remaining === 0;
return (
<tr key={l.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3 font-mono text-slate-700">
{l.fastener.partNumber}
</td>
<td className="px-4 py-3">
<div className="font-medium">{l.fastener.description}</div>
{l.fastener.supplier ? (
<div className="text-xs text-slate-500">{l.fastener.supplier}</div>
) : null}
</td>
<td className="px-4 py-3 text-right tabular-nums">{l.qty}</td>
<td className="px-4 py-3 text-right tabular-nums">
{l.receivedQty}
{full && l.receivedQty > 0 ? (
<Badge tone="green" className="ml-1">
full
</Badge>
) : null}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{effectiveCost !== null ? effectiveCost.toFixed(2) : "—"}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{lineTotal !== null ? lineTotal.toFixed(2) : "—"}
</td>
{canReceive ? (
<td className="px-4 py-3 text-right">
<Input
type="number"
min={0}
max={remaining}
value={receipts[l.id] ?? ""}
onChange={(e) =>
setReceipts((prev) => ({ ...prev, [l.id]: e.target.value }))
}
placeholder={remaining.toString()}
className="w-20 text-right"
disabled={full || busy}
/>
</td>
) : null}
</tr>
);
})}
</tbody>
<tfoot className="bg-slate-50 border-t border-slate-200">
<tr>
<td className="px-4 py-3" colSpan={2}>
<span className="text-slate-500 text-xs uppercase tracking-wide">Totals</span>
</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{totalQty}</td>
<td className="px-4 py-3 text-right tabular-nums font-medium">{totalReceived}</td>
<td></td>
<td className="px-4 py-3 text-right tabular-nums font-medium">
{total > 0 ? total.toFixed(2) : "—"}
</td>
{canReceive ? <td></td> : null}
</tr>
</tfoot>
</table>
</Card>
{canReceive ? (
<div className="mt-4 flex justify-end">
<Button onClick={submitReceipts} disabled={busy}>
Record receipts
</Button>
</div>
) : null}
</div>
);
}