QoL changes and additions
Build and Push Docker Image / build (push) Successful in 45s

This commit is contained in:
jason
2026-04-22 13:16:42 -05:00
parent a165428f14
commit 04ae88ca0d
14 changed files with 1424 additions and 29 deletions
@@ -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<string, "slate" | "blue" | "green" | "amber" | "red"> = {
@@ -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 (
<div className="mx-auto max-w-6xl px-4 py-8">
@@ -167,6 +192,9 @@ export default function PartDetailClient({
>
Print travelers (PDF)
</a>
<Button variant="secondary" onClick={() => setDupeOpen(true)}>
Duplicate
</Button>
<Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit part
</Button>
@@ -231,6 +259,8 @@ export default function PartDetailClient({
onChange={() => router.refresh()}
/>
<QcHistorySection records={qcRecords} />
{editOpen && (
<EditPartModal
part={part}
@@ -239,10 +269,179 @@ export default function PartDetailClient({
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({
@@ -261,6 +460,7 @@ function OperationsSection({
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);
@@ -399,6 +599,14 @@ function OperationsSection({
>
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>
@@ -460,10 +668,275 @@ function OperationsSection({
/>
)}
{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