Files
mrp-qrcode/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx
T
jason 04ae88ca0d
Build and Push Docker Image / build (push) Successful in 45s
QoL changes and additions
2026-04-22 13:16:42 -05:00

1573 lines
51 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 { useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter } from "next/navigation";
import {
Badge,
Button,
Card,
ErrorBanner,
Field,
Input,
Modal,
PageHeader,
Select,
Textarea,
} from "@/components/ui";
import { apiFetch, ApiClientError } from "@/lib/client-api";
// three.js + occt wasm are heavy — keep them out of the page's initial bundle
// and out of SSR. The viewer only loads when the user clicks "Show 3D".
const StepViewer = dynamic(() => import("@/components/StepViewer"), {
ssr: false,
loading: () => (
<div className="relative w-full h-[480px] rounded-lg border border-slate-200 bg-slate-50 flex items-center justify-center text-sm text-slate-500">
Loading viewer
</div>
),
});
interface FileView {
id: string;
originalName: string;
sizeBytes: number;
kind: string;
mimeType: string | null;
}
interface PartInfo {
id: string;
code: string;
name: string;
material: string | null;
qty: number;
notes: string | null;
stepFile: FileView | null;
drawingFile: FileView | null;
cutFile: FileView | null;
thumbnailFileId: string | null;
}
export interface TimeLogRow {
id: string;
startedAt: string;
endedAt: string | null;
unitsProcessed: number | null;
note: string | null;
operatorName: string;
}
export interface OperationRow {
id: string;
sequence: number;
name: string;
kind: string;
machineId: string | null;
machineName: string | null;
templateId: string | null;
templateName: string | null;
settings: string | null;
materialNotes: string | null;
instructions: string | null;
qcRequired: boolean;
plannedMinutes: number | null;
plannedUnits: number | null;
status: string;
qrToken: string;
timeLogs: TimeLogRow[];
}
interface MachineOption {
id: string;
name: string;
}
interface TemplateOption {
id: string;
name: string;
machineId: string | null;
defaultSettings: string | null;
defaultInstructions: string | null;
qcRequired: boolean;
}
export interface QcRecordRow {
id: string;
kind: string;
passed: boolean;
notes: string | null;
measurements: string | null;
createdAt: string;
operatorName: string;
operationSequence: number;
operationName: string;
}
type Slot = "stepFileId" | "drawingFileId" | "cutFileId";
const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = {
pending: "slate",
in_progress: "blue",
partial: "amber",
completed: "green",
qc_failed: "red",
};
const STATUS_LABEL: Record<string, string> = {
pending: "Pending",
in_progress: "In progress",
partial: "Partial",
completed: "Completed",
qc_failed: "QC failed",
};
function formatBytes(n: number) {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
export default function PartDetailClient({
project,
assembly,
part,
operations,
machines,
templates,
qcRecords,
}: {
project: { id: string; code: string; name: string };
assembly: { id: string; code: string; name: string };
part: PartInfo;
operations: OperationRow[];
machines: MachineOption[];
templates: TemplateOption[];
qcRecords: QcRecordRow[];
}) {
const router = useRouter();
const [editOpen, setEditOpen] = useState(false);
const [dupeOpen, setDupeOpen] = 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>
<Link
href={`/admin/projects/${project.id}/assemblies/${assembly.id}`}
className="hover:underline"
>
{assembly.code}
</Link>
<span className="mx-1"></span>
<span>{part.code}</span>
</nav>
<PageHeader
title={
<span className="flex items-center gap-3">
<span className="font-mono text-slate-500 text-lg">{part.code}</span>
<span>{part.name}</span>
</span>
}
description={
<span className="text-slate-500">
{part.material ? `${part.material} · ` : ""}Qty {part.qty}
</span>
}
actions={
<div className="flex items-center gap-2">
<a
href={`/api/v1/parts/${part.id}/travelers.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"
>
Print travelers (PDF)
</a>
<Button variant="secondary" onClick={() => setDupeOpen(true)}>
Duplicate
</Button>
<Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit part
</Button>
</div>
}
/>
{part.notes ? (
<Card className="mb-4">
<div className="p-4 text-sm text-slate-700 whitespace-pre-wrap">{part.notes}</div>
</Card>
) : null}
<section className="mb-8">
<h2 className="text-lg font-semibold mb-3">Files</h2>
<div className="grid gap-3 md:grid-cols-3">
<FileSlot
partId={part.id}
label="STEP / 3D"
description="3D model for in-app viewer."
accept=".step,.stp"
slot="stepFileId"
file={part.stepFile}
onChange={() => router.refresh()}
/>
<FileSlot
partId={part.id}
label="Drawing (PDF)"
description="Dimensioned drawing."
accept=".pdf"
slot="drawingFileId"
file={part.drawingFile}
onChange={() => router.refresh()}
/>
<FileSlot
partId={part.id}
label="Cut file"
description="DXF or SVG for the punch/laser."
accept=".dxf,.svg"
slot="cutFileId"
file={part.cutFile}
onChange={() => router.refresh()}
/>
</div>
</section>
{part.stepFile ? (
<StepViewerSection
partId={part.id}
fileId={part.stepFile.id}
fileName={part.stepFile.originalName}
hasThumbnail={!!part.thumbnailFileId}
onThumbnailSaved={() => router.refresh()}
/>
) : null}
<OperationsSection
partId={part.id}
operations={operations}
machines={machines}
templates={templates}
onChange={() => router.refresh()}
/>
<QcHistorySection records={qcRecords} />
{editOpen && (
<EditPartModal
part={part}
onClose={() => setEditOpen(false)}
onSaved={() => router.refresh()}
onDeleted={() => router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}`)}
/>
)}
{dupeOpen && (
<DuplicatePartModal
part={part}
onClose={() => setDupeOpen(false)}
onCreated={(newPartId) => {
setDupeOpen(false);
router.push(
`/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${newPartId}`,
);
}}
/>
)}
</div>
);
}
// -------- QC history -----------------------------------------------------
function QcHistorySection({ records }: { records: QcRecordRow[] }) {
if (records.length === 0) {
return (
<section className="mt-10">
<h2 className="text-lg font-semibold mb-3">QC history</h2>
<Card>
<div className="p-6 text-sm text-slate-500 text-center">
No QC records yet. Inline QC stamps and dedicated inspection steps will appear here as
they&apos;re recorded.
</div>
</Card>
</section>
);
}
const fails = records.filter((r) => !r.passed).length;
return (
<section className="mt-10">
<div className="flex items-baseline justify-between mb-3">
<h2 className="text-lg font-semibold">QC history</h2>
<div className="text-xs text-slate-500">
{records.length} record{records.length === 1 ? "" : "s"}
{fails > 0 ? ` · ${fails} failing` : ""}
</div>
</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-3 py-2 font-medium w-[130px]">When</th>
<th className="px-3 py-2 font-medium">Step</th>
<th className="px-3 py-2 font-medium">Operator</th>
<th className="px-3 py-2 font-medium">Kind</th>
<th className="px-3 py-2 font-medium">Result</th>
<th className="px-3 py-2 font-medium">Notes</th>
</tr>
</thead>
<tbody>
{records.map((r) => (
<tr key={r.id} className="border-b border-slate-100 last:border-0 align-top">
<td className="px-3 py-3 text-slate-600 whitespace-nowrap">
{new Date(r.createdAt).toLocaleString()}
</td>
<td className="px-3 py-3">
<div className="font-medium">
{r.operationSequence}. {r.operationName}
</div>
</td>
<td className="px-3 py-3 text-slate-700">{r.operatorName}</td>
<td className="px-3 py-3 text-slate-600 capitalize">{r.kind}</td>
<td className="px-3 py-3">
<Badge tone={r.passed ? "green" : "red"}>{r.passed ? "Pass" : "Fail"}</Badge>
</td>
<td className="px-3 py-3 text-slate-700 whitespace-pre-wrap max-w-md">
{r.notes ? r.notes : <span className="text-slate-400"></span>}
{r.measurements ? (
<details className="mt-1">
<summary className="text-xs text-slate-500 cursor-pointer">
Measurements
</summary>
<pre className="mt-1 text-xs bg-slate-50 rounded p-2 overflow-x-auto">
{r.measurements}
</pre>
</details>
) : null}
</td>
</tr>
))}
</tbody>
</table>
</Card>
</section>
);
}
// -------- Duplicate part -------------------------------------------------
function DuplicatePartModal({
part,
onClose,
onCreated,
}: {
part: PartInfo;
onClose: () => void;
onCreated: (id: string) => void;
}) {
// Default to "<original>-COPY" so the admin just has to edit a suffix rather
// than retyping the whole thing; the uniqueness constraint is enforced
// server-side and reports back as a 409 if it clashes.
const [code, setCode] = useState(`${part.code}-COPY`);
const [name, setName] = useState(part.name);
const [includeOperations, setIncludeOperations] = useState(true);
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 res = await apiFetch<{ part: { id: string }; operationsCopied: number }>(
`/api/v1/parts/${part.id}/duplicate`,
{
method: "POST",
body: JSON.stringify({ code, name, includeOperations }),
},
);
onCreated(res.part.id);
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Duplicate failed");
setBusy(false);
}
}
return (
<Modal
open
onClose={onClose}
title={`Duplicate ${part.code}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="part-dup-form" disabled={busy}>
{busy ? "Duplicating…" : "Duplicate"}
</Button>
</>
}
>
<form id="part-dup-form" onSubmit={submit} className="space-y-4">
<p className="text-sm text-slate-600">
Creates a new part in the same assembly. File attachments are re-used;
operations are cloned with fresh QR codes and reset to pending.
</p>
<Field label="New code" required hint="Must be unique within this assembly.">
<Input value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
</Field>
<Field label="Name">
<Input value={name} onChange={(e) => setName(e.target.value)} />
</Field>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={includeOperations}
onChange={(e) => setIncludeOperations(e.target.checked)}
/>
Copy operations (fresh QR codes, status reset to pending)
</label>
<ErrorBanner message={error} />
</form>
</Modal>
);
}
// -------- Operations -----------------------------------------------------
function OperationsSection({
partId,
operations,
machines,
templates,
onChange,
}: {
partId: string;
operations: OperationRow[];
machines: MachineOption[];
templates: TemplateOption[];
onChange: () => void;
}) {
const [newOpen, setNewOpen] = useState(false);
const [edit, setEdit] = useState<OperationRow | null>(null);
const [qrFor, setQrFor] = useState<OperationRow | null>(null);
const [logsFor, setLogsFor] = useState<OperationRow | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function move(index: number, dir: -1 | 1) {
const target = index + dir;
if (target < 0 || target >= operations.length) return;
const order = operations.map((o) => o.id);
[order[index], order[target]] = [order[target], order[index]];
setBusyId(operations[index].id);
setError(null);
try {
await apiFetch(`/api/v1/parts/${partId}/operations/reorder`, {
method: "POST",
body: JSON.stringify({ order }),
});
onChange();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Reorder failed");
} finally {
setBusyId(null);
}
}
async function remove(op: OperationRow) {
if (!confirm(`Delete operation ${op.sequence}. ${op.name}?`)) return;
setBusyId(op.id);
setError(null);
try {
await apiFetch(`/api/v1/operations/${op.id}`, { method: "DELETE" });
onChange();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Delete failed");
} finally {
setBusyId(null);
}
}
async function resetQc(op: OperationRow) {
if (
!confirm(
`Clear QC failure on step ${op.sequence}. ${op.name}? The step will reopen for rework; the failing QC record stays on file.`,
)
) {
return;
}
setBusyId(op.id);
setError(null);
try {
await apiFetch(`/api/v1/operations/${op.id}/qc-reset`, { method: "POST" });
onChange();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Reset failed");
} finally {
setBusyId(null);
}
}
return (
<section>
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">Operations</h2>
<Button size="sm" onClick={() => setNewOpen(true)}>
Add operation
</Button>
</div>
<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-3 py-2 font-medium w-10">#</th>
<th className="px-3 py-2 font-medium">Name</th>
<th className="px-3 py-2 font-medium">Machine</th>
<th className="px-3 py-2 font-medium">QC</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">QR</th>
<th className="px-3 py-2 text-right"></th>
</tr>
</thead>
<tbody>
{operations.map((op, i) => (
<tr key={op.id} className="border-b border-slate-100 last:border-0">
<td className="px-3 py-3 text-slate-600">{op.sequence}</td>
<td className="px-3 py-3">
<div className="font-medium flex items-center gap-2">
<span>{op.name}</span>
{op.kind === "qc" ? <Badge tone="blue">QC step</Badge> : null}
</div>
{op.templateName ? (
<div className="text-xs text-slate-500">from {op.templateName}</div>
) : null}
</td>
<td className="px-3 py-3 text-slate-600">{op.machineName ?? "—"}</td>
<td className="px-3 py-3">
{op.qcRequired ? <Badge tone="amber">required</Badge> : <span className="text-slate-400"></span>}
</td>
<td className="px-3 py-3">
<Badge tone={STATUS_TONE[op.status] ?? "slate"}>
{STATUS_LABEL[op.status] ?? op.status}
</Badge>
</td>
<td className="px-3 py-3">
<code className="text-xs text-slate-500" title={op.qrToken}>
{op.qrToken.slice(0, 8)}
</code>
</td>
<td className="px-3 py-3 text-right whitespace-nowrap">
<button
type="button"
className="text-slate-500 hover:text-slate-900 px-1 disabled:opacity-40"
disabled={i === 0 || busyId !== null}
onClick={() => move(i, -1)}
aria-label="Move up"
>
</button>
<button
type="button"
className="text-slate-500 hover:text-slate-900 px-1 disabled:opacity-40"
disabled={i === operations.length - 1 || busyId !== null}
onClick={() => move(i, 1)}
aria-label="Move down"
>
</button>
<Button variant="ghost" size="sm" onClick={() => setQrFor(op)} disabled={busyId !== null}>
QR
</Button>
<a
href={`/api/v1/operations/${op.id}/card.pdf`}
target="_blank"
rel="noopener"
className={`inline-flex items-center rounded-md px-2 py-1 text-xs text-slate-600 hover:text-slate-900 hover:underline ${
busyId !== null ? "pointer-events-none opacity-50" : ""
}`}
>
Print
</a>
<Button
variant="ghost"
size="sm"
onClick={() => setLogsFor(op)}
disabled={busyId !== null}
>
Logs{op.timeLogs.length > 0 ? ` (${op.timeLogs.length})` : ""}
</Button>
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
Edit
</Button>
{op.status === "qc_failed" ? (
<Button
variant="ghost"
size="sm"
onClick={() => resetQc(op)}
disabled={busyId !== null}
>
Reset QC
</Button>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => remove(op)}
disabled={busyId !== null || op.status === "in_progress"}
>
Delete
</Button>
</td>
</tr>
))}
{operations.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
No operations on this part yet.
</td>
</tr>
)}
</tbody>
</table>
</Card>
{newOpen && (
<OperationModal
partId={partId}
machines={machines}
templates={templates}
onClose={() => setNewOpen(false)}
onSaved={() => {
setNewOpen(false);
onChange();
}}
/>
)}
{edit && (
<OperationModal
partId={partId}
operation={edit}
machines={machines}
templates={templates}
onClose={() => setEdit(null)}
onSaved={() => {
setEdit(null);
onChange();
}}
/>
)}
{qrFor && <QrModal operation={qrFor} onClose={() => setQrFor(null)} />}
{logsFor && (
<TimeLogsModal
operation={logsFor}
onClose={() => setLogsFor(null)}
onChange={() => {
onChange();
setLogsFor(null);
}}
/>
)}
</section>
);
}
// -------- Time log correction -------------------------------------------
function formatDateTimeLocal(iso: string | null): string {
// <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in local time.
// Avoid `toISOString()` here — that's UTC and shifts the displayed value.
if (!iso) return "";
const d = new Date(iso);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function parseLocalDateTime(local: string): string | null {
if (!local) return null;
// Treat the input as local time — new Date("YYYY-MM-DDTHH:mm") already does,
// but we ensure seconds are zero so the round-trip is stable.
const d = new Date(local);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString();
}
function durationText(startedAt: string, endedAt: string | null): string {
if (!endedAt) return "open";
const ms = new Date(endedAt).getTime() - new Date(startedAt).getTime();
if (ms < 0) return "inverted";
const totalMin = Math.round(ms / 60000);
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function TimeLogsModal({
operation,
onClose,
onChange,
}: {
operation: OperationRow;
onClose: () => void;
onChange: () => void;
}) {
const [editing, setEditing] = useState<TimeLogRow | null>(null);
return (
<Modal
open
onClose={onClose}
title={`Time logs — step ${operation.sequence}. ${operation.name}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose}>
Close
</Button>
</>
}
>
{operation.timeLogs.length === 0 ? (
<p className="text-sm text-slate-500">No time logs recorded on this operation yet.</p>
) : (
<div className="space-y-2">
<p className="text-xs text-slate-500">
Adjust a stale or mis-entered log. Edits are audited; the operator&apos;s original row is kept in the audit log.
</p>
<table className="w-full text-sm">
<thead className="text-left text-slate-600 border-b border-slate-200">
<tr>
<th className="px-2 py-2 font-medium">Operator</th>
<th className="px-2 py-2 font-medium">Started</th>
<th className="px-2 py-2 font-medium">Ended</th>
<th className="px-2 py-2 font-medium">Duration</th>
<th className="px-2 py-2 font-medium">Units</th>
<th className="px-2 py-2 font-medium">Note</th>
<th className="px-2 py-2"></th>
</tr>
</thead>
<tbody>
{operation.timeLogs.map((log) => {
const isOpen = log.endedAt === null;
return (
<tr key={log.id} className="border-b border-slate-100 last:border-0 align-top">
<td className="px-2 py-2 text-slate-700">{log.operatorName}</td>
<td className="px-2 py-2 text-slate-600 whitespace-nowrap">
{new Date(log.startedAt).toLocaleString()}
</td>
<td className="px-2 py-2 text-slate-600 whitespace-nowrap">
{isOpen ? (
<Badge tone="amber">open</Badge>
) : (
new Date(log.endedAt!).toLocaleString()
)}
</td>
<td className="px-2 py-2 text-slate-600">
{durationText(log.startedAt, log.endedAt)}
</td>
<td className="px-2 py-2 text-slate-600">
{log.unitsProcessed ?? <span className="text-slate-400"></span>}
</td>
<td className="px-2 py-2 text-slate-600">
{log.note ? (
<span className="line-clamp-2" title={log.note}>
{log.note}
</span>
) : (
<span className="text-slate-400"></span>
)}
</td>
<td className="px-2 py-2 text-right whitespace-nowrap">
<Button variant="ghost" size="sm" onClick={() => setEditing(log)}>
Edit
</Button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{editing && (
<EditTimeLogModal
log={editing}
onClose={() => setEditing(null)}
onSaved={() => {
setEditing(null);
onChange();
}}
/>
)}
</Modal>
);
}
function EditTimeLogModal({
log,
onClose,
onSaved,
}: {
log: TimeLogRow;
onClose: () => void;
onSaved: () => void;
}) {
const [startedAt, setStartedAt] = useState(formatDateTimeLocal(log.startedAt));
const [endedAt, setEndedAt] = useState(formatDateTimeLocal(log.endedAt));
const [units, setUnits] = useState(log.unitsProcessed === null ? "" : String(log.unitsProcessed));
const [note, setNote] = useState(log.note ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
const patch: Record<string, unknown> = {};
const startedIso = parseLocalDateTime(startedAt);
if (startedIso && startedIso !== log.startedAt) patch.startedAt = startedIso;
const endedIso = endedAt ? parseLocalDateTime(endedAt) : null;
if (endedIso !== log.endedAt) patch.endedAt = endedIso;
if (units === "") {
if (log.unitsProcessed !== null) patch.unitsProcessed = null;
} else {
const n = Number(units);
if (Number.isFinite(n) && n !== log.unitsProcessed) patch.unitsProcessed = n;
}
const nextNote = note.trim() === "" ? null : note;
if (nextNote !== log.note) patch.note = nextNote;
if (Object.keys(patch).length === 0) {
onClose();
return;
}
try {
await apiFetch(`/api/v1/timelogs/${log.id}`, {
method: "PATCH",
body: JSON.stringify(patch),
});
onSaved();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Save failed");
setBusy(false);
}
}
async function remove() {
if (
!confirm(
"Delete this time log entry? Prefer editing to zero units out when the row represents real work.",
)
) {
return;
}
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/timelogs/${log.id}`, { method: "DELETE" });
onSaved();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Delete failed");
setBusy(false);
}
}
return (
<Modal
open
onClose={onClose}
title="Edit time log"
footer={
<>
<Button variant="danger" size="sm" onClick={remove} disabled={busy}>
Delete
</Button>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="tl-edit-form" disabled={busy}>
{busy ? "Saving…" : "Save"}
</Button>
</>
}
>
<form id="tl-edit-form" onSubmit={submit} className="space-y-4">
<Field label="Started at" required>
<Input
type="datetime-local"
value={startedAt}
onChange={(e) => setStartedAt(e.target.value)}
required
/>
</Field>
<Field
label="Ended at"
hint="Leave blank to re-open the log (the operator will need to close it themselves)."
>
<Input
type="datetime-local"
value={endedAt}
onChange={(e) => setEndedAt(e.target.value)}
/>
</Field>
<Field label="Units processed" hint="Leave blank for unknown.">
<Input
type="number"
min={0}
value={units}
onChange={(e) => setUnits(e.target.value)}
/>
</Field>
<Field label="Note">
<Textarea value={note} onChange={(e) => setNote(e.target.value)} />
</Field>
<ErrorBanner message={error} />
</form>
</Modal>
);
}
function QrModal({ operation, onClose }: { operation: OperationRow; onClose: () => void }) {
const [data, setData] = useState<
{ dataUrl: string; scanUrl: string; token: string } | null
>(null);
const [error, setError] = useState<string | null>(null);
// Fetch lazily so we don't pre-render QRs for every op on the page. The
// data URL is ~1 KB so this is cheap, but it does require a server hop.
useEffect(() => {
let cancelled = false;
apiFetch<{ dataUrl: string; scanUrl: string; token: string }>(
`/api/v1/operations/${operation.id}/qr`,
)
.then((d) => {
if (!cancelled) setData(d);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiClientError ? err.message : "Load failed");
});
return () => {
cancelled = true;
};
}, [operation.id]);
return (
<Modal
open
onClose={onClose}
title={`QR: step ${operation.sequence}. ${operation.name}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{data ? (
<a
href={data.dataUrl}
download={`op-${operation.sequence}-${operation.id}.png`}
className="inline-flex items-center rounded-md bg-slate-900 text-white text-sm px-3 py-1.5 hover:bg-slate-800"
>
Download PNG
</a>
) : null}
</>
}
>
<div className="space-y-3">
{error ? (
<ErrorBanner message={error} />
) : data ? (
<>
<div className="flex justify-center bg-white border border-slate-200 rounded-md p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.dataUrl}
alt={`QR for ${operation.name}`}
width={256}
height={256}
/>
</div>
<div className="text-xs text-slate-600 space-y-1">
<div>
<span className="font-medium">Scan URL:</span>{" "}
<a href={data.scanUrl} className="text-blue-600 hover:underline break-all">
{data.scanUrl}
</a>
</div>
<div>
<span className="font-medium">Token:</span>{" "}
<code className="text-slate-500">{data.token}</code>
</div>
</div>
</>
) : (
<div className="text-center text-slate-500 text-sm py-10">Rendering QR</div>
)}
</div>
</Modal>
);
}
function OperationModal({
partId,
operation,
machines,
templates,
onClose,
onSaved,
}: {
partId: string;
operation?: OperationRow;
machines: MachineOption[];
templates: TemplateOption[];
onClose: () => void;
onSaved: () => void;
}) {
const editing = !!operation;
const [templateId, setTemplateId] = useState(operation?.templateId ?? "");
const [name, setName] = useState(operation?.name ?? "");
const [kind, setKind] = useState<"work" | "qc">(
(operation?.kind as "work" | "qc") ?? "work",
);
const [machineId, setMachineId] = useState(operation?.machineId ?? "");
const [settings, setSettings] = useState(operation?.settings ?? "");
const [materialNotes, setMaterialNotes] = useState(operation?.materialNotes ?? "");
const [instructions, setInstructions] = useState(operation?.instructions ?? "");
const [qcRequired, setQcRequired] = useState(operation?.qcRequired ?? false);
const [plannedMinutes, setPlannedMinutes] = useState(
operation?.plannedMinutes ? String(operation.plannedMinutes) : "",
);
const [plannedUnits, setPlannedUnits] = useState(
operation?.plannedUnits ? String(operation.plannedUnits) : "",
);
const [status, setStatus] = useState(operation?.status ?? "pending");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
function applyTemplate(id: string) {
setTemplateId(id);
if (!id) return;
const t = templates.find((x) => x.id === id);
if (!t) return;
if (!name) setName(t.name);
if (!machineId && t.machineId) setMachineId(t.machineId);
if (!settings && t.defaultSettings) setSettings(t.defaultSettings);
if (!instructions && t.defaultInstructions) setInstructions(t.defaultInstructions);
if (t.qcRequired) setQcRequired(true);
}
async function submit(e: React.FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
const body = {
templateId: templateId || null,
name,
kind,
machineId: machineId || null,
settings,
materialNotes,
instructions,
qcRequired,
plannedMinutes: plannedMinutes ? Number(plannedMinutes) : null,
plannedUnits: plannedUnits ? Number(plannedUnits) : null,
...(editing ? { status } : {}),
};
if (editing) {
await apiFetch(`/api/v1/operations/${operation!.id}`, {
method: "PATCH",
body: JSON.stringify(body),
});
} else {
await apiFetch(`/api/v1/parts/${partId}/operations`, {
method: "POST",
body: JSON.stringify(body),
});
}
onSaved();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Save failed");
setBusy(false);
}
}
return (
<Modal
open
onClose={onClose}
title={editing ? `Edit operation ${operation!.sequence}. ${operation!.name}` : "New operation"}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="op-form" disabled={busy}>
{busy ? "Saving…" : editing ? "Save" : "Create"}
</Button>
</>
}
>
<form id="op-form" onSubmit={submit} className="space-y-4">
{!editing && (
<Field label="Start from template" hint="Pre-fills machine, settings, instructions, and QC.">
<Select value={templateId} onChange={(e) => applyTemplate(e.target.value)}>
<option value=""> ad-hoc, no template </option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</Select>
</Field>
)}
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</Field>
<Field
label="Kind"
hint='"Work" is a normal production step. "QC" is a dedicated inspection step — close always demands a pass/fail record and unit counts are ignored.'
>
<Select value={kind} onChange={(e) => setKind(e.target.value as "work" | "qc")}>
<option value="work">Work production step</option>
<option value="qc">QC dedicated inspection</option>
</Select>
</Field>
<Field label="Machine">
<Select value={machineId} onChange={(e) => setMachineId(e.target.value)}>
<option value=""> none </option>
{machines.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</Select>
</Field>
<div className="grid gap-3 sm:grid-cols-2">
<Field label="Planned minutes" hint="Optional estimate.">
<Input
type="number"
min={1}
value={plannedMinutes}
onChange={(e) => setPlannedMinutes(e.target.value)}
/>
</Field>
<Field label="Planned units" hint="Target count for this step.">
<Input
type="number"
min={1}
value={plannedUnits}
onChange={(e) => setPlannedUnits(e.target.value)}
/>
</Field>
</div>
<Field label="Settings (JSON)" hint='e.g. {"programNumber":47}. Optional.'>
<Textarea
value={settings}
onChange={(e) => setSettings(e.target.value)}
placeholder='{"programNumber":47}'
/>
</Field>
<Field label="Material notes" hint="Stock sizing, grain direction, etc.">
<Textarea value={materialNotes} onChange={(e) => setMaterialNotes(e.target.value)} />
</Field>
<Field label="Instructions" hint="Printed on the traveler card.">
<Textarea value={instructions} onChange={(e) => setInstructions(e.target.value)} />
</Field>
<label className="flex items-center gap-2 text-sm">
<input type="checkbox" checked={qcRequired} onChange={(e) => setQcRequired(e.target.checked)} />
<span>QC check required on close-out</span>
</label>
{editing && (
<Field
label="Status"
hint="Use QC-failed only if you need to block a step out-of-band; the normal path is for the operator's Done/fail to set it."
>
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
<option value="pending">Pending</option>
<option value="in_progress">In progress</option>
<option value="partial">Partial</option>
<option value="completed">Completed</option>
<option value="qc_failed">QC failed</option>
</Select>
</Field>
)}
<ErrorBanner message={error} />
</form>
</Modal>
);
}
// -------- 3D viewer ------------------------------------------------------
function StepViewerSection({
partId,
fileId,
fileName,
hasThumbnail,
onThumbnailSaved,
}: {
partId: string;
fileId: string;
fileName: string;
hasThumbnail: boolean;
onThumbnailSaved: () => void;
}) {
const [open, setOpen] = useState(false);
const [edges, setEdges] = useState(true);
const [savingThumb, setSavingThumb] = useState(false);
const [thumbMessage, setThumbMessage] = useState<string | null>(null);
// Bump this to force a remount of the viewer (and therefore a fresh
// first-frame capture). Used by "Regenerate thumbnail".
const [captureNonce, setCaptureNonce] = useState(0);
// One-shot guard so onFirstFrame from the render loop only fires once per
// viewer mount. Reset whenever captureNonce advances.
const capturedRef = useRef(false);
// The viewer fetches via `/api/v1/files/:id/download`, which streams the
// stored STEP bytes with correct auth (admin session cookie is already set).
const url = `/api/v1/files/${fileId}/download`;
// Capture + upload the first-frame thumbnail. Runs at most once per open.
async function handleFirstFrame(blob: Blob) {
if (capturedRef.current) return;
capturedRef.current = true;
setSavingThumb(true);
setThumbMessage(null);
try {
const form = new FormData();
form.set("file", new File([blob], `part-${partId}.jpg`, { type: "image/jpeg" }));
const res = await apiFetch<{ file: { id: string } }>("/api/v1/files", {
method: "POST",
body: form,
});
await apiFetch(`/api/v1/parts/${partId}`, {
method: "PATCH",
body: JSON.stringify({ thumbnailFileId: res.file.id }),
});
setThumbMessage("Thumbnail saved.");
onThumbnailSaved();
} catch (err) {
setThumbMessage(
err instanceof ApiClientError ? `Thumbnail save failed: ${err.message}` : "Thumbnail save failed",
);
} finally {
setSavingThumb(false);
}
}
// Only request a new thumbnail if there isn't one already (or the user hit
// "Regenerate"), to avoid overwriting a good capture with a worse one every
// time someone opens the viewer.
const shouldCapture = open && (!hasThumbnail || captureNonce > 0) && !capturedRef.current;
return (
<section className="mb-8">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold">3D viewer</h2>
<div className="flex items-center gap-2">
{open ? (
<>
<label className="flex items-center gap-1.5 text-xs text-slate-600">
<input
type="checkbox"
checked={edges}
onChange={(e) => setEdges(e.target.checked)}
/>
Show edges
</label>
{hasThumbnail ? (
<Button
variant="ghost"
size="sm"
disabled={savingThumb}
onClick={() => {
capturedRef.current = false;
setThumbMessage("Regenerating on next frame…");
setCaptureNonce((n) => n + 1);
}}
>
{savingThumb ? "Saving…" : "Regenerate thumbnail"}
</Button>
) : null}
</>
) : null}
<Button variant={open ? "secondary" : "primary"} size="sm" onClick={() => setOpen((v) => !v)}>
{open ? "Hide" : "Show 3D"}
</Button>
</div>
</div>
{open ? (
<Card>
<div className="p-3">
<div className="mb-2 flex items-center justify-between gap-3 text-xs text-slate-500">
<span className="truncate" title={fileName}>
Viewing: <code className="font-mono text-slate-700">{fileName}</code>
</span>
{thumbMessage ? <span className="text-slate-500">{thumbMessage}</span> : null}
</div>
<StepViewer
key={`${fileId}-${edges ? "e" : "ne"}-${captureNonce}`}
url={url}
showEdges={edges}
onFirstFrame={shouldCapture ? handleFirstFrame : undefined}
/>
<p className="mt-2 text-[11px] text-slate-500">
Drag to rotate · scroll to zoom · right-drag to pan. Parsing and rendering happen in
your browser; the first view also uploads a ~480×360 JPEG thumbnail for the parts list.
</p>
</div>
</Card>
) : (
<p className="text-sm text-slate-500">
Loads the STEP file into an interactive 3D view. Click <span className="font-medium">Show 3D</span>{" "}
to start the viewer downloads ~2 MB of OpenCascade WASM on first use.
</p>
)}
</section>
);
}
// -------- Files ----------------------------------------------------------
function FileSlot({
partId,
label,
description,
accept,
slot,
file,
onChange,
}: {
partId: string;
label: string;
description: string;
accept: string;
slot: Slot;
file: FileView | null;
onChange: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
async function upload(f: File) {
setBusy(true);
setError(null);
try {
const form = new FormData();
form.set("file", f);
const uploaded = await apiFetch<{ file: { id: string } }>("/api/v1/files", {
method: "POST",
body: form,
});
await apiFetch(`/api/v1/parts/${partId}`, {
method: "PATCH",
body: JSON.stringify({ [slot]: uploaded.file.id }),
});
onChange();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Upload failed");
} finally {
setBusy(false);
if (inputRef.current) inputRef.current.value = "";
}
}
async function detach() {
if (!confirm(`Detach ${label}?`)) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/parts/${partId}`, {
method: "PATCH",
body: JSON.stringify({ [slot]: null }),
});
onChange();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Detach failed");
} finally {
setBusy(false);
}
}
return (
<Card>
<div className="p-4 space-y-3">
<div>
<h3 className="font-semibold">{label}</h3>
<p className="text-xs text-slate-500">{description}</p>
</div>
{file ? (
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
<div className="font-medium truncate" title={file.originalName}>
{file.originalName}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{formatBytes(file.sizeBytes)} · {file.kind}
</div>
<div className="flex gap-2 mt-2">
<a
href={`/api/v1/files/${file.id}/download`}
className="text-xs text-blue-600 hover:underline"
>
Download
</a>
<button
type="button"
onClick={detach}
disabled={busy}
className="text-xs text-red-600 hover:underline disabled:opacity-50"
>
Detach
</button>
<button
type="button"
onClick={() => inputRef.current?.click()}
disabled={busy}
className="text-xs text-slate-600 hover:underline disabled:opacity-50 ml-auto"
>
Replace
</button>
</div>
</div>
) : (
<div className="rounded-md border border-dashed border-slate-300 p-4 text-center">
<p className="text-xs text-slate-500 mb-2">No file attached.</p>
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => inputRef.current?.click()}
disabled={busy}
>
{busy ? "Uploading…" : "Upload"}
</Button>
</div>
)}
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) upload(f);
}}
/>
<ErrorBanner message={error} />
</div>
</Card>
);
}
// -------- Edit part ------------------------------------------------------
function EditPartModal({
part,
onClose,
onSaved,
onDeleted,
}: {
part: PartInfo;
onClose: () => void;
onSaved: () => void;
onDeleted: () => void;
}) {
const [code, setCode] = useState(part.code);
const [name, setName] = useState(part.name);
const [material, setMaterial] = useState(part.material ?? "");
const [qty, setQty] = useState(String(part.qty));
const [notes, setNotes] = useState(part.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 {
await apiFetch(`/api/v1/parts/${part.id}`, {
method: "PATCH",
body: JSON.stringify({
code,
name,
material: material || null,
qty: Number(qty),
notes: notes || null,
}),
});
onSaved();
onClose();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Save failed");
setBusy(false);
}
}
async function remove() {
if (!confirm(`Delete part ${part.code}? All operations on it will be removed.`)) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/v1/parts/${part.id}`, { method: "DELETE" });
onDeleted();
} catch (err) {
setError(err instanceof ApiClientError ? err.message : "Delete failed");
setBusy(false);
}
}
return (
<Modal
open
onClose={onClose}
title={`Edit ${part.code}`}
footer={
<>
<Button variant="danger" size="sm" onClick={remove} disabled={busy}>
Delete
</Button>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="part-edit-form" disabled={busy}>
{busy ? "Saving…" : "Save"}
</Button>
</>
}
>
<form id="part-edit-form" onSubmit={submit} className="space-y-4">
<Field label="Code" required>
<Input value={code} onChange={(e) => setCode(e.target.value)} required />
</Field>
<Field label="Name" required>
<Input value={name} onChange={(e) => setName(e.target.value)} required />
</Field>
<Field label="Material">
<Input value={material} onChange={(e) => setMaterial(e.target.value)} />
</Field>
<Field label="Quantity" required>
<Input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} required />
</Field>
<Field label="Notes">
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
</Field>
<ErrorBanner message={error} />
</form>
</Modal>
);
}