@@ -11,7 +11,11 @@
|
|||||||
"Bash(npm run *)",
|
"Bash(npm run *)",
|
||||||
"Bash(node scripts/copy-viewer-assets.mjs)",
|
"Bash(node scripts/copy-viewer-assets.mjs)",
|
||||||
"Bash(npx prisma *)",
|
"Bash(npx prisma *)",
|
||||||
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name add_part_thumbnail)"
|
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name add_part_thumbnail)",
|
||||||
|
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate dev --name assembly_files_and_partial_state --create-only)",
|
||||||
|
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma migrate deploy)",
|
||||||
|
"Bash(DATABASE_URL=\"file:./data/app.db\" npx prisma generate)",
|
||||||
|
"Bash(DATABASE_URL=\"file:./data/app.db\" npm run build)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -16,12 +16,31 @@ import {
|
|||||||
} from "@/components/ui";
|
} from "@/components/ui";
|
||||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||||
|
|
||||||
|
interface FileView {
|
||||||
|
id: string;
|
||||||
|
originalName: string;
|
||||||
|
sizeBytes: number;
|
||||||
|
kind: string;
|
||||||
|
mimeType: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
interface AssemblyInfo {
|
interface AssemblyInfo {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
qty: number;
|
qty: number;
|
||||||
notes: string | null;
|
notes: string | null;
|
||||||
|
stepFile: FileView | null;
|
||||||
|
drawingFile: FileView | null;
|
||||||
|
cutFile: FileView | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssemblySlot = "stepFileId" | "drawingFileId" | "cutFileId";
|
||||||
|
|
||||||
|
function formatBytes(n: number) {
|
||||||
|
if (n < 1024) return `${n} B`;
|
||||||
|
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProjectInfo {
|
interface ProjectInfo {
|
||||||
@@ -97,6 +116,43 @@ export default function AssemblyDetailClient({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<section className="mb-6">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Assembly files</h2>
|
||||||
|
<p className="text-xs text-slate-500 mb-3">
|
||||||
|
Shared across every part in this assembly — overall STEP model, assembly drawing,
|
||||||
|
welding/fit-up layouts, etc. These also appear as quick-links on every traveler scan page.
|
||||||
|
</p>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<AssemblyFileSlot
|
||||||
|
assemblyId={assembly.id}
|
||||||
|
label="STEP / 3D"
|
||||||
|
description="Full assembly 3D model."
|
||||||
|
accept=".step,.stp"
|
||||||
|
slot="stepFileId"
|
||||||
|
file={assembly.stepFile}
|
||||||
|
onChange={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<AssemblyFileSlot
|
||||||
|
assemblyId={assembly.id}
|
||||||
|
label="Drawing (PDF)"
|
||||||
|
description="Assembly drawing / weldment print."
|
||||||
|
accept=".pdf"
|
||||||
|
slot="drawingFileId"
|
||||||
|
file={assembly.drawingFile}
|
||||||
|
onChange={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
<AssemblyFileSlot
|
||||||
|
assemblyId={assembly.id}
|
||||||
|
label="Cut / layout"
|
||||||
|
description="DXF / SVG layout for the assembly."
|
||||||
|
accept=".dxf,.svg"
|
||||||
|
slot="cutFileId"
|
||||||
|
file={assembly.cutFile}
|
||||||
|
onChange={() => router.refresh()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
<thead className="bg-slate-50 text-left text-slate-600 border-b border-slate-200">
|
||||||
@@ -358,3 +414,138 @@ function NewPartModal({
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -------- Assembly file slot --------------------------------------------
|
||||||
|
// Small variant of the FileSlot used on the part detail page — uploads a file
|
||||||
|
// via /api/v1/files, then PATCHes the assembly with the new ID. Null-id
|
||||||
|
// detaches. Refresh is delegated to the caller (router.refresh()).
|
||||||
|
function AssemblyFileSlot({
|
||||||
|
assemblyId,
|
||||||
|
label,
|
||||||
|
description,
|
||||||
|
accept,
|
||||||
|
slot,
|
||||||
|
file,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
assemblyId: string;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
accept: string;
|
||||||
|
slot: AssemblySlot;
|
||||||
|
file: FileView | null;
|
||||||
|
onChange: () => void;
|
||||||
|
}) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
async function upload(f: File) {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", f);
|
||||||
|
const uploaded = await apiFetch<{ file: { id: string } }>("/api/v1/files", {
|
||||||
|
method: "POST",
|
||||||
|
body: form,
|
||||||
|
});
|
||||||
|
await apiFetch(`/api/v1/assemblies/${assemblyId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ [slot]: uploaded.file.id }),
|
||||||
|
});
|
||||||
|
onChange();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiClientError ? err.message : "Upload failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detach() {
|
||||||
|
if (!confirm(`Detach ${label}?`)) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await apiFetch(`/api/v1/assemblies/${assemblyId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ [slot]: null }),
|
||||||
|
});
|
||||||
|
onChange();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiClientError ? err.message : "Detach failed");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold">{label}</h3>
|
||||||
|
<p className="text-xs text-slate-500">{description}</p>
|
||||||
|
</div>
|
||||||
|
{file ? (
|
||||||
|
<div className="rounded-md border border-slate-200 bg-slate-50 p-3 text-sm">
|
||||||
|
<div className="font-medium truncate" title={file.originalName}>
|
||||||
|
{file.originalName}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 mt-0.5">
|
||||||
|
{formatBytes(file.sizeBytes)} · {file.kind}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<a
|
||||||
|
href={`/api/v1/files/${file.id}/download`}
|
||||||
|
className="text-xs text-blue-600 hover:underline"
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={detach}
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs text-red-600 hover:underline disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Detach
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
className="text-xs text-slate-600 hover:underline disabled:opacity-50 ml-auto"
|
||||||
|
>
|
||||||
|
Replace
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-md border border-dashed border-slate-300 p-4 text-center">
|
||||||
|
<p className="text-xs text-slate-500 mb-2">No file attached.</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
{busy ? "Uploading…" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept={accept}
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) upload(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ErrorBanner message={error} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export default async function AdminAssemblyDetailPage({
|
|||||||
where: { id: assemblyId, projectId: id },
|
where: { id: assemblyId, projectId: id },
|
||||||
include: {
|
include: {
|
||||||
project: { select: { id: true, code: true, name: true } },
|
project: { select: { id: true, code: true, name: true } },
|
||||||
|
stepFile: true,
|
||||||
|
drawingFile: true,
|
||||||
|
cutFile: true,
|
||||||
parts: {
|
parts: {
|
||||||
orderBy: { code: "asc" },
|
orderBy: { code: "asc" },
|
||||||
include: {
|
include: {
|
||||||
@@ -28,6 +31,17 @@ export default async function AdminAssemblyDetailPage({
|
|||||||
});
|
});
|
||||||
if (!assembly) notFound();
|
if (!assembly) notFound();
|
||||||
|
|
||||||
|
const fileView = (f: typeof assembly.stepFile) =>
|
||||||
|
f
|
||||||
|
? {
|
||||||
|
id: f.id,
|
||||||
|
originalName: f.originalName,
|
||||||
|
sizeBytes: f.sizeBytes,
|
||||||
|
kind: f.kind,
|
||||||
|
mimeType: f.mimeType,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AssemblyDetailClient
|
<AssemblyDetailClient
|
||||||
project={assembly.project}
|
project={assembly.project}
|
||||||
@@ -37,6 +51,9 @@ export default async function AdminAssemblyDetailPage({
|
|||||||
name: assembly.name,
|
name: assembly.name,
|
||||||
qty: assembly.qty,
|
qty: assembly.qty,
|
||||||
notes: assembly.notes,
|
notes: assembly.notes,
|
||||||
|
stepFile: fileView(assembly.stepFile),
|
||||||
|
drawingFile: fileView(assembly.drawingFile),
|
||||||
|
cutFile: fileView(assembly.cutFile),
|
||||||
}}
|
}}
|
||||||
parts={assembly.parts.map((p) => ({
|
parts={assembly.parts.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
|
|||||||
@@ -87,12 +87,14 @@ 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"> = {
|
||||||
pending: "slate",
|
pending: "slate",
|
||||||
in_progress: "blue",
|
in_progress: "blue",
|
||||||
|
partial: "amber",
|
||||||
completed: "green",
|
completed: "green",
|
||||||
};
|
};
|
||||||
|
|
||||||
const STATUS_LABEL: Record<string, string> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
pending: "Pending",
|
pending: "Pending",
|
||||||
in_progress: "In progress",
|
in_progress: "In progress",
|
||||||
|
partial: "Partial",
|
||||||
completed: "Completed",
|
completed: "Completed",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -671,6 +673,7 @@ function OperationModal({
|
|||||||
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
|
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||||
<option value="pending">Pending</option>
|
<option value="pending">Pending</option>
|
||||||
<option value="in_progress">In progress</option>
|
<option value="in_progress">In progress</option>
|
||||||
|
<option value="partial">Partial</option>
|
||||||
<option value="completed">Completed</option>
|
<option value="completed">Completed</option>
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: strin
|
|||||||
...(body.name !== undefined ? { name: body.name } : {}),
|
...(body.name !== undefined ? { name: body.name } : {}),
|
||||||
...(body.qty !== undefined ? { qty: body.qty } : {}),
|
...(body.qty !== undefined ? { qty: body.qty } : {}),
|
||||||
...(body.notes !== undefined ? { notes: body.notes } : {}),
|
...(body.notes !== undefined ? { notes: body.notes } : {}),
|
||||||
|
...(body.stepFileId !== undefined ? { stepFileId: body.stepFileId } : {}),
|
||||||
|
...(body.drawingFileId !== undefined ? { drawingFileId: body.drawingFileId } : {}),
|
||||||
|
...(body.cutFileId !== undefined ? { cutFileId: body.cutFileId } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await audit({
|
await audit({
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
select: {
|
select: {
|
||||||
code: true,
|
code: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
qty: true,
|
||||||
project: { select: { code: true, name: true } },
|
project: { select: { code: true, name: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -39,7 +40,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
|
|
||||||
const data: OperationCardData = {
|
const data: OperationCardData = {
|
||||||
project: op.part.assembly.project,
|
project: op.part.assembly.project,
|
||||||
assembly: { code: op.part.assembly.code, name: op.part.assembly.name },
|
assembly: {
|
||||||
|
code: op.part.assembly.code,
|
||||||
|
name: op.part.assembly.name,
|
||||||
|
qty: op.part.assembly.qty,
|
||||||
|
},
|
||||||
part: {
|
part: {
|
||||||
code: op.part.code,
|
code: op.part.code,
|
||||||
name: op.part.name,
|
name: op.part.name,
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// `partial` behaves exactly like `pending` for claim purposes — it just
|
||||||
|
// means a previous operator paused with some units done. Either is fair
|
||||||
|
// game to resume.
|
||||||
const updateResult = await prisma.operation.updateMany({
|
const updateResult = await prisma.operation.updateMany({
|
||||||
where: { id, claimedByUserId: null, status: "pending" },
|
where: { id, claimedByUserId: null, status: { in: ["pending", "partial"] } },
|
||||||
data: {
|
data: {
|
||||||
status: "in_progress",
|
status: "in_progress",
|
||||||
claimedByUserId: actor.id,
|
claimedByUserId: actor.id,
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// unitsCompleted is cumulative across pause/resume cycles; on close we
|
||||||
|
// add this session's batch so the total reflects everything the step
|
||||||
|
// actually produced.
|
||||||
|
const units = body.unitsProcessed ?? 0;
|
||||||
await tx.operation.update({
|
await tx.operation.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -73,6 +77,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
completedAt: now,
|
completedAt: now,
|
||||||
claimedByUserId: null,
|
claimedByUserId: null,
|
||||||
claimedAt: null,
|
claimedAt: null,
|
||||||
|
...(units > 0 ? { unitsCompleted: { increment: units } } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,12 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
throw new ApiError(409, "op_not_active", "Step is not active");
|
throw new ApiError(409, "op_not_active", "Step is not active");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the operator logged any units during this session we flip status to
|
||||||
|
// `partial` (instead of `pending`) so the scan card can say "Resume this
|
||||||
|
// step" and the counter survives across pauses.
|
||||||
|
const units = body.unitsProcessed ?? 0;
|
||||||
|
const nextStatus: "pending" | "partial" = units > 0 ? "partial" : "pending";
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
// Close the most recent open TimeLog for (op, operator). We accept that
|
// Close the most recent open TimeLog for (op, operator). We accept that
|
||||||
@@ -50,7 +56,12 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
}
|
}
|
||||||
await tx.operation.update({
|
await tx.operation.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: { status: "pending", claimedByUserId: null, claimedAt: null },
|
data: {
|
||||||
|
status: nextStatus,
|
||||||
|
claimedByUserId: null,
|
||||||
|
claimedAt: null,
|
||||||
|
...(units > 0 ? { unitsCompleted: { increment: units } } : {}),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -60,7 +71,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
action: "release_op",
|
action: "release_op",
|
||||||
entity: "Operation",
|
entity: "Operation",
|
||||||
entityId: id,
|
entityId: id,
|
||||||
after: { status: "pending", unitsProcessed: body.unitsProcessed ?? null },
|
after: { status: nextStatus, unitsProcessed: body.unitsProcessed ?? null },
|
||||||
ipAddress: clientIp(req),
|
ipAddress: clientIp(req),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { type NextRequest, NextResponse } from "next/server";
|
import { type NextRequest, NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { errorResponse, requireRole, ApiError } from "@/lib/api";
|
import { errorResponse, requireRole, ApiError } from "@/lib/api";
|
||||||
|
import { readFileBytes } from "@/lib/files";
|
||||||
import {
|
import {
|
||||||
renderPartTravelers,
|
renderPartTravelers,
|
||||||
type OperationCardData,
|
type OperationCardData,
|
||||||
@@ -24,10 +25,15 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
include: {
|
||||||
assembly: {
|
assembly: {
|
||||||
include: { project: { select: { code: true, name: true } } },
|
include: {
|
||||||
|
project: { select: { code: true, name: true } },
|
||||||
|
drawingFile: { select: { path: true, originalName: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
|
stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
|
||||||
drawingFile: { 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 } },
|
cutFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
|
||||||
operations: {
|
operations: {
|
||||||
orderBy: { sequence: "asc" },
|
orderBy: { sequence: "asc" },
|
||||||
@@ -42,7 +48,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = part.assembly.project;
|
const project = part.assembly.project;
|
||||||
const assembly = { code: part.assembly.code, name: part.assembly.name };
|
const assembly = {
|
||||||
|
code: part.assembly.code,
|
||||||
|
name: part.assembly.name,
|
||||||
|
qty: part.assembly.qty,
|
||||||
|
};
|
||||||
const partHeader = {
|
const partHeader = {
|
||||||
code: part.code,
|
code: part.code,
|
||||||
name: part.name,
|
name: part.name,
|
||||||
@@ -50,6 +60,14 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
qty: part.qty,
|
qty: part.qty,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pull drawing PDFs off disk so pdf-lib can inline them behind the cover /
|
||||||
|
// op cards. Missing / unreadable files are logged and skipped — the
|
||||||
|
// traveler still prints without them rather than 500ing.
|
||||||
|
const drawingPdfBytes = await tryReadPdf(part.drawingFile?.path ?? null);
|
||||||
|
const assemblyDrawingPdfBytes = await tryReadPdf(
|
||||||
|
part.assembly.drawingFile?.path ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
const cover: PartCoverData = {
|
const cover: PartCoverData = {
|
||||||
project,
|
project,
|
||||||
assembly,
|
assembly,
|
||||||
@@ -88,7 +106,12 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pdf = await renderPartTravelers({ cover, cards });
|
const pdf = await renderPartTravelers({
|
||||||
|
cover,
|
||||||
|
cards,
|
||||||
|
drawingPdfBytes,
|
||||||
|
assemblyDrawingPdfBytes,
|
||||||
|
});
|
||||||
const safeName = `${part.code}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_");
|
const safeName = `${part.code}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||||
|
|
||||||
return new NextResponse(pdf as unknown as BodyInit, {
|
return new NextResponse(pdf as unknown as BodyInit, {
|
||||||
@@ -103,3 +126,14 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
return errorResponse(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("[travelers.pdf] could not read drawing file", { path, err });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
type ScanFile = { id: string; originalName: string; kind: string };
|
||||||
|
|
||||||
export type ScanOp = {
|
export type ScanOp = {
|
||||||
id: string;
|
id: string;
|
||||||
sequence: number;
|
sequence: number;
|
||||||
@@ -14,6 +16,7 @@ export type ScanOp = {
|
|||||||
settings: string | null;
|
settings: string | null;
|
||||||
plannedMinutes: number | null;
|
plannedMinutes: number | null;
|
||||||
plannedUnits: number | null;
|
plannedUnits: number | null;
|
||||||
|
unitsCompleted: number;
|
||||||
claimedByUserId: string | null;
|
claimedByUserId: string | null;
|
||||||
claimedAt: string | null;
|
claimedAt: string | null;
|
||||||
machine: { id: string; name: string; kind: string } | null;
|
machine: { id: string; name: string; kind: string } | null;
|
||||||
@@ -23,10 +26,18 @@ export type ScanOp = {
|
|||||||
name: string;
|
name: string;
|
||||||
material: string | null;
|
material: string | null;
|
||||||
qty: number;
|
qty: number;
|
||||||
|
stepFile: ScanFile | null;
|
||||||
|
drawingFile: ScanFile | null;
|
||||||
|
cutFile: ScanFile | null;
|
||||||
|
thumbnailFileId: string | null;
|
||||||
assembly: {
|
assembly: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
qty: number;
|
||||||
|
stepFile: ScanFile | null;
|
||||||
|
drawingFile: ScanFile | null;
|
||||||
|
cutFile: ScanFile | null;
|
||||||
project: { id: string; code: string; name: string };
|
project: { id: string; code: string; name: string };
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -56,8 +67,29 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
|
|
||||||
const isOperator = viewer.role === "operator";
|
const isOperator = viewer.role === "operator";
|
||||||
const active = op.status === "in_progress";
|
const active = op.status === "in_progress";
|
||||||
|
const partial = op.status === "partial";
|
||||||
const completed = op.status === "completed";
|
const completed = op.status === "completed";
|
||||||
|
|
||||||
|
// Total units the shop needs to run through this op to satisfy the project:
|
||||||
|
// assembly.qty (how many assemblies we're building) × part.qty (parts per
|
||||||
|
// assembly). This is distinct from plannedUnits, which is the admin's
|
||||||
|
// optional time estimate for a single run.
|
||||||
|
const totalUnits = op.part.assembly.qty * op.part.qty;
|
||||||
|
|
||||||
|
// Flat list of attached files (part first, then assembly). Rendered as big
|
||||||
|
// tap targets so the operator can pull the drawing / STEP right from the
|
||||||
|
// scan page without bouncing to the admin UI.
|
||||||
|
const quickFiles: Array<{ label: string; file: ScanFile; scope: "part" | "assembly" }> = [];
|
||||||
|
if (op.part.drawingFile) quickFiles.push({ label: "Drawing", file: op.part.drawingFile, scope: "part" });
|
||||||
|
if (op.part.stepFile) quickFiles.push({ label: "3D / STEP", file: op.part.stepFile, scope: "part" });
|
||||||
|
if (op.part.cutFile) quickFiles.push({ label: "Cut file", file: op.part.cutFile, scope: "part" });
|
||||||
|
if (op.part.assembly.drawingFile)
|
||||||
|
quickFiles.push({ label: "Assembly drawing", file: op.part.assembly.drawingFile, scope: "assembly" });
|
||||||
|
if (op.part.assembly.stepFile)
|
||||||
|
quickFiles.push({ label: "Assembly 3D", file: op.part.assembly.stepFile, scope: "assembly" });
|
||||||
|
if (op.part.assembly.cutFile)
|
||||||
|
quickFiles.push({ label: "Assembly cut", file: op.part.assembly.cutFile, scope: "assembly" });
|
||||||
|
|
||||||
async function call(path: string, body?: unknown) {
|
async function call(path: string, body?: unknown) {
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await fetch(path, {
|
const res = await fetch(path, {
|
||||||
@@ -129,10 +161,21 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
<h1 className="text-xl font-semibold mt-1">{op.part.name}</h1>
|
<h1 className="text-xl font-semibold mt-1">{op.part.name}</h1>
|
||||||
<div className="text-slate-600 text-sm">
|
<div className="text-slate-600 text-sm">
|
||||||
Part <span className="font-mono">{op.part.code}</span>
|
Part <span className="font-mono">{op.part.code}</span>
|
||||||
{op.part.material ? ` · ${op.part.material}` : null} · qty {op.part.qty}
|
{op.part.material ? ` · ${op.part.material}` : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-slate-700 text-sm mt-2">
|
||||||
|
<span className="font-semibold text-slate-900">Total to produce: {totalUnits}</span>
|
||||||
|
<span className="text-slate-500">
|
||||||
|
{" "}({op.part.assembly.qty} assemblies × {op.part.qty} per assembly)
|
||||||
|
</span>
|
||||||
|
{op.unitsCompleted > 0 ? (
|
||||||
|
<span className="ml-2 text-amber-700 font-medium">
|
||||||
|
{op.unitsCompleted} done so far
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex items-center gap-2">
|
<div className="mt-4 flex items-center gap-2 flex-wrap">
|
||||||
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-1">
|
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-1">
|
||||||
Step {op.sequence}
|
Step {op.sequence}
|
||||||
</span>
|
</span>
|
||||||
@@ -142,10 +185,16 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
? "bg-emerald-100 text-emerald-800"
|
? "bg-emerald-100 text-emerald-800"
|
||||||
: active
|
: active
|
||||||
? "bg-amber-100 text-amber-800"
|
? "bg-amber-100 text-amber-800"
|
||||||
|
: partial
|
||||||
|
? "bg-orange-100 text-orange-800"
|
||||||
: "bg-slate-100 text-slate-700"
|
: "bg-slate-100 text-slate-700"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{op.status === "in_progress" ? "in progress" : op.status}
|
{op.status === "in_progress"
|
||||||
|
? "in progress"
|
||||||
|
: op.status === "partial"
|
||||||
|
? "partial"
|
||||||
|
: op.status}
|
||||||
</span>
|
</span>
|
||||||
{op.qcRequired ? (
|
{op.qcRequired ? (
|
||||||
<span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1">
|
<span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1">
|
||||||
@@ -171,6 +220,32 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{quickFiles.length > 0 ? (
|
||||||
|
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-900 mb-3">Quick files</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{quickFiles.map(({ label, file, scope }) => (
|
||||||
|
<a
|
||||||
|
key={`${scope}-${file.id}`}
|
||||||
|
href={`/api/v1/files/${file.id}/download`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
className="flex flex-col rounded-xl border border-slate-200 bg-slate-50 active:bg-slate-100 px-3 py-2 min-h-[64px]"
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-slate-900">{label}</span>
|
||||||
|
<span className="text-[11px] text-slate-500 mt-0.5">
|
||||||
|
{scope === "assembly" ? "Assembly · " : "Part · "}
|
||||||
|
{file.kind.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 truncate mt-0.5" title={file.originalName}>
|
||||||
|
{file.originalName}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{op.instructions ? (
|
{op.instructions ? (
|
||||||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||||
<h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2>
|
<h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2>
|
||||||
@@ -214,20 +289,37 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v
|
|||||||
disabled={isPending || (!!op.claimedByUserId && op.claimedByUserId !== viewer.id)}
|
disabled={isPending || (!!op.claimedByUserId && op.claimedByUserId !== viewer.id)}
|
||||||
className="w-full h-14 rounded-xl bg-slate-900 text-white text-lg font-medium disabled:bg-slate-400"
|
className="w-full h-14 rounded-xl bg-slate-900 text-white text-lg font-medium disabled:bg-slate-400"
|
||||||
>
|
>
|
||||||
{isPending ? "Claiming…" : "Start this step"}
|
{isPending
|
||||||
|
? partial
|
||||||
|
? "Resuming…"
|
||||||
|
: "Claiming…"
|
||||||
|
: partial
|
||||||
|
? "Resume this step"
|
||||||
|
: "Start this step"}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-1 gap-3">
|
<div className="grid grid-cols-1 gap-3">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="text-sm font-medium text-slate-900">Units processed</span>
|
<span className="text-sm font-medium text-slate-900">
|
||||||
|
Units processed
|
||||||
|
{op.unitsCompleted > 0 ? (
|
||||||
|
<span className="ml-2 text-xs text-slate-500 font-normal">
|
||||||
|
{op.unitsCompleted} of {totalUnits} already done
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</span>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
value={units}
|
value={units}
|
||||||
onChange={(e) => setUnits(e.target.value)}
|
onChange={(e) => setUnits(e.target.value)}
|
||||||
placeholder={op.plannedUnits?.toString() ?? "0"}
|
placeholder={
|
||||||
|
op.unitsCompleted > 0
|
||||||
|
? `${Math.max(0, totalUnits - op.unitsCompleted)} remaining`
|
||||||
|
: op.plannedUnits?.toString() ?? totalUnits.toString()
|
||||||
|
}
|
||||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base"
|
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -35,11 +35,19 @@ export default async function ScanPage({ params }: { params: Promise<{ token: st
|
|||||||
name: true,
|
name: true,
|
||||||
material: true,
|
material: true,
|
||||||
qty: true,
|
qty: true,
|
||||||
|
stepFile: { select: { id: true, originalName: true, kind: true } },
|
||||||
|
drawingFile: { select: { id: true, originalName: true, kind: true } },
|
||||||
|
cutFile: { select: { id: true, originalName: true, kind: true } },
|
||||||
|
thumbnailFileId: true,
|
||||||
assembly: {
|
assembly: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
code: true,
|
code: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
qty: true,
|
||||||
|
stepFile: { select: { id: true, originalName: true, kind: true } },
|
||||||
|
drawingFile: { select: { id: true, originalName: true, kind: true } },
|
||||||
|
cutFile: { select: { id: true, originalName: true, kind: true } },
|
||||||
project: { select: { id: true, code: true, name: true } },
|
project: { select: { id: true, code: true, name: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+45
-5
@@ -20,7 +20,9 @@ const MARGIN = 48; // 2/3"
|
|||||||
|
|
||||||
export interface OperationCardData {
|
export interface OperationCardData {
|
||||||
project: { code: string; name: string };
|
project: { code: string; name: string };
|
||||||
assembly: { code: string; name: string };
|
/** `qty` is the number of assemblies of this kind in the project. */
|
||||||
|
assembly: { code: string; name: string; qty: number };
|
||||||
|
/** `qty` is the per-assembly part count (so total parts = assembly.qty × part.qty). */
|
||||||
part: { code: string; name: string; material: string | null; qty: number };
|
part: { code: string; name: string; material: string | null; qty: number };
|
||||||
operation: {
|
operation: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -59,7 +61,7 @@ export interface PurchaseOrderPdfData {
|
|||||||
|
|
||||||
export interface PartCoverData {
|
export interface PartCoverData {
|
||||||
project: { code: string; name: string };
|
project: { code: string; name: string };
|
||||||
assembly: { code: string; name: string };
|
assembly: { code: string; name: string; qty: number };
|
||||||
part: {
|
part: {
|
||||||
code: string;
|
code: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -98,10 +100,21 @@ export async function renderPurchaseOrder(data: PurchaseOrderPdfData): Promise<U
|
|||||||
return doc.save();
|
return doc.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Entry point: cover sheet + every operation card, all in one PDF. */
|
/** Entry point: cover sheet + every operation card, all in one PDF.
|
||||||
|
*
|
||||||
|
* If `drawingPdfBytes` is provided (raw bytes of the part's PDF drawing),
|
||||||
|
* those pages are inlined right after the cover sheet so the printed stack
|
||||||
|
* is: cover → drawing(s) → op 1 → op 2 … Operators see the drawing on the
|
||||||
|
* same sheet they're holding while running the part — no separate print.
|
||||||
|
*
|
||||||
|
* Assembly-level drawings can be appended too (`assemblyDrawingPdfBytes`),
|
||||||
|
* rendered before the part drawing.
|
||||||
|
*/
|
||||||
export async function renderPartTravelers(payload: {
|
export async function renderPartTravelers(payload: {
|
||||||
cover: PartCoverData;
|
cover: PartCoverData;
|
||||||
cards: OperationCardData[];
|
cards: OperationCardData[];
|
||||||
|
drawingPdfBytes?: Uint8Array | null;
|
||||||
|
assemblyDrawingPdfBytes?: Uint8Array | null;
|
||||||
}): Promise<Uint8Array> {
|
}): Promise<Uint8Array> {
|
||||||
const doc = await PDFDocument.create();
|
const doc = await PDFDocument.create();
|
||||||
const fonts = await embedFonts(doc);
|
const fonts = await embedFonts(doc);
|
||||||
@@ -109,6 +122,16 @@ export async function renderPartTravelers(payload: {
|
|||||||
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, payload.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 (payload.drawingPdfBytes) {
|
||||||
|
await appendPdfPages(doc, payload.drawingPdfBytes, "part drawing");
|
||||||
|
}
|
||||||
|
|
||||||
for (const card of payload.cards) {
|
for (const card of payload.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);
|
||||||
@@ -117,6 +140,19 @@ export async function renderPartTravelers(payload: {
|
|||||||
return doc.save();
|
return doc.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load an external PDF's pages and copy them into `doc`. Best-effort: if the
|
||||||
|
// upstream PDF is unreadable we log to stderr (server-side) and skip; the
|
||||||
|
// caller's traveler PDF is still produced.
|
||||||
|
async function appendPdfPages(doc: PDFDocument, bytes: Uint8Array, label: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const src = await PDFDocument.load(bytes, { ignoreEncryption: true });
|
||||||
|
const pages = await doc.copyPages(src, src.getPageIndices());
|
||||||
|
for (const p of pages) doc.addPage(p);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[travelers.pdf] skipped ${label}: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Layout helpers
|
// Layout helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -258,10 +294,11 @@ async function drawOperationCard(
|
|||||||
drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22 });
|
drawText(page, line, { x: MARGIN, y: cursor.top, font: fonts.bold, size: 22 });
|
||||||
}
|
}
|
||||||
cursor.top -= 14;
|
cursor.top -= 14;
|
||||||
|
const totalUnits = data.assembly.qty * data.part.qty;
|
||||||
const partMeta = [
|
const partMeta = [
|
||||||
`Part ${data.part.code}`,
|
`Part ${data.part.code}`,
|
||||||
data.part.material ? `${data.part.material}` : null,
|
data.part.material ? `${data.part.material}` : null,
|
||||||
`qty ${data.part.qty}`,
|
`${data.part.qty}/assembly × ${data.assembly.qty} assy = ${totalUnits} to produce`,
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
@@ -471,9 +508,12 @@ async function drawCoverSheet(
|
|||||||
});
|
});
|
||||||
cursor.top -= 18;
|
cursor.top -= 18;
|
||||||
|
|
||||||
|
const totalUnits = data.assembly.qty * data.part.qty;
|
||||||
const meta = [
|
const meta = [
|
||||||
data.part.material ? `Material: ${data.part.material}` : null,
|
data.part.material ? `Material: ${data.part.material}` : null,
|
||||||
`Quantity: ${data.part.qty}`,
|
`Per-assembly qty: ${data.part.qty}`,
|
||||||
|
`Assemblies: ${data.assembly.qty}`,
|
||||||
|
`Total to produce: ${totalUnits}`,
|
||||||
`Project: ${data.project.name}`,
|
`Project: ${data.project.name}`,
|
||||||
`Assembly: ${data.assembly.name}`,
|
`Assembly: ${data.assembly.name}`,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
|
|||||||
+8
-1
@@ -163,6 +163,9 @@ export const UpdateAssemblySchema = z
|
|||||||
name: NonEmpty.optional(),
|
name: NonEmpty.optional(),
|
||||||
qty: z.coerce.number().int().positive().max(100000).optional(),
|
qty: z.coerce.number().int().positive().max(100000).optional(),
|
||||||
notes: OptionalText,
|
notes: OptionalText,
|
||||||
|
stepFileId: z.string().min(1).nullable().optional(),
|
||||||
|
drawingFileId: z.string().min(1).nullable().optional(),
|
||||||
|
cutFileId: z.string().min(1).nullable().optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
|
|
||||||
@@ -202,7 +205,11 @@ export const UpdatePartSchema = z
|
|||||||
|
|
||||||
// ---- operations ---------------------------------------------------------
|
// ---- operations ---------------------------------------------------------
|
||||||
|
|
||||||
export const OperationStatuses = ["pending", "in_progress", "completed"] as const;
|
// "partial" = an operation that was started, had units logged, and then paused.
|
||||||
|
// Behaves like "pending" for claim purposes (any operator can resume it) but
|
||||||
|
// visually distinct so admins can see work-in-flight that isn't actively
|
||||||
|
// being run right now.
|
||||||
|
export const OperationStatuses = ["pending", "in_progress", "partial", "completed"] as const;
|
||||||
|
|
||||||
export const CreateOperationSchema = z.object({
|
export const CreateOperationSchema = z.object({
|
||||||
templateId: z.string().min(1).nullable().optional(),
|
templateId: z.string().min(1).nullable().optional(),
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_Assembly" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"code" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"qty" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"notes" TEXT,
|
||||||
|
"stepFileId" TEXT,
|
||||||
|
"drawingFileId" TEXT,
|
||||||
|
"cutFileId" TEXT,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Assembly_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Assembly_stepFileId_fkey" FOREIGN KEY ("stepFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Assembly_drawingFileId_fkey" FOREIGN KEY ("drawingFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Assembly_cutFileId_fkey" FOREIGN KEY ("cutFileId") REFERENCES "FileAsset" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Assembly" ("code", "createdAt", "id", "name", "notes", "projectId", "qty", "updatedAt") SELECT "code", "createdAt", "id", "name", "notes", "projectId", "qty", "updatedAt" FROM "Assembly";
|
||||||
|
DROP TABLE "Assembly";
|
||||||
|
ALTER TABLE "new_Assembly" RENAME TO "Assembly";
|
||||||
|
CREATE UNIQUE INDEX "Assembly_projectId_code_key" ON "Assembly"("projectId", "code");
|
||||||
|
CREATE TABLE "new_Operation" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"partId" TEXT NOT NULL,
|
||||||
|
"sequence" INTEGER NOT NULL,
|
||||||
|
"templateId" TEXT,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"machineId" TEXT,
|
||||||
|
"settings" TEXT,
|
||||||
|
"materialNotes" TEXT,
|
||||||
|
"instructions" TEXT,
|
||||||
|
"qcRequired" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
"qrToken" TEXT NOT NULL,
|
||||||
|
"claimedByUserId" TEXT,
|
||||||
|
"claimedAt" DATETIME,
|
||||||
|
"completedAt" DATETIME,
|
||||||
|
"plannedMinutes" INTEGER,
|
||||||
|
"plannedUnits" INTEGER,
|
||||||
|
"unitsCompleted" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
CONSTRAINT "Operation_partId_fkey" FOREIGN KEY ("partId") REFERENCES "Part" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Operation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "OperationTemplate" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Operation_machineId_fkey" FOREIGN KEY ("machineId") REFERENCES "Machine" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "Operation_claimedByUserId_fkey" FOREIGN KEY ("claimedByUserId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
INSERT INTO "new_Operation" ("claimedAt", "claimedByUserId", "completedAt", "createdAt", "id", "instructions", "machineId", "materialNotes", "name", "partId", "plannedMinutes", "plannedUnits", "qcRequired", "qrToken", "sequence", "settings", "status", "templateId", "updatedAt") SELECT "claimedAt", "claimedByUserId", "completedAt", "createdAt", "id", "instructions", "machineId", "materialNotes", "name", "partId", "plannedMinutes", "plannedUnits", "qcRequired", "qrToken", "sequence", "settings", "status", "templateId", "updatedAt" FROM "Operation";
|
||||||
|
DROP TABLE "Operation";
|
||||||
|
ALTER TABLE "new_Operation" RENAME TO "Operation";
|
||||||
|
CREATE UNIQUE INDEX "Operation_qrToken_key" ON "Operation"("qrToken");
|
||||||
|
CREATE INDEX "Operation_status_idx" ON "Operation"("status");
|
||||||
|
CREATE INDEX "Operation_claimedByUserId_idx" ON "Operation"("claimedByUserId");
|
||||||
|
CREATE UNIQUE INDEX "Operation_partId_sequence_key" ON "Operation"("partId", "sequence");
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
+13
-1
@@ -118,11 +118,17 @@ model Assembly {
|
|||||||
name String
|
name String
|
||||||
qty Int @default(1)
|
qty Int @default(1)
|
||||||
notes String?
|
notes String?
|
||||||
|
stepFileId String?
|
||||||
|
drawingFileId String?
|
||||||
|
cutFileId String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
|
||||||
parts Part[]
|
parts Part[]
|
||||||
|
stepFile FileAsset? @relation("AssemblyStep", fields: [stepFileId], references: [id], onDelete: SetNull)
|
||||||
|
drawingFile FileAsset? @relation("AssemblyDrawing", fields: [drawingFileId], references: [id], onDelete: SetNull)
|
||||||
|
cutFile FileAsset? @relation("AssemblyCut", fields: [cutFileId], references: [id], onDelete: SetNull)
|
||||||
|
|
||||||
@@unique([projectId, code])
|
@@unique([projectId, code])
|
||||||
}
|
}
|
||||||
@@ -165,13 +171,16 @@ model Operation {
|
|||||||
materialNotes String?
|
materialNotes String?
|
||||||
instructions String?
|
instructions String?
|
||||||
qcRequired Boolean @default(false)
|
qcRequired Boolean @default(false)
|
||||||
status String @default("pending") // pending | in_progress | completed
|
status String @default("pending") // pending | in_progress | partial | completed
|
||||||
qrToken String @unique
|
qrToken String @unique
|
||||||
claimedByUserId String?
|
claimedByUserId String?
|
||||||
claimedAt DateTime?
|
claimedAt DateTime?
|
||||||
completedAt DateTime?
|
completedAt DateTime?
|
||||||
plannedMinutes Int?
|
plannedMinutes Int?
|
||||||
plannedUnits Int?
|
plannedUnits Int?
|
||||||
|
/// Cumulative units recorded across every Start→Pause/Done cycle on this op.
|
||||||
|
/// Incremented whenever an operator hands in a non-zero `unitsProcessed`.
|
||||||
|
unitsCompleted Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -290,6 +299,9 @@ model FileAsset {
|
|||||||
partDrawing Part[] @relation("PartDrawing")
|
partDrawing Part[] @relation("PartDrawing")
|
||||||
partCut Part[] @relation("PartCut")
|
partCut Part[] @relation("PartCut")
|
||||||
partThumbnail Part[] @relation("PartThumbnail")
|
partThumbnail Part[] @relation("PartThumbnail")
|
||||||
|
assemblyStep Assembly[] @relation("AssemblyStep")
|
||||||
|
assemblyDrawing Assembly[] @relation("AssemblyDrawing")
|
||||||
|
assemblyCut Assembly[] @relation("AssemblyCut")
|
||||||
poPdfs PurchaseOrder[] @relation("PoPdf")
|
poPdfs PurchaseOrder[] @relation("PoPdf")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user