This commit is contained in:
@@ -98,6 +98,24 @@ export default function ProjectDetailClient({
|
||||
}
|
||||
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)}>
|
||||
Edit project
|
||||
</Button>
|
||||
|
||||
@@ -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 (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
@@ -103,6 +104,9 @@ export default function AssemblyDetailClient({
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setDupeOpen(true)}>
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setEditOpen(true)}>
|
||||
Edit assembly
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
assembly,
|
||||
onClose,
|
||||
|
||||
@@ -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'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'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
|
||||
|
||||
@@ -31,6 +31,10 @@ export default async function AdminPartDetailPage({
|
||||
include: {
|
||||
machine: { 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();
|
||||
|
||||
// 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) =>
|
||||
f
|
||||
? {
|
||||
@@ -99,6 +117,14 @@ export default async function AdminPartDetailPage({
|
||||
plannedUnits: op.plannedUnits,
|
||||
status: op.status,
|
||||
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}
|
||||
templates={templates.map((t) => ({
|
||||
@@ -109,6 +135,17 @@ export default async function AdminPartDetailPage({
|
||||
defaultInstructions: t.defaultInstructions,
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -8,28 +8,43 @@ import { prisma } from "@/lib/prisma";
|
||||
* 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.
|
||||
*/
|
||||
export default async function OperatorHomePage() {
|
||||
const user = await requireOperator();
|
||||
|
||||
const claims = await prisma.operation.findMany({
|
||||
where: { claimedByUserId: user.id, status: "in_progress" },
|
||||
orderBy: { claimedAt: "desc" },
|
||||
include: {
|
||||
const OP_INCLUDE = {
|
||||
machine: { select: { name: true } },
|
||||
part: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
qty: true,
|
||||
assembly: {
|
||||
select: {
|
||||
code: true,
|
||||
qty: 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 (
|
||||
<div className="mx-auto max-w-3xl px-4 py-6 space-y-6">
|
||||
@@ -40,11 +55,13 @@ export default async function OperatorHomePage() {
|
||||
</p>
|
||||
</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">
|
||||
You have no active steps. Scan a traveler QR to begin.
|
||||
</div>
|
||||
) : (
|
||||
) : null}
|
||||
|
||||
{claims.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wide">
|
||||
Active ({claims.length})
|
||||
@@ -70,7 +87,44 @@ export default async function OperatorHomePage() {
|
||||
</Link>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
+6
-1
@@ -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** |
|
||||
| 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** |
|
||||
| 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
|
||||
|
||||
| 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 |
|
||||
| ~~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` |
|
||||
|
||||
|
||||
@@ -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
@@ -131,26 +131,170 @@ export async function renderPartTravelers(payload: {
|
||||
}): Promise<Uint8Array> {
|
||||
const doc = await PDFDocument.create();
|
||||
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]);
|
||||
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
|
||||
// optional. We swallow per-PDF errors so a corrupt drawing doesn't block
|
||||
// the op cards from printing.
|
||||
if (payload.assemblyDrawingPdfBytes) {
|
||||
await appendPdfPages(doc, payload.assemblyDrawingPdfBytes, "assembly drawing");
|
||||
if (bundle.assemblyDrawingPdfBytes) {
|
||||
await appendPdfPages(doc, bundle.assemblyDrawingPdfBytes, "assembly drawing");
|
||||
}
|
||||
if (payload.drawingPdfBytes) {
|
||||
await appendPdfPages(doc, payload.drawingPdfBytes, "part drawing");
|
||||
if (bundle.drawingPdfBytes) {
|
||||
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]);
|
||||
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
|
||||
|
||||
@@ -203,6 +203,23 @@ export const UpdatePartSchema = z
|
||||
})
|
||||
.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 ---------------------------------------------------------
|
||||
|
||||
// "partial" = an operation that was started, had units logged, and then paused.
|
||||
@@ -347,6 +364,19 @@ export const ReceivePOSchema = z.object({
|
||||
.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({
|
||||
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
|
||||
note: OptionalText,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user