This commit is contained in:
@@ -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,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user