Files
mrp-qrcode/app/admin/projects/[id]/fasteners/FastenersClient.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

291 lines
9.3 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,
Field,
Input,
Modal,
PageHeader,
Textarea,
} from "@/components/ui";
import { apiFetch, ApiClientError } from "@/lib/client-api";
export interface FastenerRow {
id: string;
partNumber: string;
description: string;
qty: number;
supplier: string | null;
unitCost: number | null;
notes: string | null;
onOrder: number;
received: number;
remaining: number;
unresolved: number;
}
export default function FastenersClient({
project,
initial,
}: {
project: { id: string; code: string; name: string };
initial: FastenerRow[];
}) {
const router = useRouter();
const [newOpen, setNewOpen] = useState(false);
const [edit, setEdit] = useState<FastenerRow | null>(null);
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>Fasteners</span>
</nav>
<PageHeader
title={<span>Fasteners {project.code}</span>}
description={
<span className="text-slate-500">
Parts that get bought, not built. Lines roll up into purchase order drafts.
</span>
}
actions={
<div className="flex items-center gap-2">
<Link
href={`/admin/projects/${project.id}/purchase-orders`}
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"
>
Purchase orders
</Link>
<Button onClick={() => setNewOpen(true)}>Add fastener</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">Part #</th>
<th className="px-4 py-2 font-medium">Description</th>
<th className="px-4 py-2 font-medium">Supplier</th>
<th className="px-4 py-2 font-medium text-right">Qty</th>
<th className="px-4 py-2 font-medium text-right">On order</th>
<th className="px-4 py-2 font-medium text-right">Received</th>
<th className="px-4 py-2 font-medium text-right">Unit cost</th>
<th className="px-4 py-2"></th>
</tr>
</thead>
<tbody>
{initial.map((f) => (
<tr key={f.id} className="border-b border-slate-100 last:border-0">
<td className="px-4 py-3 font-mono text-slate-700">{f.partNumber}</td>
<td className="px-4 py-3">
<div className="font-medium">{f.description}</div>
{f.notes ? (
<div className="text-xs text-slate-500 line-clamp-2">{f.notes}</div>
) : null}
</td>
<td className="px-4 py-3 text-slate-600">{f.supplier ?? "—"}</td>
<td className="px-4 py-3 text-right tabular-nums">{f.qty}</td>
<td className="px-4 py-3 text-right tabular-nums">
{f.onOrder}
{f.unresolved > 0 ? (
<Badge tone="amber" className="ml-1">
{f.unresolved} to PO
</Badge>
) : null}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{f.received}
{f.remaining === 0 && f.received > 0 ? (
<Badge tone="green" className="ml-1">
full
</Badge>
) : null}
</td>
<td className="px-4 py-3 text-right tabular-nums">
{f.unitCost != null ? f.unitCost.toFixed(2) : "—"}
</td>
<td className="px-4 py-3 text-right">
<Button variant="ghost" size="sm" onClick={() => setEdit(f)}>
Edit
</Button>
</td>
</tr>
))}
{initial.length === 0 && (
<tr>
<td colSpan={8} className="px-4 py-10 text-center text-slate-500">
No fasteners yet. Add the bolts, rivets, inserts, etc. that need to be purchased for this project.
</td>
</tr>
)}
</tbody>
</table>
</Card>
{newOpen && (
<FastenerModal
projectId={project.id}
onClose={() => setNewOpen(false)}
onSaved={() => {
setNewOpen(false);
router.refresh();
}}
/>
)}
{edit && (
<FastenerModal
projectId={project.id}
fastener={edit}
onClose={() => setEdit(null)}
onSaved={() => {
setEdit(null);
router.refresh();
}}
onDeleted={() => {
setEdit(null);
router.refresh();
}}
/>
)}
</div>
);
}
function FastenerModal({
projectId,
fastener,
onClose,
onSaved,
onDeleted,
}: {
projectId: string;
fastener?: FastenerRow;
onClose: () => void;
onSaved: () => void;
onDeleted?: () => void;
}) {
const editing = !!fastener;
const [partNumber, setPartNumber] = useState(fastener?.partNumber ?? "");
const [description, setDescription] = useState(fastener?.description ?? "");
const [qty, setQty] = useState(String(fastener?.qty ?? 1));
const [supplier, setSupplier] = useState(fastener?.supplier ?? "");
const [unitCost, setUnitCost] = useState(fastener?.unitCost != null ? String(fastener.unitCost) : "");
const [notes, setNotes] = useState(fastener?.notes ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const payload = {
partNumber,
description,
qty: Number(qty),
supplier: supplier || null,
unitCost: unitCost ? Number(unitCost) : null,
notes: notes || null,
};
if (editing) {
await apiFetch(`/api/v1/fasteners/${fastener!.id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
} else {
await apiFetch(`/api/v1/projects/${projectId}/fasteners`, {
method: "POST",
body: JSON.stringify(payload),
});
}
onSaved();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Save failed");
setBusy(false);
}
}
async function remove() {
if (!fastener || !onDeleted) return;
if (!confirm(`Delete fastener ${fastener.partNumber}?`)) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/fasteners/${fastener.id}`, { method: "DELETE" });
onDeleted();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Delete failed");
setBusy(false);
}
}
return (
<Modal
open
onClose={onClose}
title={editing ? `Edit ${fastener!.partNumber}` : "New fastener"}
footer={
<>
{editing && onDeleted ? (
<Button variant="danger" size="sm" onClick={remove} disabled={busy}>
Delete
</Button>
) : null}
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="fastener-form" disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Create"}
</Button>
</>
}
>
<form id="fastener-form" onSubmit={submit} className="space-y-4">
<Field label="Part number" required>
<Input value={partNumber} onChange={(e) => setPartNumber(e.target.value)} required autoFocus />
</Field>
<Field label="Description" required>
<Input value={description} onChange={(e) => setDescription(e.target.value)} required />
</Field>
<div className="grid grid-cols-2 gap-3">
<Field label="Quantity" required>
<Input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} required />
</Field>
<Field label="Unit cost" hint="Per-each. Overridable on the PO line.">
<Input
type="number"
min={0}
step="0.01"
value={unitCost}
onChange={(e) => setUnitCost(e.target.value)}
/>
</Field>
</div>
<Field label="Supplier">
<Input value={supplier} onChange={(e) => setSupplier(e.target.value)} />
</Field>
<Field label="Notes">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<ErrorBanner message={error} />
</form>
</Modal>
);
}