From 5847a175af82f4efcf8fd2eaf2fe9e1c7b8a1b64 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 21 Apr 2026 13:14:27 -0500 Subject: [PATCH] stage 5-6 --- README.md | 7 +- .../projects/[id]/ProjectDetailClient.tsx | 44 +- .../parts/[partId]/PartDetailClient.tsx | 26 +- .../[id]/fasteners/FastenersClient.tsx | 290 +++++++ app/admin/projects/[id]/fasteners/page.tsx | 56 ++ .../purchase-orders/PurchaseOrdersClient.tsx | 380 +++++++++ .../[id]/purchase-orders/[poId]/POClient.tsx | 309 +++++++ .../[id]/purchase-orders/[poId]/page.tsx | 60 ++ .../projects/[id]/purchase-orders/page.tsx | 82 ++ app/api/v1/fasteners/[id]/route.ts | 83 ++ app/api/v1/operations/[id]/card.pdf/route.ts | 79 ++ app/api/v1/parts/[id]/travelers.pdf/route.ts | 105 +++ app/api/v1/projects/[id]/fasteners/route.ts | 95 +++ .../v1/projects/[id]/purchase-orders/route.ts | 114 +++ app/api/v1/purchase-orders/[id]/pdf/route.ts | 56 ++ .../v1/purchase-orders/[id]/receive/route.ts | 102 +++ app/api/v1/purchase-orders/[id]/route.ts | 141 +++ .../v1/purchase-orders/[id]/status/route.ts | 66 ++ components/ui.tsx | 10 +- docs/BUILD-PLAN.md | 12 +- lib/pdf.ts | 803 ++++++++++++++++++ lib/qr.ts | 13 + lib/schemas.ts | 81 ++ package-lock.json | 43 + package.json | 1 + tsconfig.tsbuildinfo | 2 +- 26 files changed, 3031 insertions(+), 29 deletions(-) create mode 100644 app/admin/projects/[id]/fasteners/FastenersClient.tsx create mode 100644 app/admin/projects/[id]/fasteners/page.tsx create mode 100644 app/admin/projects/[id]/purchase-orders/PurchaseOrdersClient.tsx create mode 100644 app/admin/projects/[id]/purchase-orders/[poId]/POClient.tsx create mode 100644 app/admin/projects/[id]/purchase-orders/[poId]/page.tsx create mode 100644 app/admin/projects/[id]/purchase-orders/page.tsx create mode 100644 app/api/v1/fasteners/[id]/route.ts create mode 100644 app/api/v1/operations/[id]/card.pdf/route.ts create mode 100644 app/api/v1/parts/[id]/travelers.pdf/route.ts create mode 100644 app/api/v1/projects/[id]/fasteners/route.ts create mode 100644 app/api/v1/projects/[id]/purchase-orders/route.ts create mode 100644 app/api/v1/purchase-orders/[id]/pdf/route.ts create mode 100644 app/api/v1/purchase-orders/[id]/receive/route.ts create mode 100644 app/api/v1/purchase-orders/[id]/route.ts create mode 100644 app/api/v1/purchase-orders/[id]/status/route.ts create mode 100644 lib/pdf.ts diff --git a/README.md b/README.md index 836ad6b..7c440da 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,16 @@ A single-container, self-hosted Manufacturing Resource Planning (MRP) app built ## Status -Steps 1 – 3 of the build plan are in this repo: +Steps 1 – 6 of the build plan are in this repo: - **1.** Scaffold + auth (admin email/password, operator name/4-digit PIN with 12 h device session, PIN lockout, audited sessions). - **2.** Admin CRUD (users, machines, operation templates, projects / assemblies / parts) with content-addressed STEP / PDF / DXF / SVG file uploads. - **3.** Operation authoring with per-operation QR tokens (192-bit, base64url). +- **4.** Operator scan flow — phone scan resolves `/op/scan/`, single-claim enforced at DB level, Start / Pause / Done with inline QC for steps that require it, TimeLog rows for every claim. +- **5.** PDF traveler generation — per-operation card + per-part cover sheet with the full operation list and file manifest. Printed via `pdf-lib` (no native deps). +- **6.** Fasteners + purchase orders — per-project BOM of fasteners with unresolved-need rollups, PO lifecycle (`draft → sent → partial → received`, or `cancelled`), per-line receipt entry with auto-advance, and vendor-ready PDF downloads. -Planned (not yet shipped): operator scan flow → PDF traveler print → fasteners & POs → dashboard → STEP viewer → QC records → OpenAPI docs + backups. See [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md). +Planned (not yet shipped): dashboard → STEP viewer → dedicated QC operations → OpenAPI docs + backups. See [`docs/BUILD-PLAN.md`](docs/BUILD-PLAN.md). ## Core concepts diff --git a/app/admin/projects/[id]/ProjectDetailClient.tsx b/app/admin/projects/[id]/ProjectDetailClient.tsx index 0ce1f06..2922f76 100644 --- a/app/admin/projects/[id]/ProjectDetailClient.tsx +++ b/app/admin/projects/[id]/ProjectDetailClient.tsx @@ -155,24 +155,36 @@ export default function ProjectDetailClient({
- -
-

Fasteners

-

- {project.fastenerCount} item{project.fastenerCount === 1 ? "" : "s"} tracked -

-

Fastener authoring lands in step 6.

+ +
+

Fasteners

+ Open →
- - -
-

Purchase orders

-

- {project.poCount} PO{project.poCount === 1 ? "" : "s"} -

-

PO lifecycle and PDFs land in step 6.

+

+ {project.fastenerCount} item{project.fastenerCount === 1 ? "" : "s"} tracked +

+

+ Add BOM items, suppliers, unit costs, and unresolved-need suggestions for POs. +

+ + +
+

Purchase orders

+ Open →
- +

+ {project.poCount} PO{project.poCount === 1 ? "" : "s"} +

+

+ Draft → sent → partial → received. Download vendor PDFs, record receipts. +

+
{editOpen && ( diff --git a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx index f90672a..8d8f1f4 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx @@ -140,9 +140,19 @@ export default function PartDetailClient({ } actions={ - +
+ + Print travelers (PDF) + + +
} /> @@ -328,6 +338,16 @@ function OperationsSection({ + + Print + diff --git a/app/admin/projects/[id]/fasteners/FastenersClient.tsx b/app/admin/projects/[id]/fasteners/FastenersClient.tsx new file mode 100644 index 0000000..970dc7d --- /dev/null +++ b/app/admin/projects/[id]/fasteners/FastenersClient.tsx @@ -0,0 +1,290 @@ +"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(null); + + return ( +
+ + Fasteners — {project.code}} + description={ + + Parts that get bought, not built. Lines roll up into purchase order drafts. + + } + actions={ +
+ + Purchase orders → + + +
+ } + /> + + + + + + + + + + + + + + + + + {initial.map((f) => ( + + + + + + + + + + + ))} + {initial.length === 0 && ( + + + + )} + +
Part #DescriptionSupplierQtyOn orderReceivedUnit cost
{f.partNumber} +
{f.description}
+ {f.notes ? ( +
{f.notes}
+ ) : null} +
{f.supplier ?? "—"}{f.qty} + {f.onOrder} + {f.unresolved > 0 ? ( + + {f.unresolved} to PO + + ) : null} + + {f.received} + {f.remaining === 0 && f.received > 0 ? ( + + full + + ) : null} + + {f.unitCost != null ? f.unitCost.toFixed(2) : "—"} + + +
+ No fasteners yet. Add the bolts, rivets, inserts, etc. that need to be purchased for this project. +
+
+ + {newOpen && ( + setNewOpen(false)} + onSaved={() => { + setNewOpen(false); + router.refresh(); + }} + /> + )} + {edit && ( + setEdit(null)} + onSaved={() => { + setEdit(null); + router.refresh(); + }} + onDeleted={() => { + setEdit(null); + router.refresh(); + }} + /> + )} +
+ ); +} + +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(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 ( + + {editing && onDeleted ? ( + + ) : null} +
+ + + + } + > +
+ + setPartNumber(e.target.value)} required autoFocus /> + + + setDescription(e.target.value)} required /> + +
+ + setQty(e.target.value)} required /> + + + setUnitCost(e.target.value)} + /> + +
+ + setSupplier(e.target.value)} /> + + +