From 04ae88ca0d15b5755d4bc5e9bb2fe6c5f37220a9 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 22 Apr 2026 13:16:42 -0500 Subject: [PATCH] QoL changes and additions --- .../projects/[id]/ProjectDetailClient.tsx | 18 + .../[assemblyId]/AssemblyDetailClient.tsx | 91 ++++ .../parts/[partId]/PartDetailClient.tsx | 473 ++++++++++++++++++ .../[assemblyId]/parts/[partId]/page.tsx | 37 ++ app/api/v1/assemblies/[id]/duplicate/route.ts | 46 ++ app/api/v1/parts/[id]/duplicate/route.ts | 42 ++ .../v1/projects/[id]/travelers.pdf/route.ts | 184 +++++++ app/api/v1/timelogs/[id]/route.ts | 93 ++++ app/op/page.tsx | 94 +++- docs/BUILD-PLAN.md | 7 +- lib/duplicate.ts | 178 +++++++ lib/pdf.ts | 158 +++++- lib/schemas.ts | 30 ++ tsconfig.tsbuildinfo | 2 +- 14 files changed, 1424 insertions(+), 29 deletions(-) create mode 100644 app/api/v1/assemblies/[id]/duplicate/route.ts create mode 100644 app/api/v1/parts/[id]/duplicate/route.ts create mode 100644 app/api/v1/projects/[id]/travelers.pdf/route.ts create mode 100644 app/api/v1/timelogs/[id]/route.ts create mode 100644 lib/duplicate.ts diff --git a/app/admin/projects/[id]/ProjectDetailClient.tsx b/app/admin/projects/[id]/ProjectDetailClient.tsx index 2922f76..c058125 100644 --- a/app/admin/projects/[id]/ProjectDetailClient.tsx +++ b/app/admin/projects/[id]/ProjectDetailClient.tsx @@ -98,6 +98,24 @@ export default function ProjectDetailClient({ } actions={ <> + + Print outstanding travelers + + + Print all travelers + diff --git a/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx b/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx index a26dce8..436ab15 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/AssemblyDetailClient.tsx @@ -75,6 +75,7 @@ export default function AssemblyDetailClient({ const router = useRouter(); const [editOpen, setEditOpen] = useState(false); const [newPartOpen, setNewPartOpen] = useState(false); + const [dupeOpen, setDupeOpen] = useState(false); return (
@@ -103,6 +104,9 @@ export default function AssemblyDetailClient({ } actions={ <> + @@ -253,10 +257,97 @@ export default function AssemblyDetailClient({ }} /> )} + {dupeOpen && ( + setDupeOpen(false)} + onCreated={(newAssemblyId) => { + setDupeOpen(false); + router.push(`/admin/projects/${project.id}/assemblies/${newAssemblyId}`); + }} + /> + )}
); } +function DuplicateAssemblyModal({ + assembly, + onClose, + onCreated, +}: { + assembly: AssemblyInfo; + onClose: () => void; + onCreated: (id: string) => void; +}) { + const [code, setCode] = useState(`${assembly.code}-COPY`); + const [name, setName] = useState(assembly.name); + const [includeOperations, setIncludeOperations] = useState(true); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + try { + const res = await apiFetch<{ assembly: { id: string } }>( + `/api/v1/assemblies/${assembly.id}/duplicate`, + { + method: "POST", + body: JSON.stringify({ code, name, includeOperations }), + }, + ); + onCreated(res.assembly.id); + } catch (err) { + setError(err instanceof ApiClientError ? err.message : "Duplicate failed"); + setBusy(false); + } + } + + return ( + +
+ + + + } + > +
+

+ Creates a new assembly in the same project. Every part is cloned along + with its file attachments. Operations get fresh QR codes and reset to + pending. +

+ + setCode(e.target.value)} required autoFocus /> + + + setName(e.target.value)} /> + + + + + + ); +} + function EditAssemblyModal({ assembly, onClose, 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 f4da39a..53e806b 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx @@ -50,6 +50,15 @@ interface PartInfo { 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; @@ -67,6 +76,7 @@ export interface OperationRow { plannedUnits: number | null; status: string; qrToken: string; + timeLogs: TimeLogRow[]; } interface MachineOption { @@ -83,6 +93,18 @@ interface TemplateOption { 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 = { @@ -114,6 +136,7 @@ export default function PartDetailClient({ operations, machines, templates, + qcRecords, }: { project: { id: string; code: string; name: string }; assembly: { id: string; code: string; name: string }; @@ -121,9 +144,11 @@ export default function PartDetailClient({ operations: OperationRow[]; machines: MachineOption[]; templates: TemplateOption[]; + qcRecords: QcRecordRow[]; }) { const router = useRouter(); const [editOpen, setEditOpen] = useState(false); + const [dupeOpen, setDupeOpen] = useState(false); return (
@@ -167,6 +192,9 @@ export default function PartDetailClient({ > Print travelers (PDF) + @@ -231,6 +259,8 @@ export default function PartDetailClient({ onChange={() => router.refresh()} /> + + {editOpen && ( router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}`)} /> )} + {dupeOpen && ( + setDupeOpen(false)} + onCreated={(newPartId) => { + setDupeOpen(false); + router.push( + `/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${newPartId}`, + ); + }} + /> + )}
); } +// -------- QC history ----------------------------------------------------- + +function QcHistorySection({ records }: { records: QcRecordRow[] }) { + if (records.length === 0) { + return ( +
+

QC history

+ +
+ No QC records yet. Inline QC stamps and dedicated inspection steps will appear here as + they're recorded. +
+
+
+ ); + } + const fails = records.filter((r) => !r.passed).length; + return ( +
+
+

QC history

+
+ {records.length} record{records.length === 1 ? "" : "s"} + {fails > 0 ? ` · ${fails} failing` : ""} +
+
+ + + + + + + + + + + + + + {records.map((r) => ( + + + + + + + + + ))} + +
WhenStepOperatorKindResultNotes
+ {new Date(r.createdAt).toLocaleString()} + +
+ {r.operationSequence}. {r.operationName} +
+
{r.operatorName}{r.kind} + {r.passed ? "Pass" : "Fail"} + + {r.notes ? r.notes : } + {r.measurements ? ( +
+ + Measurements + +
+                        {r.measurements}
+                      
+
+ ) : null} +
+
+
+ ); +} + +// -------- Duplicate part ------------------------------------------------- + +function DuplicatePartModal({ + part, + onClose, + onCreated, +}: { + part: PartInfo; + onClose: () => void; + onCreated: (id: string) => void; +}) { + // Default to "-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(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 ( + +
+ + + + } + > +
+

+ Creates a new part in the same assembly. File attachments are re-used; + operations are cloned with fresh QR codes and reset to pending. +

+ + setCode(e.target.value)} required autoFocus /> + + + setName(e.target.value)} /> + + + + + + ); +} + // -------- Operations ----------------------------------------------------- function OperationsSection({ @@ -261,6 +460,7 @@ function OperationsSection({ const [newOpen, setNewOpen] = useState(false); const [edit, setEdit] = useState(null); const [qrFor, setQrFor] = useState(null); + const [logsFor, setLogsFor] = useState(null); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); @@ -399,6 +599,14 @@ function OperationsSection({ > Print + @@ -460,10 +668,275 @@ function OperationsSection({ /> )} {qrFor && setQrFor(null)} />} + {logsFor && ( + setLogsFor(null)} + onChange={() => { + onChange(); + setLogsFor(null); + }} + /> + )} ); } +// -------- Time log correction ------------------------------------------- + +function formatDateTimeLocal(iso: string | null): string { + // 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(null); + return ( + +
+ + + } + > + {operation.timeLogs.length === 0 ? ( +

No time logs recorded on this operation yet.

+ ) : ( +
+

+ Adjust a stale or mis-entered log. Edits are audited; the operator's original row is kept in the audit log. +

+ + + + + + + + + + + + + + {operation.timeLogs.map((log) => { + const isOpen = log.endedAt === null; + return ( + + + + + + + + + + ); + })} + +
OperatorStartedEndedDurationUnitsNote
{log.operatorName} + {new Date(log.startedAt).toLocaleString()} + + {isOpen ? ( + open + ) : ( + new Date(log.endedAt!).toLocaleString() + )} + + {durationText(log.startedAt, log.endedAt)} + + {log.unitsProcessed ?? } + + {log.note ? ( + + {log.note} + + ) : ( + + )} + + +
+
+ )} + {editing && ( + setEditing(null)} + onSaved={() => { + setEditing(null); + onChange(); + }} + /> + )} + + ); +} + +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(null); + + async function submit(e: React.FormEvent) { + e.preventDefault(); + setBusy(true); + setError(null); + const patch: Record = {}; + 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 ( + + +
+ + + + } + > +
+ + setStartedAt(e.target.value)} + required + /> + + + setEndedAt(e.target.value)} + /> + + + setUnits(e.target.value)} + /> + + +