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
@@ -98,6 +98,24 @@ export default function ProjectDetailClient({
} }
actions={ actions={
<> <>
<a
href={`/api/v1/projects/${project.id}/travelers.pdf?scope=incomplete`}
target="_blank"
rel="noopener"
title="Every part with outstanding ops — cover + drawings + cards, one PDF"
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 outstanding travelers
</a>
<a
href={`/api/v1/projects/${project.id}/travelers.pdf?scope=all`}
target="_blank"
rel="noopener"
title="Every part in the project — full reprint bundle"
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 all travelers
</a>
<Button variant="secondary" onClick={() => setEditOpen(true)}> <Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit project Edit project
</Button> </Button>
@@ -75,6 +75,7 @@ export default function AssemblyDetailClient({
const router = useRouter(); const router = useRouter();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [newPartOpen, setNewPartOpen] = useState(false); const [newPartOpen, setNewPartOpen] = useState(false);
const [dupeOpen, setDupeOpen] = useState(false);
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8"> <div className="mx-auto max-w-6xl px-4 py-8">
@@ -103,6 +104,9 @@ export default function AssemblyDetailClient({
} }
actions={ actions={
<> <>
<Button variant="secondary" onClick={() => setDupeOpen(true)}>
Duplicate
</Button>
<Button variant="secondary" onClick={() => setEditOpen(true)}> <Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit assembly Edit assembly
</Button> </Button>
@@ -253,10 +257,97 @@ export default function AssemblyDetailClient({
}} }}
/> />
)} )}
{dupeOpen && (
<DuplicateAssemblyModal
assembly={assembly}
onClose={() => setDupeOpen(false)}
onCreated={(newAssemblyId) => {
setDupeOpen(false);
router.push(`/admin/projects/${project.id}/assemblies/${newAssemblyId}`);
}}
/>
)}
</div> </div>
); );
} }
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<string | null>(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 (
<Modal
open
onClose={onClose}
title={`Duplicate ${assembly.code}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" form="asm-dup-form" disabled={busy}>
{busy ? "Duplicating…" : "Duplicate"}
</Button>
</>
}
>
<form id="asm-dup-form" onSubmit={submit} className="space-y-4">
<p className="text-sm text-slate-600">
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.
</p>
<Field label="New code" required hint="Must be unique within this project.">
<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>
);
}
function EditAssemblyModal({ function EditAssemblyModal({
assembly, assembly,
onClose, onClose,
@@ -50,6 +50,15 @@ interface PartInfo {
thumbnailFileId: string | 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 { export interface OperationRow {
id: string; id: string;
sequence: number; sequence: number;
@@ -67,6 +76,7 @@ export interface OperationRow {
plannedUnits: number | null; plannedUnits: number | null;
status: string; status: string;
qrToken: string; qrToken: string;
timeLogs: TimeLogRow[];
} }
interface MachineOption { interface MachineOption {
@@ -83,6 +93,18 @@ interface TemplateOption {
qcRequired: boolean; 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"; type Slot = "stepFileId" | "drawingFileId" | "cutFileId";
const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = { const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = {
@@ -114,6 +136,7 @@ export default function PartDetailClient({
operations, operations,
machines, machines,
templates, templates,
qcRecords,
}: { }: {
project: { id: string; code: string; name: string }; project: { id: string; code: string; name: string };
assembly: { id: string; code: string; name: string }; assembly: { id: string; code: string; name: string };
@@ -121,9 +144,11 @@ export default function PartDetailClient({
operations: OperationRow[]; operations: OperationRow[];
machines: MachineOption[]; machines: MachineOption[];
templates: TemplateOption[]; templates: TemplateOption[];
qcRecords: QcRecordRow[];
}) { }) {
const router = useRouter(); const router = useRouter();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [dupeOpen, setDupeOpen] = useState(false);
return ( return (
<div className="mx-auto max-w-6xl px-4 py-8"> <div className="mx-auto max-w-6xl px-4 py-8">
@@ -167,6 +192,9 @@ export default function PartDetailClient({
> >
Print travelers (PDF) Print travelers (PDF)
</a> </a>
<Button variant="secondary" onClick={() => setDupeOpen(true)}>
Duplicate
</Button>
<Button variant="secondary" onClick={() => setEditOpen(true)}> <Button variant="secondary" onClick={() => setEditOpen(true)}>
Edit part Edit part
</Button> </Button>
@@ -231,6 +259,8 @@ export default function PartDetailClient({
onChange={() => router.refresh()} onChange={() => router.refresh()}
/> />
<QcHistorySection records={qcRecords} />
{editOpen && ( {editOpen && (
<EditPartModal <EditPartModal
part={part} part={part}
@@ -239,10 +269,179 @@ export default function PartDetailClient({
onDeleted={() => router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}`)} 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> </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 ----------------------------------------------------- // -------- Operations -----------------------------------------------------
function OperationsSection({ function OperationsSection({
@@ -261,6 +460,7 @@ function OperationsSection({
const [newOpen, setNewOpen] = useState(false); const [newOpen, setNewOpen] = useState(false);
const [edit, setEdit] = useState<OperationRow | null>(null); const [edit, setEdit] = useState<OperationRow | null>(null);
const [qrFor, setQrFor] = 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 [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -399,6 +599,14 @@ function OperationsSection({
> >
Print Print
</a> </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}> <Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
Edit Edit
</Button> </Button>
@@ -460,10 +668,275 @@ function OperationsSection({
/> />
)} )}
{qrFor && <QrModal operation={qrFor} onClose={() => setQrFor(null)} />} {qrFor && <QrModal operation={qrFor} onClose={() => setQrFor(null)} />}
{logsFor && (
<TimeLogsModal
operation={logsFor}
onClose={() => setLogsFor(null)}
onChange={() => {
onChange();
setLogsFor(null);
}}
/>
)}
</section> </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 }) { function QrModal({ operation, onClose }: { operation: OperationRow; onClose: () => void }) {
const [data, setData] = useState< const [data, setData] = useState<
{ dataUrl: string; scanUrl: string; token: string } | null { dataUrl: string; scanUrl: string; token: string } | null
@@ -31,6 +31,10 @@ export default async function AdminPartDetailPage({
include: { include: {
machine: { select: { id: true, name: true } }, machine: { select: { id: true, name: true } },
template: { select: { id: true, name: true } }, template: { select: { id: true, name: true } },
timeLogs: {
orderBy: { startedAt: "desc" },
include: { operator: { select: { id: true, name: true } } },
},
}, },
}, },
}, },
@@ -55,6 +59,20 @@ export default async function AdminPartDetailPage({
]); ]);
if (!part) notFound(); if (!part) notFound();
// QC history across every op on the part — newest first. Same data feeds
// two views: the "QC history" strip on the part page and the failure-digest
// on the admin dashboard. Keeping it to the last 50 per part is plenty for
// any realistic production run.
const qcRecords = await prisma.qCRecord.findMany({
where: { operation: { partId: part.id } },
orderBy: { createdAt: "desc" },
take: 50,
include: {
operator: { select: { id: true, name: true } },
operation: { select: { id: true, sequence: true, name: true } },
},
});
const fileView = (f: typeof part.stepFile) => const fileView = (f: typeof part.stepFile) =>
f f
? { ? {
@@ -99,6 +117,14 @@ export default async function AdminPartDetailPage({
plannedUnits: op.plannedUnits, plannedUnits: op.plannedUnits,
status: op.status, status: op.status,
qrToken: op.qrToken, qrToken: op.qrToken,
timeLogs: op.timeLogs.map((l) => ({
id: l.id,
startedAt: l.startedAt.toISOString(),
endedAt: l.endedAt ? l.endedAt.toISOString() : null,
unitsProcessed: l.unitsProcessed,
note: l.note,
operatorName: l.operator.name,
})),
}))} }))}
machines={machines} machines={machines}
templates={templates.map((t) => ({ templates={templates.map((t) => ({
@@ -109,6 +135,17 @@ export default async function AdminPartDetailPage({
defaultInstructions: t.defaultInstructions, defaultInstructions: t.defaultInstructions,
qcRequired: t.qcRequired, qcRequired: t.qcRequired,
}))} }))}
qcRecords={qcRecords.map((r) => ({
id: r.id,
kind: r.kind,
passed: r.passed,
notes: r.notes,
measurements: r.measurements,
createdAt: r.createdAt.toISOString(),
operatorName: r.operator.name,
operationSequence: r.operation.sequence,
operationName: r.operation.name,
}))}
/> />
); );
} }
@@ -0,0 +1,46 @@
import { type NextRequest } from "next/server";
import { ok, errorResponse, requireRole, parseJson } from "@/lib/api";
import { DuplicateAssemblySchema } from "@/lib/schemas";
import { audit } from "@/lib/audit";
import { clientIp } from "@/lib/request";
import { duplicateAssembly } from "@/lib/duplicate";
/**
* Admin-only. Clone an Assembly (plus every child Part, plus every
* Operation when includeOperations is true) into the same project under a
* new `code`. Useful when a new project needs the same assembly with minor
* tweaks — duplicate then edit rather than rebuilding from scratch.
*/
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const actor = await requireRole("admin");
const { id } = await ctx.params;
const body = await parseJson(req, DuplicateAssemblySchema);
const result = await duplicateAssembly({
sourceAssemblyId: id,
code: body.code,
name: body.name,
includeOperations: body.includeOperations ?? true,
});
await audit({
actorId: actor.id,
action: "duplicate",
entity: "Assembly",
entityId: result.id,
after: { sourceAssemblyId: id, ...result },
ipAddress: clientIp(req),
});
return ok(
{
assembly: { id: result.id },
partsCopied: result.partsCopied,
operationsCopied: result.operationsCopied,
},
{ status: 201 },
);
} catch (err) {
return errorResponse(err);
}
}
+42
View File
@@ -0,0 +1,42 @@
import { type NextRequest } from "next/server";
import { ok, errorResponse, requireRole, parseJson } from "@/lib/api";
import { DuplicatePartSchema } from "@/lib/schemas";
import { audit } from "@/lib/audit";
import { clientIp } from "@/lib/request";
import { duplicatePart } from "@/lib/duplicate";
/**
* Admin-only. Clone a Part into its existing assembly with a new `code`
* (and optionally a new `name`). By default every Operation is cloned too —
* with fresh qrTokens, reset to `pending` status and zero units. Useful for
* spinning up a left/right variant, or re-running a completed part.
*
* Not used for cross-assembly / cross-project moves — admin can edit the
* code / qty / notes on the copy afterwards.
*/
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const actor = await requireRole("admin");
const { id } = await ctx.params;
const body = await parseJson(req, DuplicatePartSchema);
const result = await duplicatePart({
sourcePartId: id,
code: body.code,
name: body.name,
includeOperations: body.includeOperations ?? true,
});
await audit({
actorId: actor.id,
action: "duplicate",
entity: "Part",
entityId: result.id,
after: { sourcePartId: id, ...result },
ipAddress: clientIp(req),
});
return ok({ part: { id: result.id }, operationsCopied: result.operationsCopied }, { status: 201 });
} catch (err) {
return errorResponse(err);
}
}
@@ -0,0 +1,184 @@
import { type NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { errorResponse, requireRole, ApiError } from "@/lib/api";
import { readFileBytes } from "@/lib/files";
import {
renderProjectTravelers,
type OperationCardData,
type PartCoverData,
} from "@/lib/pdf";
/**
* Admin-only. Bulk traveler PDF for an entire project — one megafile
* containing every part's cover + drawings + op cards, prefixed with a
* project-level summary sheet.
*
* Query params:
* ?scope=all (default) every part with at least one operation
* ?scope=incomplete only parts that still have pending / partial /
* in_progress / qc_failed ops (i.e. what's left to run)
*
* Parts with zero operations are always skipped — they'd render an empty
* card anyway. Drawings are inlined best-effort (missing files are logged
* and the PDF still renders).
*/
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
await requireRole("admin");
const { id } = await ctx.params;
const scope = (new URL(req.url).searchParams.get("scope") ?? "all").toLowerCase();
if (scope !== "all" && scope !== "incomplete") {
throw new ApiError(400, "bad_scope", "scope must be 'all' or 'incomplete'");
}
const project = await prisma.project.findUnique({
where: { id },
select: { id: true, code: true, name: true, customerCode: true, dueDate: true },
});
if (!project) throw new ApiError(404, "not_found", "Project not found");
const parts = await prisma.part.findMany({
where: {
assembly: { projectId: id },
...(scope === "incomplete"
? {
operations: {
some: {
status: { in: ["pending", "partial", "in_progress", "qc_failed"] },
},
},
}
: {}),
},
orderBy: [{ assembly: { code: "asc" } }, { code: "asc" }],
include: {
assembly: {
include: {
project: { select: { code: true, name: true } },
drawingFile: { select: { path: true, originalName: true } },
},
},
stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
drawingFile: {
select: { originalName: true, sizeBytes: true, sha256: true, path: true },
},
cutFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
operations: {
orderBy: { sequence: "asc" },
include: { machine: { select: { name: true, kind: true } } },
},
},
});
const eligible = parts.filter((p) => p.operations.length > 0);
if (eligible.length === 0) {
throw new ApiError(
400,
"no_parts",
scope === "incomplete"
? "No parts with outstanding operations in this project"
: "No parts with operations in this project",
);
}
const bundles = await Promise.all(
eligible.map(async (part) => {
const projectHead = { code: part.assembly.project.code, name: part.assembly.project.name };
const assembly = {
code: part.assembly.code,
name: part.assembly.name,
qty: part.assembly.qty,
};
const partHeader = {
code: part.code,
name: part.name,
material: part.material,
qty: part.qty,
};
const drawingPdfBytes = await tryReadPdf(part.drawingFile?.path ?? null);
const assemblyDrawingPdfBytes = await tryReadPdf(part.assembly.drawingFile?.path ?? null);
const cover: PartCoverData = {
project: projectHead,
assembly,
part: { ...partHeader, notes: part.notes },
files: [
{ label: "STEP / 3D", file: part.stepFile },
{ label: "Drawing PDF", file: part.drawingFile },
{ label: "Cut file", file: part.cutFile },
],
operations: part.operations.map((op) => ({
sequence: op.sequence,
name: op.name,
kind: op.kind,
machineName: op.machine?.name ?? null,
qcRequired: op.qcRequired,
qrToken: op.qrToken,
unitsCompleted: op.unitsCompleted,
status: op.status,
})),
};
const cards: OperationCardData[] = part.operations.map((op) => ({
project: projectHead,
assembly,
part: partHeader,
operation: {
id: op.id,
sequence: op.sequence,
name: op.name,
kind: op.kind,
qrToken: op.qrToken,
machineName: op.machine?.name ?? null,
machineKind: op.machine?.kind ?? null,
settings: op.settings,
materialNotes: op.materialNotes,
instructions: op.instructions,
qcRequired: op.qcRequired,
plannedMinutes: op.plannedMinutes,
plannedUnits: op.plannedUnits,
unitsCompleted: op.unitsCompleted,
status: op.status,
},
}));
return { cover, cards, drawingPdfBytes, assemblyDrawingPdfBytes };
}),
);
const pdf = await renderProjectTravelers({
project: {
code: project.code,
name: project.name,
customerCode: project.customerCode,
dueDate: project.dueDate,
},
bundles,
});
const suffix = scope === "incomplete" ? "-incomplete" : "";
const safeName = `${project.code}${suffix}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_");
return new NextResponse(pdf as unknown as BodyInit, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${safeName}"`,
"Cache-Control": "private, no-store",
},
});
} catch (err) {
return errorResponse(err);
}
}
async function tryReadPdf(path: string | null): Promise<Uint8Array | null> {
if (!path) return null;
try {
const buf = await readFileBytes(path);
return new Uint8Array(buf);
} catch (err) {
console.warn("[projects/travelers.pdf] could not read drawing file", { path, err });
return null;
}
}
+93
View File
@@ -0,0 +1,93 @@
import { type NextRequest } from "next/server";
import { prisma } from "@/lib/prisma";
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
import { UpdateTimeLogSchema } from "@/lib/schemas";
import { audit } from "@/lib/audit";
import { clientIp } from "@/lib/request";
/**
* Admin-only correction of a TimeLog row. Intended for "operator forgot to
* pause overnight" cleanup — plan-vs-actual hours reports are only as good
* as the data on the floor, and a 16-hour phantom entry is worse than a
* deleted one. We audit the before/after so the raw operator entry is still
* traceable.
*
* Does NOT mutate Operation.status or claims. If the op itself is stuck
* in_progress, the admin should use the existing release/close routes.
*/
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const actor = await requireRole("admin");
const { id } = await ctx.params;
const body = await parseJson(req, UpdateTimeLogSchema);
const before = await prisma.timeLog.findUnique({ where: { id } });
if (!before) throw new ApiError(404, "not_found", "Time log not found");
// Resolve the effective startedAt / endedAt we'd persist and reject obvious
// nonsense (endedAt before startedAt). Null endedAt is still allowed —
// reopening a log for the operator to close themselves is a legitimate
// undo of a premature admin-close.
const startedAt = body.startedAt ?? before.startedAt;
const endedAt =
body.endedAt !== undefined ? body.endedAt : before.endedAt;
if (endedAt !== null && endedAt < startedAt) {
throw new ApiError(
400,
"invalid_range",
"endedAt must be on or after startedAt",
);
}
const updated = await prisma.timeLog.update({
where: { id },
data: {
...(body.startedAt !== undefined ? { startedAt: body.startedAt } : {}),
...(body.endedAt !== undefined ? { endedAt: body.endedAt } : {}),
...(body.unitsProcessed !== undefined ? { unitsProcessed: body.unitsProcessed } : {}),
...(body.note !== undefined ? { note: body.note } : {}),
},
});
await audit({
actorId: actor.id,
action: "correct_timelog",
entity: "TimeLog",
entityId: id,
before,
after: updated,
ipAddress: clientIp(req),
});
return ok({ timeLog: updated });
} catch (err) {
return errorResponse(err);
}
}
/**
* Admin-only. Deletes a TimeLog row outright — reserve for obviously-bogus
* entries (duplicate scans, test pings). Note this does NOT walk back the
* operation's `unitsCompleted` counter; if a real unit count was logged
* you almost always want to PATCH to zero it out instead.
*/
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
try {
const actor = await requireRole("admin");
const { id } = await ctx.params;
const before = await prisma.timeLog.findUnique({ where: { id } });
if (!before) throw new ApiError(404, "not_found", "Time log not found");
await prisma.timeLog.delete({ where: { id } });
await audit({
actorId: actor.id,
action: "delete_timelog",
entity: "TimeLog",
entityId: id,
before,
ipAddress: clientIp(req),
});
return ok({ ok: true });
} catch (err) {
return errorResponse(err);
}
}
+66 -12
View File
@@ -8,28 +8,43 @@ import { prisma } from "@/lib/prisma";
* on?" Active claims are the headline list; below that we show a generic * on?" Active claims are the headline list; below that we show a generic
* "scan a card to start" hint so a fresh operator knows what to do. * "scan a card to start" hint so a fresh operator knows what to do.
*/ */
export default async function OperatorHomePage() { const OP_INCLUDE = {
const user = await requireOperator();
const claims = await prisma.operation.findMany({
where: { claimedByUserId: user.id, status: "in_progress" },
orderBy: { claimedAt: "desc" },
include: {
machine: { select: { name: true } }, machine: { select: { name: true } },
part: { part: {
select: { select: {
code: true, code: true,
name: true, name: true,
qty: true,
assembly: { assembly: {
select: { select: {
code: true, code: true,
qty: true,
project: { select: { code: true, name: true } }, project: { select: { code: true, name: true } },
}, },
}, },
}, },
}, },
}, } as const;
});
export default async function OperatorHomePage() {
const user = await requireOperator();
const [claims, resumable] = await Promise.all([
prisma.operation.findMany({
where: { claimedByUserId: user.id, status: "in_progress" },
orderBy: { claimedAt: "desc" },
include: OP_INCLUDE,
}),
// B1: partial ops are unclaimed after a release and only re-surface when
// someone physically re-scans the card. List them here so any operator
// can pick up where the last shift left off.
prisma.operation.findMany({
where: { status: "partial", claimedByUserId: null },
orderBy: { updatedAt: "desc" },
take: 50,
include: OP_INCLUDE,
}),
]);
return ( return (
<div className="mx-auto max-w-3xl px-4 py-6 space-y-6"> <div className="mx-auto max-w-3xl px-4 py-6 space-y-6">
@@ -40,11 +55,13 @@ export default async function OperatorHomePage() {
</p> </p>
</div> </div>
{claims.length === 0 ? ( {claims.length === 0 && resumable.length === 0 ? (
<div className="rounded-xl bg-white border border-slate-200 p-6 text-slate-600 text-sm text-center"> <div className="rounded-xl bg-white border border-slate-200 p-6 text-slate-600 text-sm text-center">
You have no active steps. Scan a traveler QR to begin. You have no active steps. Scan a traveler QR to begin.
</div> </div>
) : ( ) : null}
{claims.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wide"> <h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wide">
Active ({claims.length}) Active ({claims.length})
@@ -70,7 +87,44 @@ export default async function OperatorHomePage() {
</Link> </Link>
))} ))}
</div> </div>
)} ) : null}
{resumable.length > 0 ? (
<div className="space-y-3">
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wide">
Resumable ({resumable.length})
</h2>
<p className="text-xs text-slate-500 -mt-1">
Previously started and released with units still to run. Tap to pick up where the last shift left off.
</p>
{resumable.map((c) => {
const total = c.part.qty * c.part.assembly.qty;
const remaining = Math.max(total - c.unitsCompleted, 0);
return (
<Link
key={c.id}
href={`/op/scan/${c.qrToken}`}
className="block rounded-xl bg-white border border-amber-300 p-4 hover:border-amber-500 hover:shadow-sm transition"
>
<div className="text-xs text-slate-500">
{c.part.assembly.project.code} · {c.part.assembly.code}
</div>
<div className="font-medium mt-0.5">{c.part.name}</div>
<div className="text-sm text-slate-700 mt-1">
Step {c.sequence}: {c.name}
</div>
<div className="text-xs text-slate-600 mt-1 flex flex-wrap gap-x-3">
<span className="font-mono">{c.part.code}</span>
{c.machine ? <span>{c.machine.name}</span> : null}
<span className="text-amber-700 font-medium">
{c.unitsCompleted} of {total} done · {remaining} remaining
</span>
</div>
</Link>
);
})}
</div>
) : null}
</div> </div>
); );
} }
+6 -1
View File
@@ -32,12 +32,17 @@ Items that came out of floor-testing, not in the original roadmap.
| A7 | "Done" button auto-detects partial: if typed units < remaining, step ends `partial` and releases claim instead of locking `completed` | **done** | | A7 | "Done" button auto-detects partial: if typed units < remaining, step ends `partial` and releases claim instead of locking `completed` | **done** |
| A8 | QC fail workflow: `kind="qc"` dedicated inspection steps + `qc_failed` status blocks reclaim until admin hits qc-reset (landed together with Step 9) | **done** | | A8 | QC fail workflow: `kind="qc"` dedicated inspection steps + `qc_failed` status blocks reclaim until admin hits qc-reset (landed together with Step 9) | **done** |
| A9 | Progress + live status on traveler cover + op cards (`X of Y done` driven by `unitsCompleted`, status pill matching the UI) — reprints reflect reality | **done** | | A9 | Progress + live status on traveler cover + op cards (`X of Y done` driven by `unitsCompleted`, status pill matching the UI) — reprints reflect reality | **done** |
| A10 | Operator `/op` dashboard lists **resumable** ops (`status = "partial"` with no current claim) so any operator can pick up where the last shift left off (landed as B1) | **done** |
| A11 | Part + Assembly duplication — clone into the same parent with a new code. Operations get fresh QR tokens and reset to pending; file attachments are re-referenced (content-addressed) | **done** |
| A12 | QC history view on the part detail page — cross-operation log of every QCRecord (pass/fail, operator, notes, measurements) | **done** |
| A13 | Admin TimeLog correction — per-op "Logs" modal lets admins edit startedAt / endedAt / unitsProcessed / note on a stale entry (or delete it outright). Audited as `correct_timelog` / `delete_timelog` | **done** |
| A14 | Bulk project travelers PDF — one megafile for every part in a project (or just the ones with outstanding ops). Prefixed with a project-level summary sheet | **done** |
### Planned ### Planned
| Step | What | Notes | | Step | What | Notes |
| ---- | ---- | ----- | | ---- | ---- | ----- |
| B1 | Operator `/op` dashboard lists **resumable** ops (`status = "partial"` with no current claim) | Needed because A5+A7 mean partial ops become unclaimed — they currently only re-surface when someone physically re-scans the card | | ~~B1~~ | ~~Operator `/op` dashboard lists **resumable** ops~~ | Landed as A10 — second section on `/op` with amber border, shows `unitsCompleted / total · remaining`, tap to scan |
| ~~B2~~ | ~~Progress column on traveler cover + op cards~~ | Landed as A9 — cover sublines + op-card "Progress" meta row + live status pill on both | | ~~B2~~ | ~~Progress column on traveler cover + op cards~~ | Landed as A9 — cover sublines + op-card "Progress" meta row + live status pill on both |
| ~~B3~~ | ~~QC fail workflow on close~~ | Landed as A8 alongside Step 9 — `qc_failed` locks the step; admin `qc-reset` rolls it back to `pending`/`partial` based on `unitsCompleted` | | ~~B3~~ | ~~QC fail workflow on close~~ | Landed as A8 alongside Step 9 — `qc_failed` locks the step; admin `qc-reset` rolls it back to `pending`/`partial` based on `unitsCompleted` |
+178
View File
@@ -0,0 +1,178 @@
import { prisma } from "@/lib/prisma";
import { ApiError } from "@/lib/api";
import { generateQrToken } from "@/lib/qr";
/**
* Clone a part's operations under a new partId. Each op gets a fresh qrToken
* and is reset to `pending` with a zero unit count — the copy has never been
* worked on even if the source had partial/completed steps. Claims, time logs,
* and QC records are intentionally NOT carried over: they belong to the
* original's history.
*/
async function cloneOperationsForPart(
sourcePartId: string,
destPartId: string,
): Promise<number> {
const sourceOps = await prisma.operation.findMany({
where: { partId: sourcePartId },
orderBy: { sequence: "asc" },
});
for (const op of sourceOps) {
// Collision-retry for qrToken; 192 bits of entropy, same pattern as
// the create-op route.
let qrToken = generateQrToken();
for (let attempt = 0; attempt < 5; attempt++) {
const existing = await prisma.operation.findUnique({
where: { qrToken },
select: { id: true },
});
if (!existing) break;
qrToken = generateQrToken();
if (attempt === 4)
throw new ApiError(500, "qr_collision", "Unable to allocate QR token");
}
await prisma.operation.create({
data: {
partId: destPartId,
sequence: op.sequence,
templateId: op.templateId,
name: op.name,
kind: op.kind,
machineId: op.machineId,
settings: op.settings,
materialNotes: op.materialNotes,
instructions: op.instructions,
qcRequired: op.qcRequired,
plannedMinutes: op.plannedMinutes,
plannedUnits: op.plannedUnits,
qrToken,
// fresh copy — no prior work, no claim, no status
status: "pending",
unitsCompleted: 0,
},
});
}
return sourceOps.length;
}
/**
* Clone a Part into its existing assembly. Returns the new part's id.
* Operations are copied unless `includeOperations` is false. File
* attachments are re-referenced by id — FileAsset rows are content-addressed
* and shared.
*
* The caller must check that `code` is unique within the destination assembly
* before calling; we re-check inside for safety and turn Prisma's unique-
* constraint violation into a 409.
*/
export async function duplicatePart(opts: {
sourcePartId: string;
code: string;
name?: string;
includeOperations: boolean;
}): Promise<{ id: string; operationsCopied: number }> {
const source = await prisma.part.findUnique({
where: { id: opts.sourcePartId },
});
if (!source) throw new ApiError(404, "not_found", "Part not found");
const conflict = await prisma.part.findUnique({
where: { assemblyId_code: { assemblyId: source.assemblyId, code: opts.code } },
select: { id: true },
});
if (conflict)
throw new ApiError(409, "code_taken", `Part code ${opts.code} already in use in this assembly`);
const created = await prisma.part.create({
data: {
assemblyId: source.assemblyId,
code: opts.code,
name: opts.name ?? source.name,
material: source.material,
qty: source.qty,
notes: source.notes,
// Re-attach the same FileAssets — storage is content-addressed, no copy needed.
stepFileId: source.stepFileId,
drawingFileId: source.drawingFileId,
cutFileId: source.cutFileId,
thumbnailFileId: source.thumbnailFileId,
},
});
const operationsCopied = opts.includeOperations
? await cloneOperationsForPart(source.id, created.id)
: 0;
return { id: created.id, operationsCopied };
}
/**
* Clone an Assembly (plus every child Part, plus — optionally — every
* Operation) into the same project. Returns the new assembly's id and a
* summary count. Part codes and operation sequences are preserved since
* they were unique within their parent in the source and will remain unique
* within the new parent. Only the assembly's own `code` might clash with a
* sibling in the project, so we check that up front.
*/
export async function duplicateAssembly(opts: {
sourceAssemblyId: string;
code: string;
name?: string;
includeOperations: boolean;
}): Promise<{ id: string; partsCopied: number; operationsCopied: number }> {
const source = await prisma.assembly.findUnique({
where: { id: opts.sourceAssemblyId },
include: { parts: { select: { id: true } } },
});
if (!source) throw new ApiError(404, "not_found", "Assembly not found");
const conflict = await prisma.assembly.findUnique({
where: { projectId_code: { projectId: source.projectId, code: opts.code } },
select: { id: true },
});
if (conflict)
throw new ApiError(409, "code_taken", `Assembly code ${opts.code} already in use in this project`);
const newAssembly = await prisma.assembly.create({
data: {
projectId: source.projectId,
code: opts.code,
name: opts.name ?? source.name,
qty: source.qty,
notes: source.notes,
stepFileId: source.stepFileId,
drawingFileId: source.drawingFileId,
cutFileId: source.cutFileId,
},
});
let operationsCopied = 0;
let partsCopied = 0;
for (const sourcePart of source.parts) {
const full = await prisma.part.findUnique({ where: { id: sourcePart.id } });
if (!full) continue;
const clonedPart = await prisma.part.create({
data: {
assemblyId: newAssembly.id,
code: full.code,
name: full.name,
material: full.material,
qty: full.qty,
notes: full.notes,
stepFileId: full.stepFileId,
drawingFileId: full.drawingFileId,
cutFileId: full.cutFileId,
thumbnailFileId: full.thumbnailFileId,
},
});
partsCopied++;
if (opts.includeOperations) {
operationsCopied += await cloneOperationsForPart(full.id, clonedPart.id);
}
}
return { id: newAssembly.id, partsCopied, operationsCopied };
}
+151 -7
View File
@@ -131,26 +131,170 @@ export async function renderPartTravelers(payload: {
}): Promise<Uint8Array> { }): Promise<Uint8Array> {
const doc = await PDFDocument.create(); const doc = await PDFDocument.create();
const fonts = await embedFonts(doc); const fonts = await embedFonts(doc);
await appendPartBundle(doc, fonts, payload);
return doc.save();
}
/** Entry point: one combined PDF for an entire project — every part bundle
* concatenated (cover → drawings → op cards, repeated). Prefixed with a short
* project-level summary page so the binder has a front sheet. Useful for
* bulk reprints when a run changes.
*/
export async function renderProjectTravelers(payload: {
project: {
code: string;
name: string;
customerCode: string | null;
dueDate: Date | null;
};
bundles: {
cover: PartCoverData;
cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
}[];
}): Promise<Uint8Array> {
const doc = await PDFDocument.create();
const fonts = await embedFonts(doc);
const summaryPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
drawProjectSummary(summaryPage, fonts, payload.project, payload.bundles);
for (const bundle of payload.bundles) {
await appendPartBundle(doc, fonts, bundle);
}
return doc.save();
}
/** Append one part's cover + drawings + op cards onto an existing doc. */
async function appendPartBundle(
doc: PDFDocument,
fonts: Fonts,
bundle: {
cover: PartCoverData;
cards: OperationCardData[];
drawingPdfBytes?: Uint8Array | null;
assemblyDrawingPdfBytes?: Uint8Array | null;
},
): Promise<void> {
const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); const coverPage = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawCoverSheet(doc, coverPage, fonts, payload.cover); await drawCoverSheet(doc, coverPage, fonts, bundle.cover);
// Inline the assembly-level drawing first, then the part drawing. Both are // Inline the assembly-level drawing first, then the part drawing. Both are
// optional. We swallow per-PDF errors so a corrupt drawing doesn't block // optional. We swallow per-PDF errors so a corrupt drawing doesn't block
// the op cards from printing. // the op cards from printing.
if (payload.assemblyDrawingPdfBytes) { if (bundle.assemblyDrawingPdfBytes) {
await appendPdfPages(doc, payload.assemblyDrawingPdfBytes, "assembly drawing"); await appendPdfPages(doc, bundle.assemblyDrawingPdfBytes, "assembly drawing");
} }
if (payload.drawingPdfBytes) { if (bundle.drawingPdfBytes) {
await appendPdfPages(doc, payload.drawingPdfBytes, "part drawing"); await appendPdfPages(doc, bundle.drawingPdfBytes, "part drawing");
} }
for (const card of payload.cards) { for (const card of bundle.cards) {
const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]); const page = doc.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
await drawOperationCard(doc, page, fonts, card); await drawOperationCard(doc, page, fonts, card);
} }
}
return doc.save(); // Project-level front sheet. Deliberately minimal — the real detail lives on
// each part's own cover sheet further in. Shows project header, a totals
// line, and a table of contents listing every part being included.
function drawProjectSummary(
page: PDFPage,
fonts: Fonts,
project: { code: string; name: string; customerCode: string | null; dueDate: Date | null },
bundles: { cover: PartCoverData; cards: OperationCardData[] }[],
): void {
const cursor: Cursor = { top: PAGE_HEIGHT - MARGIN };
const contentWidth = PAGE_WIDTH - MARGIN * 2;
drawText(page, "PROJECT TRAVELERS", {
x: MARGIN,
y: cursor.top - 10,
font: fonts.bold,
size: 10,
color: rgb(0.4, 0.4, 0.45),
});
cursor.top -= 28;
drawText(page, `${project.code}${project.name}`, {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 20,
});
cursor.top -= 20;
const metaBits = [
project.customerCode ? `Customer ${project.customerCode}` : null,
project.dueDate ? `Due ${project.dueDate.toISOString().slice(0, 10)}` : null,
`${bundles.length} part${bundles.length === 1 ? "" : "s"}`,
`${bundles.reduce((acc, b) => acc + b.cards.length, 0)} operations total`,
]
.filter(Boolean)
.join(" · ");
drawText(page, metaBits, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 11,
color: rgb(0.35, 0.4, 0.5),
});
cursor.top -= 20;
page.drawLine({
start: { x: MARGIN, y: cursor.top },
end: { x: PAGE_WIDTH - MARGIN, y: cursor.top },
thickness: 0.75,
color: rgb(0.8, 0.82, 0.88),
});
cursor.top -= 18;
drawText(page, "CONTENTS", {
x: MARGIN,
y: cursor.top,
font: fonts.bold,
size: 9,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 14;
// Simple TOC. Long projects (50+ parts) will overflow the page; we cap the
// visible rows and footnote the rest rather than spilling into a multi-page
// TOC — the goal here is a summary, not a catalogue.
const maxRows = 38;
const rows = bundles.slice(0, maxRows);
for (const b of rows) {
const label = `${b.cover.assembly.code} · ${b.cover.part.code}${b.cover.part.name}`;
const line = wrapLines(label, fonts.regular, 10, contentWidth - 60)[0] ?? label;
drawText(page, line, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
});
const opsLabel = `${b.cards.length} op${b.cards.length === 1 ? "" : "s"}`;
const opsLabelW = fonts.regular.widthOfTextAtSize(opsLabel, 10);
drawText(page, opsLabel, {
x: PAGE_WIDTH - MARGIN - opsLabelW,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.45, 0.5, 0.6),
});
cursor.top -= 13;
}
if (bundles.length > maxRows) {
cursor.top -= 4;
drawText(page, `… and ${bundles.length - maxRows} more part${bundles.length - maxRows === 1 ? "" : "s"}.`, {
x: MARGIN,
y: cursor.top,
font: fonts.regular,
size: 10,
color: rgb(0.45, 0.5, 0.6),
});
}
} }
// Load an external PDF's pages and copy them into `doc`. Best-effort: if the // Load an external PDF's pages and copy them into `doc`. Best-effort: if the
+30
View File
@@ -203,6 +203,23 @@ export const UpdatePartSchema = z
}) })
.strict(); .strict();
// Clone a part (or whole assembly) into the same parent. Operations are
// re-seeded with fresh QR tokens and `status: "pending"`, `unitsCompleted: 0`
// — travelers printed for the copy need new QRs or they'd collide with the
// original. includeOperations=false gives you just the skeleton if the recipe
// is being reworked.
export const DuplicatePartSchema = z.object({
code: Code,
name: NonEmpty.optional(),
includeOperations: z.boolean().default(true).optional(),
});
export const DuplicateAssemblySchema = z.object({
code: Code,
name: NonEmpty.optional(),
includeOperations: z.boolean().default(true).optional(),
});
// ---- operations --------------------------------------------------------- // ---- operations ---------------------------------------------------------
// "partial" = an operation that was started, had units logged, and then paused. // "partial" = an operation that was started, had units logged, and then paused.
@@ -347,6 +364,19 @@ export const ReceivePOSchema = z.object({
.min(1), .min(1),
}); });
// Admin correction of a time log. Most common use: the operator forgot to
// pause overnight so endedAt is null (or absurdly late) and plan-vs-actual
// reporting is poisoned. We allow startedAt nudges too for paper-log
// backfills. The route enforces endedAt >= startedAt and audits the diff.
export const UpdateTimeLogSchema = z
.object({
startedAt: z.coerce.date().optional(),
endedAt: z.coerce.date().nullable().optional(),
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText,
})
.strict();
export const ReleaseOperationSchema = z.object({ export const ReleaseOperationSchema = z.object({
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(), unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText, note: OptionalText,
+1 -1
View File
File diff suppressed because one or more lines are too long