phase 2 and 3
This commit is contained in:
@@ -5,7 +5,9 @@
|
||||
"Bash(npm install *)",
|
||||
"Bash(DATABASE_URL=\"file:./dev.db\" npx prisma migrate dev --name init --skip-seed)",
|
||||
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npx tsc --noEmit)",
|
||||
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npm run build)"
|
||||
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npm run build)",
|
||||
"Bash(npx tsc *)",
|
||||
"Bash(npx next *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge, Button, Card, ErrorBanner, Field, Input, Modal, PageHeader, Select, Textarea } from "@/components/ui";
|
||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||
|
||||
interface MachineRow {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: string;
|
||||
location: string | null;
|
||||
notes: string | null;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const KINDS = ["NCT_PUNCH", "PRESS_BRAKE", "RIVET", "WELD", "LASER", "SHEAR", "ASSEMBLY", "OTHER"] as const;
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
NCT_PUNCH: "NCT punch",
|
||||
PRESS_BRAKE: "Press brake",
|
||||
RIVET: "Rivet",
|
||||
WELD: "Weld",
|
||||
LASER: "Laser",
|
||||
SHEAR: "Shear",
|
||||
ASSEMBLY: "Assembly",
|
||||
OTHER: "Other",
|
||||
};
|
||||
|
||||
export default function MachinesClient({ initial }: { initial: MachineRow[] }) {
|
||||
const router = useRouter();
|
||||
const [edit, setEdit] = useState<MachineRow | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
<PageHeader
|
||||
title="Machines"
|
||||
description="Shop-floor equipment. Deactivated machines are preserved so historical operations retain their references."
|
||||
actions={<Button onClick={() => setNewOpen(true)}>New machine</Button>}
|
||||
/>
|
||||
<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-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Type</th>
|
||||
<th className="px-4 py-2 font-medium">Location</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initial.map((m) => (
|
||||
<tr key={m.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 font-medium">{m.name}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{KIND_LABEL[m.kind] ?? m.kind}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{m.location ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={m.active ? "green" : "amber"}>{m.active ? "active" : "inactive"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEdit(m)}>
|
||||
Edit
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{initial.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-slate-500">
|
||||
No machines yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{newOpen && <MachineModal onClose={() => setNewOpen(false)} onSaved={() => router.refresh()} />}
|
||||
{edit && <MachineModal machine={edit} onClose={() => setEdit(null)} onSaved={() => router.refresh()} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MachineModal({
|
||||
machine,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
machine?: MachineRow;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = !!machine;
|
||||
const [name, setName] = useState(machine?.name ?? "");
|
||||
const [kind, setKind] = useState(machine?.kind ?? "OTHER");
|
||||
const [location, setLocation] = useState(machine?.location ?? "");
|
||||
const [notes, setNotes] = useState(machine?.notes ?? "");
|
||||
const [active, setActive] = useState(machine?.active ?? 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 {
|
||||
if (editing) {
|
||||
await apiFetch(`/api/v1/machines/${machine!.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name, kind, location, notes, active }),
|
||||
});
|
||||
} else {
|
||||
await apiFetch("/api/v1/machines", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ name, kind, location, notes }),
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!machine || !confirm(`Deactivate ${machine.name}?`)) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/machines/${machine.id}`, { method: "DELETE" });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Deactivate failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={editing ? `Edit ${machine!.name}` : "New machine"}
|
||||
footer={
|
||||
<>
|
||||
{editing && machine!.active && (
|
||||
<Button variant="danger" size="sm" onClick={deactivate} disabled={busy}>
|
||||
Deactivate
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="machine-form" disabled={busy}>
|
||||
{busy ? "Saving…" : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="machine-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required maxLength={200} />
|
||||
</Field>
|
||||
<Field label="Type" required>
|
||||
<Select value={kind} onChange={(e) => setKind(e.target.value)}>
|
||||
{KINDS.map((k) => (
|
||||
<option key={k} value={k}>
|
||||
{KIND_LABEL[k]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Location" hint="Where on the floor, or empty.">
|
||||
<Input value={location} onChange={(e) => setLocation(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
{editing && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
<span>Active</span>
|
||||
</label>
|
||||
)}
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import MachinesClient from "./MachinesClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminMachinesPage() {
|
||||
const machines = await prisma.machine.findMany({
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
});
|
||||
return (
|
||||
<MachinesClient
|
||||
initial={machines.map((m) => ({ ...m, createdAt: m.createdAt.toISOString(), updatedAt: m.updatedAt.toISOString() }))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge, Button, Card, ErrorBanner, Field, Input, Modal, PageHeader, Select, Textarea } from "@/components/ui";
|
||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||
|
||||
interface TemplateRow {
|
||||
id: string;
|
||||
name: string;
|
||||
machineId: string | null;
|
||||
machineName: string | null;
|
||||
defaultSettings: string | null;
|
||||
defaultInstructions: string | null;
|
||||
qcRequired: boolean;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
interface MachineOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export default function TemplatesClient({
|
||||
initialTemplates,
|
||||
machines,
|
||||
}: {
|
||||
initialTemplates: TemplateRow[];
|
||||
machines: MachineOption[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [edit, setEdit] = useState<TemplateRow | null>(null);
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
<PageHeader
|
||||
title="Operation templates"
|
||||
description="Reusable step recipes. Use them when authoring operations on a part, or define ad-hoc operations."
|
||||
actions={<Button onClick={() => setNewOpen(true)}>New template</Button>}
|
||||
/>
|
||||
<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-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Machine</th>
|
||||
<th className="px-4 py-2 font-medium">QC</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initialTemplates.map((t) => (
|
||||
<tr key={t.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 font-medium">{t.name}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{t.machineName ?? "—"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{t.qcRequired ? <Badge tone="amber">required</Badge> : <span className="text-slate-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={t.active ? "green" : "amber"}>{t.active ? "active" : "inactive"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEdit(t)}>
|
||||
Edit
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{initialTemplates.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-slate-500">
|
||||
No operation templates yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{newOpen && (
|
||||
<TemplateModal
|
||||
machines={machines}
|
||||
onClose={() => setNewOpen(false)}
|
||||
onSaved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
{edit && (
|
||||
<TemplateModal
|
||||
template={edit}
|
||||
machines={machines}
|
||||
onClose={() => setEdit(null)}
|
||||
onSaved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TemplateModal({
|
||||
template,
|
||||
machines,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
template?: TemplateRow;
|
||||
machines: MachineOption[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = !!template;
|
||||
const [name, setName] = useState(template?.name ?? "");
|
||||
const [machineId, setMachineId] = useState(template?.machineId ?? "");
|
||||
const [instructions, setInstructions] = useState(template?.defaultInstructions ?? "");
|
||||
const [settings, setSettings] = useState(template?.defaultSettings ?? "");
|
||||
const [qcRequired, setQcRequired] = useState(template?.qcRequired ?? false);
|
||||
const [active, setActive] = useState(template?.active ?? 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 body = {
|
||||
name,
|
||||
machineId: machineId || null,
|
||||
defaultInstructions: instructions,
|
||||
defaultSettings: settings,
|
||||
qcRequired,
|
||||
...(editing ? { active } : {}),
|
||||
};
|
||||
if (editing) {
|
||||
await apiFetch(`/api/v1/operation-templates/${template!.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
await apiFetch("/api/v1/operation-templates", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!template || !confirm(`Deactivate ${template.name}?`)) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/operation-templates/${template.id}`, { method: "DELETE" });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Deactivate failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={editing ? `Edit ${template!.name}` : "New operation template"}
|
||||
footer={
|
||||
<>
|
||||
{editing && template!.active && (
|
||||
<Button variant="danger" size="sm" onClick={deactivate} disabled={busy}>
|
||||
Deactivate
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="tpl-form" disabled={busy}>
|
||||
{busy ? "Saving…" : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="tpl-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Default machine">
|
||||
<Select value={machineId} onChange={(e) => setMachineId(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field
|
||||
label="Default instructions"
|
||||
hint="Step-by-step text shown on the traveler card."
|
||||
>
|
||||
<Textarea value={instructions} onChange={(e) => setInstructions(e.target.value)} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Default settings (JSON)"
|
||||
hint='Key/value like {"programNumber":47,"speed":"medium"}. Optional.'
|
||||
>
|
||||
<Textarea
|
||||
value={settings}
|
||||
onChange={(e) => setSettings(e.target.value)}
|
||||
placeholder='{"programNumber":47}'
|
||||
/>
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={qcRequired} onChange={(e) => setQcRequired(e.target.checked)} />
|
||||
<span>QC check required on close-out</span>
|
||||
</label>
|
||||
{editing && (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
<span>Active</span>
|
||||
</label>
|
||||
)}
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import TemplatesClient from "./TemplatesClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminOperationsPage() {
|
||||
const [templates, machines] = await Promise.all([
|
||||
prisma.operationTemplate.findMany({
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
include: { machine: { select: { id: true, name: true, active: true } } },
|
||||
}),
|
||||
prisma.machine.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
]);
|
||||
return (
|
||||
<TemplatesClient
|
||||
initialTemplates={templates.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
machineId: t.machineId,
|
||||
machineName: t.machine?.name ?? null,
|
||||
defaultSettings: t.defaultSettings,
|
||||
defaultInstructions: t.defaultInstructions,
|
||||
qcRequired: t.qcRequired,
|
||||
active: t.active,
|
||||
}))}
|
||||
machines={machines}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+153
-16
@@ -1,27 +1,164 @@
|
||||
export default function AdminDashboardPage() {
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminDashboardPage() {
|
||||
const [
|
||||
projectsTotal,
|
||||
projectsActive,
|
||||
assembliesTotal,
|
||||
partsTotal,
|
||||
operationsTotal,
|
||||
operationsInProgress,
|
||||
machinesActive,
|
||||
templatesActive,
|
||||
operatorsActive,
|
||||
adminsActive,
|
||||
recentProjects,
|
||||
] = await Promise.all([
|
||||
prisma.project.count(),
|
||||
prisma.project.count({ where: { status: { in: ["planning", "in_progress"] } } }),
|
||||
prisma.assembly.count(),
|
||||
prisma.part.count(),
|
||||
prisma.operation.count(),
|
||||
prisma.operation.count({ where: { status: "in_progress" } }),
|
||||
prisma.machine.count({ where: { active: true } }),
|
||||
prisma.operationTemplate.count({ where: { active: true } }),
|
||||
prisma.user.count({ where: { role: "operator", active: true } }),
|
||||
prisma.user.count({ where: { role: "admin", active: true } }),
|
||||
prisma.project.findMany({
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
_count: { select: { assemblies: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8">
|
||||
<h1 className="text-2xl font-semibold">Dashboard</h1>
|
||||
<p className="text-slate-500 mt-1">
|
||||
Project planning, machines, operations, and users will appear here as each area is built.
|
||||
</p>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
|
||||
<p className="text-slate-500 mt-1">Snapshot of the shop. Click any tile to dive in.</p>
|
||||
|
||||
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Card title="Projects" desc="Plan work: assemblies, parts, and operations." />
|
||||
<Card title="Machines" desc="Manage shop-floor equipment." />
|
||||
<Card title="Operation templates" desc="Reusable step recipes." />
|
||||
<Card title="Fasteners & POs" desc="Aggregate BOM, generate purchase orders." />
|
||||
<Card title="Users" desc="Admins and operator PIN accounts." />
|
||||
<Card title="Audit log" desc="Who did what, when." />
|
||||
<Tile
|
||||
href="/admin/projects"
|
||||
title="Projects"
|
||||
primary={projectsTotal}
|
||||
secondary={`${projectsActive} active · ${assembliesTotal} assemblies · ${partsTotal} parts`}
|
||||
/>
|
||||
<Tile
|
||||
href="/admin/projects"
|
||||
title="Operations"
|
||||
primary={operationsTotal}
|
||||
secondary={`${operationsInProgress} in progress`}
|
||||
/>
|
||||
<Tile
|
||||
href="/admin/machines"
|
||||
title="Machines"
|
||||
primary={machinesActive}
|
||||
secondary="active"
|
||||
/>
|
||||
<Tile
|
||||
href="/admin/operations"
|
||||
title="Operation templates"
|
||||
primary={templatesActive}
|
||||
secondary="active"
|
||||
/>
|
||||
<Tile
|
||||
href="/admin/users"
|
||||
title="Users"
|
||||
primary={adminsActive + operatorsActive}
|
||||
secondary={`${adminsActive} admin${adminsActive === 1 ? "" : "s"} · ${operatorsActive} operator${operatorsActive === 1 ? "" : "s"}`}
|
||||
/>
|
||||
<div className="rounded-xl bg-white border border-slate-200 p-5">
|
||||
<h2 className="font-medium text-slate-700">Fasteners & POs</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">Purchasing lifecycle lands in step 6.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="mt-10">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Recent projects</h2>
|
||||
<Link href="/admin/projects" className="text-sm text-blue-600 hover:underline">
|
||||
All projects →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-slate-200 overflow-hidden">
|
||||
<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-4 py-2 font-medium">Code</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2 font-medium">Assemblies</th>
|
||||
<th className="px-4 py-2 font-medium">Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{recentProjects.map((p) => (
|
||||
<tr key={p.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 font-mono">
|
||||
<Link href={`/admin/projects/${p.id}`} className="text-blue-600 hover:underline">
|
||||
{p.code}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="px-4 py-3">{p.name}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{p.status}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{p._count.assemblies}</td>
|
||||
<td className="px-4 py-3 text-slate-500">
|
||||
{p.updatedAt.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{recentProjects.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-slate-500">
|
||||
No projects yet.{" "}
|
||||
<Link href="/admin/projects" className="text-blue-600 hover:underline">
|
||||
Create one
|
||||
</Link>
|
||||
.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ title, desc }: { title: string; desc: string }) {
|
||||
function Tile({
|
||||
href,
|
||||
title,
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
href: string;
|
||||
title: string;
|
||||
primary: number;
|
||||
secondary: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white border border-slate-200 p-5">
|
||||
<h2 className="font-medium">{title}</h2>
|
||||
<p className="text-sm text-slate-500 mt-1">{desc}</p>
|
||||
</div>
|
||||
<Link
|
||||
href={href}
|
||||
className="block rounded-xl bg-white border border-slate-200 p-5 transition hover:border-slate-400 hover:shadow-sm"
|
||||
>
|
||||
<h2 className="font-medium text-slate-700">{title}</h2>
|
||||
<p className="text-3xl font-semibold tracking-tight mt-2">{primary}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{secondary}</p>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge, Button, Card, ErrorBanner, Field, Input, Modal, PageHeader, Select, Textarea } from "@/components/ui";
|
||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
customerCode: string | null;
|
||||
status: string;
|
||||
dueDate: string | null;
|
||||
assemblyCount: number;
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = {
|
||||
planning: "slate",
|
||||
in_progress: "blue",
|
||||
completed: "green",
|
||||
cancelled: "red",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
planning: "Planning",
|
||||
in_progress: "In progress",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
export default function ProjectsClient({ initial }: { initial: ProjectRow[] }) {
|
||||
const router = useRouter();
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<PageHeader
|
||||
title="Projects"
|
||||
description="Top-level jobs. Each project groups assemblies, parts, and operations."
|
||||
actions={<Button onClick={() => setNewOpen(true)}>New project</Button>}
|
||||
/>
|
||||
<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-4 py-2 font-medium">Code</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Customer</th>
|
||||
<th className="px-4 py-2 font-medium">Due</th>
|
||||
<th className="px-4 py-2 font-medium">Assemblies</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initial.map((p) => (
|
||||
<tr key={p.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 font-mono text-slate-700">{p.code}</td>
|
||||
<td className="px-4 py-3 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{p.customerCode ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{formatDate(p.dueDate)}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{p.assemblyCount}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={STATUS_TONE[p.status] ?? "slate"}>{STATUS_LABEL[p.status] ?? p.status}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link href={`/admin/projects/${p.id}`} className="text-sm text-blue-600 hover:underline">
|
||||
Open →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{initial.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
|
||||
No projects yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{newOpen && (
|
||||
<NewProjectModal
|
||||
onClose={() => setNewOpen(false)}
|
||||
onSaved={(id) => {
|
||||
setNewOpen(false);
|
||||
router.push(`/admin/projects/${id}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewProjectModal({ onClose, onSaved }: { onClose: () => void; onSaved: (id: string) => void }) {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [customerCode, setCustomerCode] = useState("");
|
||||
const [dueDate, setDueDate] = useState("");
|
||||
const [notes, setNotes] = useState("");
|
||||
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<{ project: { id: string } }>("/api/v1/projects", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
name,
|
||||
customerCode: customerCode || null,
|
||||
dueDate: dueDate || null,
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
onSaved(res.project.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="New project"
|
||||
footer={
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="project-form" disabled={busy}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="project-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Code" required hint="Short identifier, e.g. MP-2026-004">
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Customer code" hint="Optional customer reference.">
|
||||
<Input value={customerCode} onChange={(e) => setCustomerCode(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Due date">
|
||||
<Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditProjectModal({
|
||||
project,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
project: { id: string; code: string; name: string; customerCode: string | null; dueDate: string | null; status: string; notes: string | null };
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState(project.code);
|
||||
const [name, setName] = useState(project.name);
|
||||
const [customerCode, setCustomerCode] = useState(project.customerCode ?? "");
|
||||
const [dueDate, setDueDate] = useState(project.dueDate ? project.dueDate.slice(0, 10) : "");
|
||||
const [status, setStatus] = useState(project.status);
|
||||
const [notes, setNotes] = useState(project.notes ?? "");
|
||||
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 {
|
||||
await apiFetch(`/api/v1/projects/${project.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
name,
|
||||
customerCode: customerCode || null,
|
||||
dueDate: dueDate || null,
|
||||
status,
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Delete project ${project.code}? This removes all assemblies, parts, and operations under it.`)) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/projects/${project.id}`, { method: "DELETE" });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Delete failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Edit ${project.code}`}
|
||||
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="edit-project-form" disabled={busy}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-project-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Code" required>
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Customer code">
|
||||
<Input value={customerCode} onChange={(e) => setCustomerCode(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Due date">
|
||||
<Input type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Status" required>
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="planning">Planning</option>
|
||||
<option value="in_progress">In progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorBanner,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from "@/components/ui";
|
||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||
import { EditProjectModal } from "../ProjectsClient";
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
customerCode: string | null;
|
||||
status: string;
|
||||
dueDate: string | null;
|
||||
notes: string | null;
|
||||
fastenerCount: number;
|
||||
poCount: number;
|
||||
}
|
||||
|
||||
interface AssemblyRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
qty: number;
|
||||
notes: string | null;
|
||||
partCount: number;
|
||||
}
|
||||
|
||||
const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = {
|
||||
planning: "slate",
|
||||
in_progress: "blue",
|
||||
completed: "green",
|
||||
cancelled: "red",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
planning: "Planning",
|
||||
in_progress: "In progress",
|
||||
completed: "Completed",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
function formatDate(iso: string | null) {
|
||||
if (!iso) return "—";
|
||||
return new Date(iso).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProjectDetailClient({
|
||||
project,
|
||||
assemblies,
|
||||
}: {
|
||||
project: ProjectInfo;
|
||||
assemblies: AssemblyRow[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [newAssemblyOpen, setNewAssemblyOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<nav className="mb-3 text-sm text-slate-500">
|
||||
<Link href="/admin/projects" className="hover:underline">
|
||||
← All projects
|
||||
</Link>
|
||||
</nav>
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="font-mono text-slate-500 text-lg">{project.code}</span>
|
||||
<span>{project.name}</span>
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={STATUS_TONE[project.status] ?? "slate"}>
|
||||
{STATUS_LABEL[project.status] ?? project.status}
|
||||
</Badge>
|
||||
<span className="text-slate-500">Customer: {project.customerCode ?? "—"}</span>
|
||||
<span className="text-slate-400">·</span>
|
||||
<span className="text-slate-500">Due: {formatDate(project.dueDate)}</span>
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setEditOpen(true)}>
|
||||
Edit project
|
||||
</Button>
|
||||
<Button onClick={() => setNewAssemblyOpen(true)}>New assembly</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{project.notes ? (
|
||||
<Card className="mb-4">
|
||||
<div className="p-4 text-sm text-slate-700 whitespace-pre-wrap">{project.notes}</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">Assemblies</h2>
|
||||
<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-4 py-2 font-medium">Code</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Qty</th>
|
||||
<th className="px-4 py-2 font-medium">Parts</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{assemblies.map((a) => (
|
||||
<tr key={a.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 font-mono text-slate-700">{a.code}</td>
|
||||
<td className="px-4 py-3 font-medium">{a.name}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{a.qty}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{a.partCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}/assemblies/${a.id}`}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Open →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{assemblies.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-4 py-10 text-center text-slate-500">
|
||||
No assemblies yet. Add one to start building parts and operations.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 sm:grid-cols-2">
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold mb-1">Fasteners</h3>
|
||||
<p className="text-sm text-slate-500 mb-3">
|
||||
{project.fastenerCount} item{project.fastenerCount === 1 ? "" : "s"} tracked
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">Fastener authoring lands in step 6.</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold mb-1">Purchase orders</h3>
|
||||
<p className="text-sm text-slate-500 mb-3">
|
||||
{project.poCount} PO{project.poCount === 1 ? "" : "s"}
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">PO lifecycle and PDFs land in step 6.</p>
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{editOpen && (
|
||||
<EditProjectModal
|
||||
project={project}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
{newAssemblyOpen && (
|
||||
<NewAssemblyModal
|
||||
projectId={project.id}
|
||||
onClose={() => setNewAssemblyOpen(false)}
|
||||
onSaved={(assemblyId) => {
|
||||
setNewAssemblyOpen(false);
|
||||
router.push(`/admin/projects/${project.id}/assemblies/${assemblyId}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewAssemblyModal({
|
||||
projectId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [qty, setQty] = useState("1");
|
||||
const [notes, setNotes] = useState("");
|
||||
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/projects/${projectId}/assemblies`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ code, name, qty: Number(qty), notes: notes || null }),
|
||||
},
|
||||
);
|
||||
onSaved(res.assembly.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="New assembly"
|
||||
footer={
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="asm-form" disabled={busy}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="asm-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Code" required hint="Unique within this project.">
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Quantity" required>
|
||||
<Input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorBanner,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Textarea,
|
||||
} from "@/components/ui";
|
||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||
|
||||
interface AssemblyInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
qty: number;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
interface ProjectInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface PartRow {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
material: string | null;
|
||||
qty: number;
|
||||
hasStep: boolean;
|
||||
hasDrawing: boolean;
|
||||
hasCut: boolean;
|
||||
operationCount: number;
|
||||
}
|
||||
|
||||
export default function AssemblyDetailClient({
|
||||
project,
|
||||
assembly,
|
||||
parts,
|
||||
}: {
|
||||
project: ProjectInfo;
|
||||
assembly: AssemblyInfo;
|
||||
parts: PartRow[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [newPartOpen, setNewPartOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<nav className="mb-3 text-sm text-slate-500">
|
||||
<Link href="/admin/projects" className="hover:underline">
|
||||
Projects
|
||||
</Link>
|
||||
<span className="mx-1">›</span>
|
||||
<Link href={`/admin/projects/${project.id}`} className="hover:underline">
|
||||
{project.code}
|
||||
</Link>
|
||||
<span className="mx-1">›</span>
|
||||
<span>{assembly.code}</span>
|
||||
</nav>
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="font-mono text-slate-500 text-lg">{assembly.code}</span>
|
||||
<span>{assembly.name}</span>
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span className="text-slate-500">
|
||||
Qty {assembly.qty} · {parts.length} part{parts.length === 1 ? "" : "s"}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setEditOpen(true)}>
|
||||
Edit assembly
|
||||
</Button>
|
||||
<Button onClick={() => setNewPartOpen(true)}>New part</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
{assembly.notes ? (
|
||||
<Card className="mb-4">
|
||||
<div className="p-4 text-sm text-slate-700 whitespace-pre-wrap">{assembly.notes}</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<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-4 py-2 font-medium">Code</th>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Material</th>
|
||||
<th className="px-4 py-2 font-medium">Qty</th>
|
||||
<th className="px-4 py-2 font-medium">Files</th>
|
||||
<th className="px-4 py-2 font-medium">Ops</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{parts.map((p) => (
|
||||
<tr key={p.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 font-mono text-slate-700">{p.code}</td>
|
||||
<td className="px-4 py-3 font-medium">{p.name}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{p.material ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-slate-600">{p.qty}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-1">
|
||||
<Badge tone={p.hasStep ? "green" : "slate"}>STEP</Badge>
|
||||
<Badge tone={p.hasDrawing ? "green" : "slate"}>DWG</Badge>
|
||||
<Badge tone={p.hasCut ? "green" : "slate"}>CUT</Badge>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">{p.operationCount}</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${p.id}`}
|
||||
className="text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Open →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{parts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
|
||||
No parts yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{editOpen && (
|
||||
<EditAssemblyModal
|
||||
assembly={assembly}
|
||||
projectId={project.id}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={() => router.refresh()}
|
||||
onDeleted={() => router.push(`/admin/projects/${project.id}`)}
|
||||
/>
|
||||
)}
|
||||
{newPartOpen && (
|
||||
<NewPartModal
|
||||
assemblyId={assembly.id}
|
||||
onClose={() => setNewPartOpen(false)}
|
||||
onSaved={(partId) => {
|
||||
setNewPartOpen(false);
|
||||
router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}/parts/${partId}`);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EditAssemblyModal({
|
||||
assembly,
|
||||
onClose,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
assembly: AssemblyInfo;
|
||||
projectId: string;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState(assembly.code);
|
||||
const [name, setName] = useState(assembly.name);
|
||||
const [qty, setQty] = useState(String(assembly.qty));
|
||||
const [notes, setNotes] = useState(assembly.notes ?? "");
|
||||
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 {
|
||||
await apiFetch(`/api/v1/assemblies/${assembly.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ code, name, qty: Number(qty), notes: notes || null }),
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Delete assembly ${assembly.code}? All parts and operations under it will be removed.`)) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/assemblies/${assembly.id}`, { method: "DELETE" });
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Delete failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Edit ${assembly.code}`}
|
||||
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="asm-edit-form" disabled={busy}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="asm-edit-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Code" required>
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Quantity" required>
|
||||
<Input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function NewPartModal({
|
||||
assemblyId,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
assemblyId: string;
|
||||
onClose: () => void;
|
||||
onSaved: (id: string) => void;
|
||||
}) {
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [material, setMaterial] = useState("");
|
||||
const [qty, setQty] = useState("1");
|
||||
const [notes, setNotes] = useState("");
|
||||
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 } }>(
|
||||
`/api/v1/assemblies/${assemblyId}/parts`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
name,
|
||||
material: material || null,
|
||||
qty: Number(qty),
|
||||
notes: notes || null,
|
||||
}),
|
||||
},
|
||||
);
|
||||
onSaved(res.part.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="New part"
|
||||
footer={
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="part-form" disabled={busy}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="part-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Code" required hint="Unique within this assembly.">
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} required autoFocus />
|
||||
</Field>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Material" hint="Free text, e.g. 5052-H32 0.125"">
|
||||
<Input value={material} onChange={(e) => setMaterial(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Quantity" required>
|
||||
<Input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import AssemblyDetailClient from "./AssemblyDetailClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminAssemblyDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; assemblyId: string }>;
|
||||
}) {
|
||||
const { id, assemblyId } = await params;
|
||||
const assembly = await prisma.assembly.findFirst({
|
||||
where: { id: assemblyId, projectId: id },
|
||||
include: {
|
||||
project: { select: { id: true, code: true, name: true } },
|
||||
parts: {
|
||||
orderBy: { code: "asc" },
|
||||
include: {
|
||||
_count: { select: { operations: true } },
|
||||
stepFile: { select: { id: true } },
|
||||
drawingFile: { select: { id: true } },
|
||||
cutFile: { select: { id: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!assembly) notFound();
|
||||
|
||||
return (
|
||||
<AssemblyDetailClient
|
||||
project={assembly.project}
|
||||
assembly={{
|
||||
id: assembly.id,
|
||||
code: assembly.code,
|
||||
name: assembly.name,
|
||||
qty: assembly.qty,
|
||||
notes: assembly.notes,
|
||||
}}
|
||||
parts={assembly.parts.map((p) => ({
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
material: p.material,
|
||||
qty: p.qty,
|
||||
hasStep: !!p.stepFile,
|
||||
hasDrawing: !!p.drawingFile,
|
||||
hasCut: !!p.cutFile,
|
||||
operationCount: p._count.operations,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
ErrorBanner,
|
||||
Field,
|
||||
Input,
|
||||
Modal,
|
||||
PageHeader,
|
||||
Select,
|
||||
Textarea,
|
||||
} from "@/components/ui";
|
||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||
|
||||
interface FileView {
|
||||
id: string;
|
||||
originalName: string;
|
||||
sizeBytes: number;
|
||||
kind: string;
|
||||
mimeType: string | null;
|
||||
}
|
||||
|
||||
interface PartInfo {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
material: string | null;
|
||||
qty: number;
|
||||
notes: string | null;
|
||||
stepFile: FileView | null;
|
||||
drawingFile: FileView | null;
|
||||
cutFile: FileView | null;
|
||||
}
|
||||
|
||||
export interface OperationRow {
|
||||
id: string;
|
||||
sequence: number;
|
||||
name: string;
|
||||
machineId: string | null;
|
||||
machineName: string | null;
|
||||
templateId: string | null;
|
||||
templateName: string | null;
|
||||
settings: string | null;
|
||||
materialNotes: string | null;
|
||||
instructions: string | null;
|
||||
qcRequired: boolean;
|
||||
plannedMinutes: number | null;
|
||||
plannedUnits: number | null;
|
||||
status: string;
|
||||
qrToken: string;
|
||||
}
|
||||
|
||||
interface MachineOption {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface TemplateOption {
|
||||
id: string;
|
||||
name: string;
|
||||
machineId: string | null;
|
||||
defaultSettings: string | null;
|
||||
defaultInstructions: string | null;
|
||||
qcRequired: boolean;
|
||||
}
|
||||
|
||||
type Slot = "stepFileId" | "drawingFileId" | "cutFileId";
|
||||
|
||||
const STATUS_TONE: Record<string, "slate" | "blue" | "green" | "amber" | "red"> = {
|
||||
pending: "slate",
|
||||
in_progress: "blue",
|
||||
completed: "green",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
pending: "Pending",
|
||||
in_progress: "In progress",
|
||||
completed: "Completed",
|
||||
};
|
||||
|
||||
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`;
|
||||
}
|
||||
|
||||
export default function PartDetailClient({
|
||||
project,
|
||||
assembly,
|
||||
part,
|
||||
operations,
|
||||
machines,
|
||||
templates,
|
||||
}: {
|
||||
project: { id: string; code: string; name: string };
|
||||
assembly: { id: string; code: string; name: string };
|
||||
part: PartInfo;
|
||||
operations: OperationRow[];
|
||||
machines: MachineOption[];
|
||||
templates: TemplateOption[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8">
|
||||
<nav className="mb-3 text-sm text-slate-500">
|
||||
<Link href="/admin/projects" className="hover:underline">
|
||||
Projects
|
||||
</Link>
|
||||
<span className="mx-1">›</span>
|
||||
<Link href={`/admin/projects/${project.id}`} className="hover:underline">
|
||||
{project.code}
|
||||
</Link>
|
||||
<span className="mx-1">›</span>
|
||||
<Link
|
||||
href={`/admin/projects/${project.id}/assemblies/${assembly.id}`}
|
||||
className="hover:underline"
|
||||
>
|
||||
{assembly.code}
|
||||
</Link>
|
||||
<span className="mx-1">›</span>
|
||||
<span>{part.code}</span>
|
||||
</nav>
|
||||
<PageHeader
|
||||
title={
|
||||
<span className="flex items-center gap-3">
|
||||
<span className="font-mono text-slate-500 text-lg">{part.code}</span>
|
||||
<span>{part.name}</span>
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span className="text-slate-500">
|
||||
{part.material ? `${part.material} · ` : ""}Qty {part.qty}
|
||||
</span>
|
||||
}
|
||||
actions={
|
||||
<Button variant="secondary" onClick={() => setEditOpen(true)}>
|
||||
Edit part
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
{part.notes ? (
|
||||
<Card className="mb-4">
|
||||
<div className="p-4 text-sm text-slate-700 whitespace-pre-wrap">{part.notes}</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<section className="mb-8">
|
||||
<h2 className="text-lg font-semibold mb-3">Files</h2>
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<FileSlot
|
||||
partId={part.id}
|
||||
label="STEP / 3D"
|
||||
description="3D model for in-app viewer."
|
||||
accept=".step,.stp"
|
||||
slot="stepFileId"
|
||||
file={part.stepFile}
|
||||
onChange={() => router.refresh()}
|
||||
/>
|
||||
<FileSlot
|
||||
partId={part.id}
|
||||
label="Drawing (PDF)"
|
||||
description="Dimensioned drawing."
|
||||
accept=".pdf"
|
||||
slot="drawingFileId"
|
||||
file={part.drawingFile}
|
||||
onChange={() => router.refresh()}
|
||||
/>
|
||||
<FileSlot
|
||||
partId={part.id}
|
||||
label="Cut file"
|
||||
description="DXF or SVG for the punch/laser."
|
||||
accept=".dxf,.svg"
|
||||
slot="cutFileId"
|
||||
file={part.cutFile}
|
||||
onChange={() => router.refresh()}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<OperationsSection
|
||||
partId={part.id}
|
||||
operations={operations}
|
||||
machines={machines}
|
||||
templates={templates}
|
||||
onChange={() => router.refresh()}
|
||||
/>
|
||||
|
||||
{editOpen && (
|
||||
<EditPartModal
|
||||
part={part}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSaved={() => router.refresh()}
|
||||
onDeleted={() => router.push(`/admin/projects/${project.id}/assemblies/${assembly.id}`)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Operations -----------------------------------------------------
|
||||
|
||||
function OperationsSection({
|
||||
partId,
|
||||
operations,
|
||||
machines,
|
||||
templates,
|
||||
onChange,
|
||||
}: {
|
||||
partId: string;
|
||||
operations: OperationRow[];
|
||||
machines: MachineOption[];
|
||||
templates: TemplateOption[];
|
||||
onChange: () => void;
|
||||
}) {
|
||||
const [newOpen, setNewOpen] = useState(false);
|
||||
const [edit, setEdit] = useState<OperationRow | null>(null);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function move(index: number, dir: -1 | 1) {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= operations.length) return;
|
||||
const order = operations.map((o) => o.id);
|
||||
[order[index], order[target]] = [order[target], order[index]];
|
||||
setBusyId(operations[index].id);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/parts/${partId}/operations/reorder`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ order }),
|
||||
});
|
||||
onChange();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Reorder failed");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(op: OperationRow) {
|
||||
if (!confirm(`Delete operation ${op.sequence}. ${op.name}?`)) return;
|
||||
setBusyId(op.id);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/operations/${op.id}`, { method: "DELETE" });
|
||||
onChange();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Delete failed");
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-lg font-semibold">Operations</h2>
|
||||
<Button size="sm" onClick={() => setNewOpen(true)}>
|
||||
Add operation
|
||||
</Button>
|
||||
</div>
|
||||
<ErrorBanner message={error} />
|
||||
<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-10">#</th>
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Machine</th>
|
||||
<th className="px-3 py-2 font-medium">QC</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">QR</th>
|
||||
<th className="px-3 py-2 text-right"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{operations.map((op, i) => (
|
||||
<tr key={op.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-3 py-3 text-slate-600">{op.sequence}</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="font-medium">{op.name}</div>
|
||||
{op.templateName ? (
|
||||
<div className="text-xs text-slate-500">from {op.templateName}</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-slate-600">{op.machineName ?? "—"}</td>
|
||||
<td className="px-3 py-3">
|
||||
{op.qcRequired ? <Badge tone="amber">required</Badge> : <span className="text-slate-400">—</span>}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<Badge tone={STATUS_TONE[op.status] ?? "slate"}>
|
||||
{STATUS_LABEL[op.status] ?? op.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<code className="text-xs text-slate-500" title={op.qrToken}>
|
||||
{op.qrToken.slice(0, 8)}…
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-500 hover:text-slate-900 px-1 disabled:opacity-40"
|
||||
disabled={i === 0 || busyId !== null}
|
||||
onClick={() => move(i, -1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="text-slate-500 hover:text-slate-900 px-1 disabled:opacity-40"
|
||||
disabled={i === operations.length - 1 || busyId !== null}
|
||||
onClick={() => move(i, 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => remove(op)}
|
||||
disabled={busyId !== null || op.status === "in_progress"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{operations.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-10 text-center text-slate-500">
|
||||
No operations on this part yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{newOpen && (
|
||||
<OperationModal
|
||||
partId={partId}
|
||||
machines={machines}
|
||||
templates={templates}
|
||||
onClose={() => setNewOpen(false)}
|
||||
onSaved={() => {
|
||||
setNewOpen(false);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{edit && (
|
||||
<OperationModal
|
||||
partId={partId}
|
||||
operation={edit}
|
||||
machines={machines}
|
||||
templates={templates}
|
||||
onClose={() => setEdit(null)}
|
||||
onSaved={() => {
|
||||
setEdit(null);
|
||||
onChange();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function OperationModal({
|
||||
partId,
|
||||
operation,
|
||||
machines,
|
||||
templates,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
partId: string;
|
||||
operation?: OperationRow;
|
||||
machines: MachineOption[];
|
||||
templates: TemplateOption[];
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const editing = !!operation;
|
||||
const [templateId, setTemplateId] = useState(operation?.templateId ?? "");
|
||||
const [name, setName] = useState(operation?.name ?? "");
|
||||
const [machineId, setMachineId] = useState(operation?.machineId ?? "");
|
||||
const [settings, setSettings] = useState(operation?.settings ?? "");
|
||||
const [materialNotes, setMaterialNotes] = useState(operation?.materialNotes ?? "");
|
||||
const [instructions, setInstructions] = useState(operation?.instructions ?? "");
|
||||
const [qcRequired, setQcRequired] = useState(operation?.qcRequired ?? false);
|
||||
const [plannedMinutes, setPlannedMinutes] = useState(
|
||||
operation?.plannedMinutes ? String(operation.plannedMinutes) : "",
|
||||
);
|
||||
const [plannedUnits, setPlannedUnits] = useState(
|
||||
operation?.plannedUnits ? String(operation.plannedUnits) : "",
|
||||
);
|
||||
const [status, setStatus] = useState(operation?.status ?? "pending");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function applyTemplate(id: string) {
|
||||
setTemplateId(id);
|
||||
if (!id) return;
|
||||
const t = templates.find((x) => x.id === id);
|
||||
if (!t) return;
|
||||
if (!name) setName(t.name);
|
||||
if (!machineId && t.machineId) setMachineId(t.machineId);
|
||||
if (!settings && t.defaultSettings) setSettings(t.defaultSettings);
|
||||
if (!instructions && t.defaultInstructions) setInstructions(t.defaultInstructions);
|
||||
if (t.qcRequired) setQcRequired(true);
|
||||
}
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const body = {
|
||||
templateId: templateId || null,
|
||||
name,
|
||||
machineId: machineId || null,
|
||||
settings,
|
||||
materialNotes,
|
||||
instructions,
|
||||
qcRequired,
|
||||
plannedMinutes: plannedMinutes ? Number(plannedMinutes) : null,
|
||||
plannedUnits: plannedUnits ? Number(plannedUnits) : null,
|
||||
...(editing ? { status } : {}),
|
||||
};
|
||||
if (editing) {
|
||||
await apiFetch(`/api/v1/operations/${operation!.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} else {
|
||||
await apiFetch(`/api/v1/parts/${partId}/operations`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={editing ? `Edit operation ${operation!.sequence}. ${operation!.name}` : "New operation"}
|
||||
footer={
|
||||
<>
|
||||
<div className="flex-1" />
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="op-form" disabled={busy}>
|
||||
{busy ? "Saving…" : editing ? "Save" : "Create"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="op-form" onSubmit={submit} className="space-y-4">
|
||||
{!editing && (
|
||||
<Field label="Start from template" hint="Pre-fills machine, settings, instructions, and QC.">
|
||||
<Select value={templateId} onChange={(e) => applyTemplate(e.target.value)}>
|
||||
<option value="">— ad-hoc, no template —</option>
|
||||
{templates.map((t) => (
|
||||
<option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Machine">
|
||||
<Select value={machineId} onChange={(e) => setMachineId(e.target.value)}>
|
||||
<option value="">— none —</option>
|
||||
{machines.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
</Field>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Field label="Planned minutes" hint="Optional estimate.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={plannedMinutes}
|
||||
onChange={(e) => setPlannedMinutes(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Planned units" hint="Target count for this step.">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={plannedUnits}
|
||||
onChange={(e) => setPlannedUnits(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="Settings (JSON)" hint='e.g. {"programNumber":47}. Optional.'>
|
||||
<Textarea
|
||||
value={settings}
|
||||
onChange={(e) => setSettings(e.target.value)}
|
||||
placeholder='{"programNumber":47}'
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Material notes" hint="Stock sizing, grain direction, etc.">
|
||||
<Textarea value={materialNotes} onChange={(e) => setMaterialNotes(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Instructions" hint="Printed on the traveler card.">
|
||||
<Textarea value={instructions} onChange={(e) => setInstructions(e.target.value)} />
|
||||
</Field>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={qcRequired} onChange={(e) => setQcRequired(e.target.checked)} />
|
||||
<span>QC check required on close-out</span>
|
||||
</label>
|
||||
{editing && (
|
||||
<Field label="Status">
|
||||
<Select value={status} onChange={(e) => setStatus(e.target.value)}>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="in_progress">In progress</option>
|
||||
<option value="completed">Completed</option>
|
||||
</Select>
|
||||
</Field>
|
||||
)}
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Files ----------------------------------------------------------
|
||||
|
||||
function FileSlot({
|
||||
partId,
|
||||
label,
|
||||
description,
|
||||
accept,
|
||||
slot,
|
||||
file,
|
||||
onChange,
|
||||
}: {
|
||||
partId: string;
|
||||
label: string;
|
||||
description: string;
|
||||
accept: string;
|
||||
slot: Slot;
|
||||
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/parts/${partId}`, {
|
||||
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/parts/${partId}`, {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Edit part ------------------------------------------------------
|
||||
|
||||
function EditPartModal({
|
||||
part,
|
||||
onClose,
|
||||
onSaved,
|
||||
onDeleted,
|
||||
}: {
|
||||
part: PartInfo;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
onDeleted: () => void;
|
||||
}) {
|
||||
const [code, setCode] = useState(part.code);
|
||||
const [name, setName] = useState(part.name);
|
||||
const [material, setMaterial] = useState(part.material ?? "");
|
||||
const [qty, setQty] = useState(String(part.qty));
|
||||
const [notes, setNotes] = useState(part.notes ?? "");
|
||||
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 {
|
||||
await apiFetch(`/api/v1/parts/${part.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
name,
|
||||
material: material || null,
|
||||
qty: Number(qty),
|
||||
notes: notes || null,
|
||||
}),
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Save failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
if (!confirm(`Delete part ${part.code}? All operations on it will be removed.`)) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/parts/${part.id}`, { method: "DELETE" });
|
||||
onDeleted();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Delete failed");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Edit ${part.code}`}
|
||||
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="part-edit-form" disabled={busy}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="part-edit-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Code" required>
|
||||
<Input value={code} onChange={(e) => setCode(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Material">
|
||||
<Input value={material} onChange={(e) => setMaterial(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="Quantity" required>
|
||||
<Input type="number" min={1} value={qty} onChange={(e) => setQty(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Notes">
|
||||
<Textarea value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||
</Field>
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import PartDetailClient from "./PartDetailClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminPartDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string; assemblyId: string; partId: string }>;
|
||||
}) {
|
||||
const { id, assemblyId, partId } = await params;
|
||||
const [part, machines, templates] = await Promise.all([
|
||||
prisma.part.findFirst({
|
||||
where: { id: partId, assemblyId, assembly: { projectId: id } },
|
||||
include: {
|
||||
assembly: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
project: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
},
|
||||
stepFile: true,
|
||||
drawingFile: true,
|
||||
cutFile: true,
|
||||
operations: {
|
||||
orderBy: { sequence: "asc" },
|
||||
include: {
|
||||
machine: { select: { id: true, name: true } },
|
||||
template: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
prisma.machine.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
}),
|
||||
prisma.operationTemplate.findMany({
|
||||
where: { active: true },
|
||||
orderBy: { name: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
machineId: true,
|
||||
defaultSettings: true,
|
||||
defaultInstructions: true,
|
||||
qcRequired: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
if (!part) notFound();
|
||||
|
||||
const fileView = (f: typeof part.stepFile) =>
|
||||
f
|
||||
? {
|
||||
id: f.id,
|
||||
originalName: f.originalName,
|
||||
sizeBytes: f.sizeBytes,
|
||||
kind: f.kind,
|
||||
mimeType: f.mimeType,
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PartDetailClient
|
||||
project={part.assembly.project}
|
||||
assembly={{ id: part.assembly.id, code: part.assembly.code, name: part.assembly.name }}
|
||||
part={{
|
||||
id: part.id,
|
||||
code: part.code,
|
||||
name: part.name,
|
||||
material: part.material,
|
||||
qty: part.qty,
|
||||
notes: part.notes,
|
||||
stepFile: fileView(part.stepFile),
|
||||
drawingFile: fileView(part.drawingFile),
|
||||
cutFile: fileView(part.cutFile),
|
||||
}}
|
||||
operations={part.operations.map((op) => ({
|
||||
id: op.id,
|
||||
sequence: op.sequence,
|
||||
name: op.name,
|
||||
machineId: op.machineId,
|
||||
machineName: op.machine?.name ?? null,
|
||||
templateId: op.templateId,
|
||||
templateName: op.template?.name ?? null,
|
||||
settings: op.settings,
|
||||
materialNotes: op.materialNotes,
|
||||
instructions: op.instructions,
|
||||
qcRequired: op.qcRequired,
|
||||
plannedMinutes: op.plannedMinutes,
|
||||
plannedUnits: op.plannedUnits,
|
||||
status: op.status,
|
||||
qrToken: op.qrToken,
|
||||
}))}
|
||||
machines={machines}
|
||||
templates={templates.map((t) => ({
|
||||
id: t.id,
|
||||
name: t.name,
|
||||
machineId: t.machineId,
|
||||
defaultSettings: t.defaultSettings,
|
||||
defaultInstructions: t.defaultInstructions,
|
||||
qcRequired: t.qcRequired,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import ProjectDetailClient from "./ProjectDetailClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id } = await params;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assemblies: {
|
||||
orderBy: { code: "asc" },
|
||||
include: { _count: { select: { parts: true } } },
|
||||
},
|
||||
_count: { select: { fasteners: true, purchaseOrders: true } },
|
||||
},
|
||||
});
|
||||
if (!project) notFound();
|
||||
|
||||
return (
|
||||
<ProjectDetailClient
|
||||
project={{
|
||||
id: project.id,
|
||||
code: project.code,
|
||||
name: project.name,
|
||||
customerCode: project.customerCode,
|
||||
status: project.status,
|
||||
dueDate: project.dueDate?.toISOString() ?? null,
|
||||
notes: project.notes,
|
||||
fastenerCount: project._count.fasteners,
|
||||
poCount: project._count.purchaseOrders,
|
||||
}}
|
||||
assemblies={project.assemblies.map((a) => ({
|
||||
id: a.id,
|
||||
code: a.code,
|
||||
name: a.name,
|
||||
qty: a.qty,
|
||||
notes: a.notes,
|
||||
partCount: a._count.parts,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import ProjectsClient from "./ProjectsClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminProjectsPage() {
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
include: { _count: { select: { assemblies: true } } },
|
||||
});
|
||||
return (
|
||||
<ProjectsClient
|
||||
initial={projects.map((p) => ({
|
||||
id: p.id,
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
customerCode: p.customerCode,
|
||||
status: p.status,
|
||||
dueDate: p.dueDate?.toISOString() ?? null,
|
||||
assemblyCount: p._count.assemblies,
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Badge, Button, Card, ErrorBanner, Field, Input, Modal, PageHeader, Select } from "@/components/ui";
|
||||
import { apiFetch, ApiClientError } from "@/lib/client-api";
|
||||
|
||||
interface UserRow {
|
||||
id: string;
|
||||
role: string;
|
||||
name: string;
|
||||
email: string | null;
|
||||
active: boolean;
|
||||
lastLoginAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export default function UsersClient({ initial }: { initial: UserRow[] }) {
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState<UserRow | null>(null);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl px-4 py-8">
|
||||
<PageHeader
|
||||
title="Users"
|
||||
description="Admins manage themselves and their operators here."
|
||||
actions={<Button onClick={() => setOpen(true)}>New user</Button>}
|
||||
/>
|
||||
|
||||
<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-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Role</th>
|
||||
<th className="px-4 py-2 font-medium">Email</th>
|
||||
<th className="px-4 py-2 font-medium">Last login</th>
|
||||
<th className="px-4 py-2 font-medium">Status</th>
|
||||
<th className="px-4 py-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initial.map((u) => (
|
||||
<tr key={u.id} className="border-b border-slate-100 last:border-0">
|
||||
<td className="px-4 py-3 font-medium">{u.name}</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={u.role === "admin" ? "blue" : "slate"}>{u.role}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-slate-600">{u.email ?? "—"}</td>
|
||||
<td className="px-4 py-3 text-slate-600">
|
||||
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString() : "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge tone={u.active ? "green" : "amber"}>{u.active ? "active" : "inactive"}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<Button variant="ghost" size="sm" onClick={() => setEditOpen(u)}>
|
||||
Edit
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{initial.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-10 text-center text-slate-500">
|
||||
No users yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{open && <NewUserModal onClose={() => setOpen(false)} onSaved={() => router.refresh()} />}
|
||||
{editOpen && (
|
||||
<EditUserModal
|
||||
user={editOpen}
|
||||
onClose={() => setEditOpen(null)}
|
||||
onSaved={() => router.refresh()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NewUserModal({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
|
||||
const [role, setRole] = useState<"admin" | "operator">("operator");
|
||||
const [name, setName] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
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 body = role === "admin" ? { role, name, email, password } : { role, name, pin };
|
||||
await apiFetch("/api/v1/users", { method: "POST", body: JSON.stringify(body) });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Failed to create user");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title="New user"
|
||||
footer={
|
||||
<>
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="new-user-form" disabled={busy}>
|
||||
{busy ? "Creating…" : "Create"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="new-user-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Role" required>
|
||||
<Select value={role} onChange={(e) => setRole(e.target.value as "admin" | "operator")}>
|
||||
<option value="operator">Operator</option>
|
||||
<option value="admin">Admin</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Name" required>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} required maxLength={200} />
|
||||
</Field>
|
||||
{role === "admin" ? (
|
||||
<>
|
||||
<Field label="Email" required>
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</Field>
|
||||
<Field label="Password" required hint="At least 8 characters.">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label="PIN" required hint="Exactly 4 digits. Share this with the operator.">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
pattern="\d{4}"
|
||||
maxLength={4}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function EditUserModal({
|
||||
user,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: {
|
||||
user: UserRow;
|
||||
onClose: () => void;
|
||||
onSaved: () => void;
|
||||
}) {
|
||||
const [name, setName] = useState(user.name);
|
||||
const [email, setEmail] = useState(user.email ?? "");
|
||||
const [password, setPassword] = useState("");
|
||||
const [pin, setPin] = useState("");
|
||||
const [active, setActive] = useState(user.active);
|
||||
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 body: Record<string, unknown> = {};
|
||||
if (name !== user.name) body.name = name;
|
||||
if (active !== user.active) body.active = active;
|
||||
if (user.role === "admin") {
|
||||
if (email && email !== user.email) body.email = email;
|
||||
if (password) body.password = password;
|
||||
} else {
|
||||
if (pin) body.pin = pin;
|
||||
}
|
||||
try {
|
||||
if (Object.keys(body).length > 0) {
|
||||
await apiFetch(`/api/v1/users/${user.id}`, { method: "PATCH", body: JSON.stringify(body) });
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Failed to update user");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivate() {
|
||||
if (!confirm(`Deactivate ${user.name}? Their sessions will be revoked immediately.`)) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(`/api/v1/users/${user.id}`, { method: "DELETE" });
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiClientError ? err.message : "Failed to deactivate");
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open
|
||||
onClose={onClose}
|
||||
title={`Edit ${user.name}`}
|
||||
footer={
|
||||
<>
|
||||
{user.active && (
|
||||
<Button variant="danger" size="sm" onClick={deactivate} disabled={busy}>
|
||||
Deactivate
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button variant="secondary" onClick={onClose} disabled={busy}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" form="edit-user-form" disabled={busy}>
|
||||
{busy ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="edit-user-form" onSubmit={submit} className="space-y-4">
|
||||
<Field label="Name">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</Field>
|
||||
{user.role === "admin" ? (
|
||||
<>
|
||||
<Field label="Email">
|
||||
<Input type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
</Field>
|
||||
<Field label="New password" hint="Leave blank to keep current password.">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
minLength={password.length > 0 ? 8 : undefined}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<Field label="New PIN" hint="Leave blank to keep current PIN. Exactly 4 digits.">
|
||||
<Input
|
||||
inputMode="numeric"
|
||||
pattern="\d{4}"
|
||||
maxLength={4}
|
||||
value={pin}
|
||||
onChange={(e) => setPin(e.target.value.replace(/\D/g, ""))}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} />
|
||||
<span>Active</span>
|
||||
</label>
|
||||
<ErrorBanner message={error} />
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import UsersClient from "./UsersClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: [{ active: "desc" }, { role: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
name: true,
|
||||
email: true,
|
||||
active: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return <UsersClient initial={users.map((u) => ({ ...u, lastLoginAt: u.lastLoginAt?.toISOString() ?? null, createdAt: u.createdAt.toISOString() }))} />;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { CreatePartSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const parts = await prisma.part.findMany({
|
||||
where: { assemblyId: id },
|
||||
orderBy: { code: "asc" },
|
||||
include: { _count: { select: { operations: true } } },
|
||||
});
|
||||
return ok({ parts });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, CreatePartSchema);
|
||||
|
||||
const assembly = await prisma.assembly.findUnique({ where: { id } });
|
||||
if (!assembly) throw new ApiError(404, "not_found", "Assembly not found");
|
||||
|
||||
const created = await prisma.part.create({
|
||||
data: {
|
||||
assemblyId: id,
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
material: body.material ?? null,
|
||||
qty: body.qty,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "Part",
|
||||
entityId: created.id,
|
||||
after: created,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ part: created }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdateAssemblySchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const assembly = await prisma.assembly.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
project: { select: { id: true, code: true, name: true } },
|
||||
parts: { orderBy: { code: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!assembly) throw new ApiError(404, "not_found", "Assembly not found");
|
||||
return ok({ assembly });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, UpdateAssemblySchema);
|
||||
|
||||
const before = await prisma.assembly.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Assembly not found");
|
||||
|
||||
const updated = await prisma.assembly.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.code !== undefined ? { code: body.code } : {}),
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.qty !== undefined ? { qty: body.qty } : {}),
|
||||
...(body.notes !== undefined ? { notes: body.notes } : {}),
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "update",
|
||||
entity: "Assembly",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ assembly: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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.assembly.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Assembly not found");
|
||||
await prisma.assembly.delete({ where: { id } });
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "delete",
|
||||
entity: "Assembly",
|
||||
entityId: id,
|
||||
before,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, ApiError } from "@/lib/api";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { mimeForKind, readFileBytes, type FileKind } from "@/lib/files";
|
||||
|
||||
// Any signed-in user can download; mime/extension derived from the asset.
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await getSessionUser();
|
||||
if (!user) throw new ApiError(401, "unauthenticated", "Sign in required");
|
||||
|
||||
const { id } = await ctx.params;
|
||||
const file = await prisma.fileAsset.findUnique({ where: { id } });
|
||||
if (!file) throw new ApiError(404, "not_found", "File not found");
|
||||
|
||||
const bytes = await readFileBytes(file.path);
|
||||
const mime = mimeForKind(file.kind as FileKind, file.mimeType);
|
||||
|
||||
const body = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
||||
return new NextResponse(body as ArrayBuffer, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": mime,
|
||||
"content-length": String(bytes.byteLength),
|
||||
"content-disposition": `inline; filename="${encodeURIComponent(file.originalName)}"`,
|
||||
"cache-control": "private, max-age=3600",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, ApiError } from "@/lib/api";
|
||||
import { deleteFileFromDisk } from "@/lib/files";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const file = await prisma.fileAsset.findUnique({ where: { id } });
|
||||
if (!file) throw new ApiError(404, "not_found", "File not found");
|
||||
return ok({ file });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const user = await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const file = await prisma.fileAsset.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { partStep: true, partDrawing: true, partCut: true, poPdfs: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!file) throw new ApiError(404, "not_found", "File not found");
|
||||
|
||||
const refs =
|
||||
file._count.partStep +
|
||||
file._count.partDrawing +
|
||||
file._count.partCut +
|
||||
file._count.poPdfs;
|
||||
if (refs > 0) {
|
||||
throw new ApiError(
|
||||
409,
|
||||
"file_in_use",
|
||||
`File is referenced by ${refs} record(s). Detach it first.`,
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.fileAsset.delete({ where: { id } });
|
||||
await deleteFileFromDisk(file.path);
|
||||
|
||||
await audit({
|
||||
actorId: user.id,
|
||||
action: "delete",
|
||||
entity: "FileAsset",
|
||||
entityId: id,
|
||||
before: { sha256: file.sha256, originalName: file.originalName },
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, ApiError } from "@/lib/api";
|
||||
import { saveUploadedFile } from "@/lib/files";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const files = await prisma.fileAsset.findMany({
|
||||
orderBy: { uploadedAt: "desc" },
|
||||
take: 200,
|
||||
});
|
||||
return ok({ files });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await requireRole("admin");
|
||||
|
||||
const form = await req.formData().catch(() => null);
|
||||
if (!form) throw new ApiError(400, "invalid_form", "Expected multipart/form-data");
|
||||
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) {
|
||||
throw new ApiError(400, "missing_file", "Missing 'file' field");
|
||||
}
|
||||
|
||||
const saved = await saveUploadedFile(file, user.id);
|
||||
|
||||
if (!saved.deduped) {
|
||||
await audit({
|
||||
actorId: user.id,
|
||||
action: "create",
|
||||
entity: "FileAsset",
|
||||
entityId: saved.id,
|
||||
after: { sha256: saved.sha256, kind: saved.kind, originalName: saved.originalName },
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
}
|
||||
|
||||
return ok({ file: saved }, { status: saved.deduped ? 200 : 201 });
|
||||
} catch (err) {
|
||||
if (err instanceof Error && /Empty file|File too large/.test(err.message)) {
|
||||
return errorResponse(new ApiError(413, "file_rejected", err.message));
|
||||
}
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdateMachineSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const machine = await prisma.machine.findUnique({ where: { id } });
|
||||
if (!machine) throw new ApiError(404, "not_found", "Machine not found");
|
||||
return ok({ machine });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, UpdateMachineSchema);
|
||||
|
||||
const before = await prisma.machine.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Machine not found");
|
||||
|
||||
const updated = await prisma.machine.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.kind !== undefined ? { kind: body.kind } : {}),
|
||||
...(body.location !== undefined ? { location: body.location } : {}),
|
||||
...(body.notes !== undefined ? { notes: body.notes } : {}),
|
||||
...(body.active !== undefined ? { active: body.active } : {}),
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "update",
|
||||
entity: "Machine",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ machine: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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.machine.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Machine not found");
|
||||
|
||||
// soft-delete to preserve references on historical operations
|
||||
const updated = await prisma.machine.update({
|
||||
where: { id },
|
||||
data: { active: false },
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "deactivate",
|
||||
entity: "Machine",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson } from "@/lib/api";
|
||||
import { CreateMachineSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const includeInactive = req.nextUrl.searchParams.get("includeInactive") === "1";
|
||||
const machines = await prisma.machine.findMany({
|
||||
where: includeInactive ? undefined : { active: true },
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
});
|
||||
return ok({ machines });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const body = await parseJson(req, CreateMachineSchema);
|
||||
const created = await prisma.machine.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
kind: body.kind,
|
||||
location: body.location ?? null,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "Machine",
|
||||
entityId: created.id,
|
||||
after: created,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ machine: created }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdateTemplateSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const template = await prisma.operationTemplate.findUnique({
|
||||
where: { id },
|
||||
include: { machine: true },
|
||||
});
|
||||
if (!template) throw new ApiError(404, "not_found", "Template not found");
|
||||
return ok({ template });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, UpdateTemplateSchema);
|
||||
|
||||
const before = await prisma.operationTemplate.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Template not found");
|
||||
|
||||
const updated = await prisma.operationTemplate.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.machineId !== undefined ? { machineId: body.machineId } : {}),
|
||||
...(body.defaultSettings !== undefined ? { defaultSettings: body.defaultSettings } : {}),
|
||||
...(body.defaultInstructions !== undefined
|
||||
? { defaultInstructions: body.defaultInstructions }
|
||||
: {}),
|
||||
...(body.qcRequired !== undefined ? { qcRequired: body.qcRequired } : {}),
|
||||
...(body.active !== undefined ? { active: body.active } : {}),
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "update",
|
||||
entity: "OperationTemplate",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ template: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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.operationTemplate.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Template not found");
|
||||
|
||||
const updated = await prisma.operationTemplate.update({
|
||||
where: { id },
|
||||
data: { active: false },
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "deactivate",
|
||||
entity: "OperationTemplate",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson } from "@/lib/api";
|
||||
import { CreateTemplateSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const includeInactive = req.nextUrl.searchParams.get("includeInactive") === "1";
|
||||
const templates = await prisma.operationTemplate.findMany({
|
||||
where: includeInactive ? undefined : { active: true },
|
||||
include: { machine: { select: { id: true, name: true, kind: true, active: true } } },
|
||||
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||
});
|
||||
return ok({ templates });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const body = await parseJson(req, CreateTemplateSchema);
|
||||
const created = await prisma.operationTemplate.create({
|
||||
data: {
|
||||
name: body.name,
|
||||
machineId: body.machineId ?? null,
|
||||
defaultSettings: body.defaultSettings ?? null,
|
||||
defaultInstructions: body.defaultInstructions ?? null,
|
||||
qcRequired: body.qcRequired,
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "OperationTemplate",
|
||||
entityId: created.id,
|
||||
after: created,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ template: created }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdateOperationSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const operation = await prisma.operation.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
machine: { select: { id: true, name: true } },
|
||||
template: { select: { id: true, name: true } },
|
||||
claimedBy: { select: { id: true, name: true } },
|
||||
part: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
assembly: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
project: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!operation) throw new ApiError(404, "not_found", "Operation not found");
|
||||
return ok({ operation });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, UpdateOperationSchema);
|
||||
|
||||
const before = await prisma.operation.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Operation not found");
|
||||
|
||||
// Refuse to mutate a claimed / in-progress op's sequence — it would scramble
|
||||
// the QR card the operator is currently holding. Content edits are allowed.
|
||||
if (body.sequence !== undefined && body.sequence !== before.sequence) {
|
||||
if (before.status !== "pending") {
|
||||
throw new ApiError(
|
||||
409,
|
||||
"op_not_pending",
|
||||
"Cannot resequence an operation that is already in progress or completed",
|
||||
);
|
||||
}
|
||||
const conflict = await prisma.operation.findUnique({
|
||||
where: { partId_sequence: { partId: before.partId, sequence: body.sequence } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict && conflict.id !== id)
|
||||
throw new ApiError(409, "sequence_taken", `Sequence ${body.sequence} already in use`);
|
||||
}
|
||||
|
||||
const updated = await prisma.operation.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.templateId !== undefined ? { templateId: body.templateId } : {}),
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.machineId !== undefined ? { machineId: body.machineId } : {}),
|
||||
...(body.settings !== undefined ? { settings: body.settings } : {}),
|
||||
...(body.materialNotes !== undefined ? { materialNotes: body.materialNotes } : {}),
|
||||
...(body.instructions !== undefined ? { instructions: body.instructions } : {}),
|
||||
...(body.qcRequired !== undefined ? { qcRequired: body.qcRequired } : {}),
|
||||
...(body.plannedMinutes !== undefined ? { plannedMinutes: body.plannedMinutes } : {}),
|
||||
...(body.plannedUnits !== undefined ? { plannedUnits: body.plannedUnits } : {}),
|
||||
...(body.sequence !== undefined ? { sequence: body.sequence } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "update",
|
||||
entity: "Operation",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ operation: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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.operation.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Operation not found");
|
||||
if (before.status === "in_progress") {
|
||||
throw new ApiError(
|
||||
409,
|
||||
"op_in_progress",
|
||||
"Release the claim before deleting an in-progress operation",
|
||||
);
|
||||
}
|
||||
await prisma.operation.delete({ where: { id } });
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "delete",
|
||||
entity: "Operation",
|
||||
entityId: id,
|
||||
before,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { ReorderOperationsSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
/**
|
||||
* Resequence the operations on a part. Body: { order: [opId, opId, ...] }
|
||||
* The list must contain every operation currently on the part, in the
|
||||
* desired top-to-bottom order. Uses a two-phase update to stay inside the
|
||||
* (partId, sequence) unique constraint.
|
||||
*/
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const { id: partId } = await ctx.params;
|
||||
const { order } = await parseJson(req, ReorderOperationsSchema);
|
||||
|
||||
const existing = await prisma.operation.findMany({
|
||||
where: { partId },
|
||||
select: { id: true, sequence: true, status: true },
|
||||
orderBy: { sequence: "asc" },
|
||||
});
|
||||
|
||||
if (existing.length === 0) throw new ApiError(404, "not_found", "No operations on this part");
|
||||
if (order.length !== existing.length) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
"incomplete_order",
|
||||
"Order must list every operation on this part exactly once",
|
||||
);
|
||||
}
|
||||
const ids = new Set(existing.map((o) => o.id));
|
||||
const seen = new Set<string>();
|
||||
for (const id of order) {
|
||||
if (!ids.has(id)) throw new ApiError(400, "unknown_op", `Operation ${id} is not on this part`);
|
||||
if (seen.has(id)) throw new ApiError(400, "duplicate_op", `Operation ${id} listed twice`);
|
||||
seen.add(id);
|
||||
}
|
||||
|
||||
// Refuse if any non-pending op would land at a different sequence number.
|
||||
const byId = new Map(existing.map((o) => [o.id, o]));
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
const target = i + 1;
|
||||
const op = byId.get(order[i])!;
|
||||
if (op.sequence !== target && op.status !== "pending") {
|
||||
throw new ApiError(
|
||||
409,
|
||||
"op_not_pending",
|
||||
`Cannot move operation ${op.id} — it is already ${op.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Phase 1: park every row at a negative sequence to clear the unique slot.
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
await tx.operation.update({
|
||||
where: { id: order[i] },
|
||||
data: { sequence: -(i + 1) },
|
||||
});
|
||||
}
|
||||
// Phase 2: write the final sequence values.
|
||||
for (let i = 0; i < order.length; i++) {
|
||||
await tx.operation.update({
|
||||
where: { id: order[i] },
|
||||
data: { sequence: i + 1 },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "reorder",
|
||||
entity: "Part",
|
||||
entityId: partId,
|
||||
before: existing,
|
||||
after: order.map((id, i) => ({ id, sequence: i + 1 })),
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { CreateOperationSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
import { generateQrToken } from "@/lib/qr";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const operations = await prisma.operation.findMany({
|
||||
where: { partId: id },
|
||||
orderBy: { sequence: "asc" },
|
||||
include: {
|
||||
machine: { select: { id: true, name: true } },
|
||||
template: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
return ok({ operations });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, CreateOperationSchema);
|
||||
|
||||
const part = await prisma.part.findUnique({ where: { id } });
|
||||
if (!part) throw new ApiError(404, "not_found", "Part not found");
|
||||
|
||||
// If a template is referenced, fetch it so we can inherit unspecified defaults.
|
||||
let template: {
|
||||
id: string;
|
||||
machineId: string | null;
|
||||
defaultSettings: string | null;
|
||||
defaultInstructions: string | null;
|
||||
qcRequired: boolean;
|
||||
active: boolean;
|
||||
} | null = null;
|
||||
if (body.templateId) {
|
||||
template = await prisma.operationTemplate.findUnique({
|
||||
where: { id: body.templateId },
|
||||
select: {
|
||||
id: true,
|
||||
machineId: true,
|
||||
defaultSettings: true,
|
||||
defaultInstructions: true,
|
||||
qcRequired: true,
|
||||
active: true,
|
||||
},
|
||||
});
|
||||
if (!template || !template.active)
|
||||
throw new ApiError(400, "invalid_template", "Operation template not available");
|
||||
}
|
||||
|
||||
// Resolve sequence: explicit value wins, else append to end.
|
||||
let sequence = body.sequence;
|
||||
if (!sequence) {
|
||||
const max = await prisma.operation.aggregate({
|
||||
where: { partId: id },
|
||||
_max: { sequence: true },
|
||||
});
|
||||
sequence = (max._max.sequence ?? 0) + 1;
|
||||
} else {
|
||||
const conflict = await prisma.operation.findUnique({
|
||||
where: { partId_sequence: { partId: id, sequence } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (conflict)
|
||||
throw new ApiError(409, "sequence_taken", `Sequence ${sequence} already in use on this part`);
|
||||
}
|
||||
|
||||
// Derive effective values, falling back to the template where the caller left fields blank.
|
||||
const effectiveMachineId =
|
||||
body.machineId !== undefined ? body.machineId : (template?.machineId ?? null);
|
||||
const effectiveSettings =
|
||||
body.settings !== undefined && body.settings !== null
|
||||
? body.settings
|
||||
: (template?.defaultSettings ?? null);
|
||||
const effectiveInstructions =
|
||||
body.instructions !== undefined && body.instructions !== null
|
||||
? body.instructions
|
||||
: (template?.defaultInstructions ?? null);
|
||||
const effectiveQcRequired = body.qcRequired || template?.qcRequired || false;
|
||||
|
||||
// Generate a unique qrToken. Collisions at 192 bits are vanishingly rare; retry anyway.
|
||||
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");
|
||||
}
|
||||
|
||||
const created = await prisma.operation.create({
|
||||
data: {
|
||||
partId: id,
|
||||
sequence,
|
||||
templateId: template?.id ?? null,
|
||||
name: body.name,
|
||||
machineId: effectiveMachineId,
|
||||
settings: effectiveSettings,
|
||||
materialNotes: body.materialNotes ?? null,
|
||||
instructions: effectiveInstructions,
|
||||
qcRequired: effectiveQcRequired,
|
||||
plannedMinutes: body.plannedMinutes ?? null,
|
||||
plannedUnits: body.plannedUnits ?? null,
|
||||
qrToken,
|
||||
},
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "Operation",
|
||||
entityId: created.id,
|
||||
after: created,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ operation: created }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdatePartSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const part = await prisma.part.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assembly: {
|
||||
include: { project: { select: { id: true, code: true, name: true } } },
|
||||
},
|
||||
stepFile: true,
|
||||
drawingFile: true,
|
||||
cutFile: true,
|
||||
operations: {
|
||||
orderBy: { sequence: "asc" },
|
||||
include: { machine: { select: { id: true, name: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!part) throw new ApiError(404, "not_found", "Part not found");
|
||||
return ok({ part });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, UpdatePartSchema);
|
||||
|
||||
const before = await prisma.part.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Part not found");
|
||||
|
||||
const updated = await prisma.part.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.code !== undefined ? { code: body.code } : {}),
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.material !== undefined ? { material: body.material } : {}),
|
||||
...(body.qty !== undefined ? { qty: body.qty } : {}),
|
||||
...(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({
|
||||
actorId: actor.id,
|
||||
action: "update",
|
||||
entity: "Part",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ part: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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.part.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Part not found");
|
||||
await prisma.part.delete({ where: { id } });
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "delete",
|
||||
entity: "Part",
|
||||
entityId: id,
|
||||
before,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { CreateAssemblySchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const assemblies = await prisma.assembly.findMany({
|
||||
where: { projectId: id },
|
||||
orderBy: { code: "asc" },
|
||||
include: { _count: { select: { parts: true } } },
|
||||
});
|
||||
return ok({ assemblies });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, CreateAssemblySchema);
|
||||
|
||||
const project = await prisma.project.findUnique({ where: { id } });
|
||||
if (!project) throw new ApiError(404, "not_found", "Project not found");
|
||||
|
||||
const created = await prisma.assembly.create({
|
||||
data: {
|
||||
projectId: id,
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
qty: body.qty,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "Assembly",
|
||||
entityId: created.id,
|
||||
after: created,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ assembly: created }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdateProjectSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
assemblies: {
|
||||
orderBy: { code: "asc" },
|
||||
include: { _count: { select: { parts: true } } },
|
||||
},
|
||||
fasteners: true,
|
||||
purchaseOrders: { orderBy: { createdAt: "desc" } },
|
||||
},
|
||||
});
|
||||
if (!project) throw new ApiError(404, "not_found", "Project not found");
|
||||
return ok({ project });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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, UpdateProjectSchema);
|
||||
|
||||
const before = await prisma.project.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Project not found");
|
||||
|
||||
const updated = await prisma.project.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.code !== undefined ? { code: body.code } : {}),
|
||||
...(body.name !== undefined ? { name: body.name } : {}),
|
||||
...(body.customerCode !== undefined ? { customerCode: body.customerCode } : {}),
|
||||
...(body.dueDate !== undefined ? { dueDate: body.dueDate } : {}),
|
||||
...(body.status !== undefined ? { status: body.status } : {}),
|
||||
...(body.notes !== undefined ? { notes: body.notes } : {}),
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "update",
|
||||
entity: "Project",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ project: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
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.project.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Project not found");
|
||||
await prisma.project.delete({ where: { id } });
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "delete",
|
||||
entity: "Project",
|
||||
entityId: id,
|
||||
before,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson } from "@/lib/api";
|
||||
import { CreateProjectSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const projects = await prisma.project.findMany({
|
||||
orderBy: [{ createdAt: "desc" }],
|
||||
include: {
|
||||
_count: { select: { assemblies: true } },
|
||||
},
|
||||
});
|
||||
return ok({ projects });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const body = await parseJson(req, CreateProjectSchema);
|
||||
const created = await prisma.project.create({
|
||||
data: {
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
customerCode: body.customerCode ?? null,
|
||||
dueDate: body.dueDate ?? null,
|
||||
notes: body.notes ?? null,
|
||||
},
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "Project",
|
||||
entityId: created.id,
|
||||
after: created,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ project: created }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdateUserSchema } from "@/lib/schemas";
|
||||
import { hashPassword, hashPin } from "@/lib/password";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
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, UpdateUserSchema);
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new ApiError(404, "not_found", "User not found");
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) data.name = body.name;
|
||||
if (body.active !== undefined) data.active = body.active;
|
||||
|
||||
if (user.role === "admin") {
|
||||
if (body.email) {
|
||||
const dup = await prisma.user.findFirst({
|
||||
where: { email: body.email, id: { not: id } },
|
||||
});
|
||||
if (dup) throw new ApiError(409, "duplicate", "Email already in use");
|
||||
data.email = body.email;
|
||||
}
|
||||
if (body.password) data.passwordHash = await hashPassword(body.password);
|
||||
if (body.pin) {
|
||||
throw new ApiError(400, "invalid_field", "Admins don't have PINs");
|
||||
}
|
||||
} else {
|
||||
if (body.email || body.password) {
|
||||
throw new ApiError(400, "invalid_field", "Operators use PIN, not email/password");
|
||||
}
|
||||
if (body.pin) {
|
||||
data.pinHash = await hashPin(body.pin);
|
||||
data.failedAttempts = 0;
|
||||
data.lockedUntil = null;
|
||||
}
|
||||
if (body.name && body.name !== user.name) {
|
||||
const dup = await prisma.user.findFirst({
|
||||
where: { role: "operator", name: body.name, id: { not: id } },
|
||||
});
|
||||
if (dup) throw new ApiError(409, "duplicate", "Operator name already in use");
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
where: { id },
|
||||
data,
|
||||
select: { id: true, role: true, name: true, email: true, active: true },
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "update",
|
||||
entity: "User",
|
||||
entityId: id,
|
||||
after: {
|
||||
changed: Object.keys(data).filter((k) => k !== "passwordHash" && k !== "pinHash"),
|
||||
secretsChanged: "passwordHash" in data || "pinHash" in data,
|
||||
},
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ user: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
|
||||
if (id === actor.id) {
|
||||
throw new ApiError(400, "self_delete", "You cannot delete your own account");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new ApiError(404, "not_found", "User not found");
|
||||
|
||||
if (user.role === "admin") {
|
||||
const otherAdmins = await prisma.user.count({
|
||||
where: { role: "admin", active: true, id: { not: id } },
|
||||
});
|
||||
if (otherAdmins === 0) {
|
||||
throw new ApiError(400, "last_admin", "Cannot remove the last active admin");
|
||||
}
|
||||
}
|
||||
|
||||
// soft-delete: deactivate and remove all sessions
|
||||
await prisma.$transaction([
|
||||
prisma.session.deleteMany({ where: { userId: id } }),
|
||||
prisma.user.update({ where: { id }, data: { active: false } }),
|
||||
]);
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "deactivate",
|
||||
entity: "User",
|
||||
entityId: id,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { CreateUserSchema } from "@/lib/schemas";
|
||||
import { hashPassword, hashPin } from "@/lib/password";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const users = await prisma.user.findMany({
|
||||
orderBy: [{ role: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
name: true,
|
||||
email: true,
|
||||
active: true,
|
||||
lastLoginAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
return ok({ users });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const body = await parseJson(req, CreateUserSchema);
|
||||
|
||||
if (body.role === "admin") {
|
||||
const existing = await prisma.user.findUnique({ where: { email: body.email } });
|
||||
if (existing) throw new ApiError(409, "duplicate", "Email already in use");
|
||||
const passwordHash = await hashPassword(body.password);
|
||||
const created = await prisma.user.create({
|
||||
data: { role: "admin", name: body.name, email: body.email, passwordHash },
|
||||
select: { id: true, role: true, name: true, email: true, active: true, createdAt: true },
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "User",
|
||||
entityId: created.id,
|
||||
after: { role: "admin", name: created.name, email: created.email },
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ user: created }, { status: 201 });
|
||||
}
|
||||
|
||||
// operator: name must be unique (case-insensitive) so login tile grid is unambiguous
|
||||
const nameNorm = body.name.trim();
|
||||
const dupe = await prisma.user.findFirst({
|
||||
where: {
|
||||
role: "operator",
|
||||
name: { equals: nameNorm },
|
||||
},
|
||||
});
|
||||
if (dupe) throw new ApiError(409, "duplicate", "Operator name already in use");
|
||||
|
||||
const pinHash = await hashPin(body.pin);
|
||||
const created = await prisma.user.create({
|
||||
data: { role: "operator", name: nameNorm, pinHash },
|
||||
select: { id: true, role: true, name: true, email: true, active: true, createdAt: true },
|
||||
});
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "create",
|
||||
entity: "User",
|
||||
entityId: created.id,
|
||||
after: { role: "operator", name: created.name },
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ user: created }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef, type ButtonHTMLAttributes, type InputHTMLAttributes, type SelectHTMLAttributes, type TextareaHTMLAttributes, type ReactNode } from "react";
|
||||
|
||||
function cx(...parts: Array<string | false | null | undefined>): string {
|
||||
return parts.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
// -------- Button ---------------------------------------------------------
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
|
||||
type ButtonSize = "sm" | "md";
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: "bg-slate-900 text-white hover:bg-slate-800 border-slate-900",
|
||||
secondary: "bg-white text-slate-900 border-slate-300 hover:bg-slate-50",
|
||||
danger: "bg-red-600 text-white border-red-600 hover:bg-red-500",
|
||||
ghost: "bg-transparent text-slate-700 border-transparent hover:bg-slate-100",
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: "px-2.5 py-1.5 text-sm rounded-md",
|
||||
md: "px-4 py-2 text-sm rounded-lg",
|
||||
};
|
||||
|
||||
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
|
||||
{ variant = "primary", size = "md", className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cx(
|
||||
"inline-flex items-center justify-center gap-1.5 border font-medium transition disabled:opacity-60 disabled:cursor-not-allowed",
|
||||
variantClasses[variant],
|
||||
sizeClasses[size],
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
// -------- Field label wrapper --------------------------------------------
|
||||
|
||||
export function Field({
|
||||
label,
|
||||
hint,
|
||||
error,
|
||||
children,
|
||||
required,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: ReactNode;
|
||||
error?: string | null;
|
||||
children: ReactNode;
|
||||
required?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="block text-sm font-medium text-slate-700 mb-1">
|
||||
{label}
|
||||
{required ? <span className="text-red-600"> *</span> : null}
|
||||
</span>
|
||||
{children}
|
||||
{hint && !error ? <span className="block text-xs text-slate-500 mt-1">{hint}</span> : null}
|
||||
{error ? <span className="block text-xs text-red-600 mt-1">{error}</span> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Input / Textarea / Select --------------------------------------
|
||||
|
||||
const inputBase =
|
||||
"w-full rounded-lg border border-slate-300 px-3 py-2 text-sm outline-none focus:border-slate-900 focus:ring-2 focus:ring-slate-200 disabled:bg-slate-50";
|
||||
|
||||
type InputProps = InputHTMLAttributes<HTMLInputElement>;
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <input ref={ref} {...props} className={cx(inputBase, className)} />;
|
||||
});
|
||||
|
||||
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
|
||||
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cx(inputBase, "min-h-[5rem] font-mono leading-relaxed", className)}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
type SelectProps = SelectHTMLAttributes<HTMLSelectElement>;
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select(
|
||||
{ className, ...props },
|
||||
ref,
|
||||
) {
|
||||
return <select ref={ref} {...props} className={cx(inputBase, "bg-white pr-8", className)} />;
|
||||
});
|
||||
|
||||
// -------- Card / PageHeader / Table --------------------------------------
|
||||
|
||||
export function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cx("rounded-xl bg-white border border-slate-200 shadow-sm", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
description,
|
||||
actions,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
description?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
{description ? <p className="text-slate-500 mt-1">{description}</p> : null}
|
||||
</div>
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Empty({ title, description }: { title: string; description?: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-dashed border-slate-300 bg-white p-10 text-center">
|
||||
<p className="font-medium text-slate-700">{title}</p>
|
||||
{description ? <p className="text-sm text-slate-500 mt-1">{description}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBanner({ message }: { message: string | null | undefined }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2">
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Modal ----------------------------------------------------------
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
}) {
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/40">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="w-full max-w-lg rounded-2xl bg-white shadow-xl border border-slate-200"
|
||||
>
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-200">
|
||||
<h2 className="font-semibold">{title}</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-md p-1 text-slate-400 hover:bg-slate-100 hover:text-slate-900"
|
||||
aria-label="Close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4 max-h-[70vh] overflow-y-auto">{children}</div>
|
||||
{footer ? (
|
||||
<div className="flex justify-end gap-2 p-4 border-t border-slate-200 bg-slate-50 rounded-b-2xl">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Badge ----------------------------------------------------------
|
||||
|
||||
export function Badge({
|
||||
children,
|
||||
tone = "slate",
|
||||
}: {
|
||||
children: ReactNode;
|
||||
tone?: "slate" | "green" | "amber" | "red" | "blue";
|
||||
}) {
|
||||
const tones: Record<string, string> = {
|
||||
slate: "bg-slate-100 text-slate-700",
|
||||
green: "bg-emerald-100 text-emerald-700",
|
||||
amber: "bg-amber-100 text-amber-800",
|
||||
red: "bg-red-100 text-red-700",
|
||||
blue: "bg-blue-100 text-blue-700",
|
||||
};
|
||||
return (
|
||||
<span className={cx("inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium", tones[tone])}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { ZodError, type ZodSchema } from "zod";
|
||||
import { getSessionUser, type Role, type SessionUser } from "@/lib/session";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public code: string, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export function errorResponse(err: unknown): NextResponse {
|
||||
if (err instanceof ApiError) {
|
||||
return NextResponse.json({ error: err.message, code: err.code }, { status: err.status });
|
||||
}
|
||||
if (err instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: "Validation failed",
|
||||
code: "validation_failed",
|
||||
issues: err.issues.map((i) => ({ path: i.path, message: i.message })),
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
if (
|
||||
err &&
|
||||
typeof err === "object" &&
|
||||
"code" in err &&
|
||||
typeof (err as { code: unknown }).code === "string" &&
|
||||
((err as { code: string }).code === "P2002" || (err as { code: string }).code === "P2003")
|
||||
) {
|
||||
const p = err as { code: string; meta?: { target?: unknown } };
|
||||
const msg =
|
||||
p.code === "P2002"
|
||||
? `Duplicate value${p.meta?.target ? ` on ${JSON.stringify(p.meta.target)}` : ""}`
|
||||
: "Foreign key constraint failed";
|
||||
return NextResponse.json(
|
||||
{ error: msg, code: p.code === "P2002" ? "duplicate" : "fk_violation" },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
console.error("[api] unhandled error:", err);
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error", code: "internal" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function requireRole(role: Role): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) throw new ApiError(401, "unauthenticated", "Sign in required");
|
||||
if (user.role !== role) throw new ApiError(403, "forbidden", "Not allowed");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function parseJson<T>(req: NextRequest, schema: ZodSchema<T>): Promise<T> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await req.json();
|
||||
} catch {
|
||||
throw new ApiError(400, "invalid_json", "Expected JSON body");
|
||||
}
|
||||
return schema.parse(raw);
|
||||
}
|
||||
|
||||
export function actorContext(req: NextRequest, user: SessionUser) {
|
||||
return { actorId: user.id, ipAddress: clientIp(req) };
|
||||
}
|
||||
|
||||
export function ok<T>(data: T, init?: ResponseInit): NextResponse {
|
||||
return NextResponse.json(data, init);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export interface ApiFailure {
|
||||
error: string;
|
||||
code?: string;
|
||||
issues?: Array<{ path: unknown; message: string }>;
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
constructor(public status: number, public code: string | undefined, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(
|
||||
input: string,
|
||||
init: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
if (init.body && !headers.has("content-type") && !(init.body instanceof FormData)) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const res = await fetch(input, { ...init, headers });
|
||||
const text = await res.text();
|
||||
const data = text ? (JSON.parse(text) as unknown) : undefined;
|
||||
if (!res.ok) {
|
||||
const fail = (data ?? { error: res.statusText }) as ApiFailure;
|
||||
throw new ApiClientError(res.status, fail.code, fail.error);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
+18
-1
@@ -18,7 +18,24 @@ const EnvSchema = z.object({
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
function load(): Env {
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
// During `next build` page-data collection the route modules are evaluated
|
||||
// without real secrets — fall back to safe placeholders so the build can
|
||||
// emit the module graph. Real runtime still re-validates at request time.
|
||||
const isBuildPhase =
|
||||
process.env.NEXT_PHASE === "phase-production-build" ||
|
||||
process.env.NEXT_BUILD === "true";
|
||||
|
||||
const source = isBuildPhase
|
||||
? {
|
||||
...process.env,
|
||||
DATABASE_URL: process.env.DATABASE_URL ?? "file:./data/build-placeholder.db",
|
||||
APP_SECRET:
|
||||
process.env.APP_SECRET ??
|
||||
"build-time-placeholder-secret-please-override-at-runtime",
|
||||
}
|
||||
: process.env;
|
||||
|
||||
const parsed = EnvSchema.safeParse(source);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, writeFile, readFile, unlink, access } from "node:fs/promises";
|
||||
import { join, extname, resolve } from "node:path";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export const FILE_KINDS = ["step", "pdf", "dxf", "svg", "png", "jpg", "other"] as const;
|
||||
export type FileKind = (typeof FILE_KINDS)[number];
|
||||
|
||||
const EXT_TO_KIND: Record<string, FileKind> = {
|
||||
".step": "step",
|
||||
".stp": "step",
|
||||
".pdf": "pdf",
|
||||
".dxf": "dxf",
|
||||
".svg": "svg",
|
||||
".png": "png",
|
||||
".jpg": "jpg",
|
||||
".jpeg": "jpg",
|
||||
};
|
||||
|
||||
const MAX_SIZE = 100 * 1024 * 1024; // 100 MB per file
|
||||
|
||||
export function classifyKind(filename: string, mimeType?: string | null): FileKind {
|
||||
const ext = extname(filename).toLowerCase();
|
||||
if (EXT_TO_KIND[ext]) return EXT_TO_KIND[ext];
|
||||
if (mimeType?.startsWith("image/")) {
|
||||
if (mimeType.includes("png")) return "png";
|
||||
if (mimeType.includes("jpeg") || mimeType.includes("jpg")) return "jpg";
|
||||
if (mimeType.includes("svg")) return "svg";
|
||||
}
|
||||
if (mimeType === "application/pdf") return "pdf";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function uploadRoot(): string {
|
||||
return resolve(env.UPLOAD_DIR);
|
||||
}
|
||||
|
||||
export function storagePathFor(sha256: string, kind: FileKind, originalName: string): {
|
||||
relative: string;
|
||||
absolute: string;
|
||||
} {
|
||||
const ext = extname(originalName).toLowerCase() || `.${kind}`;
|
||||
const shard = sha256.slice(0, 2);
|
||||
const relative = join(kind, shard, `${sha256}${ext}`);
|
||||
return {
|
||||
relative,
|
||||
absolute: join(uploadRoot(), relative),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fileExistsOnDisk(absolutePath: string): Promise<boolean> {
|
||||
try {
|
||||
await access(absolutePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SavedFile {
|
||||
id: string;
|
||||
kind: FileKind;
|
||||
sha256: string;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
originalName: string;
|
||||
mimeType: string | null;
|
||||
deduped: boolean;
|
||||
}
|
||||
|
||||
export async function saveUploadedFile(
|
||||
file: File,
|
||||
uploadedBy: string | null,
|
||||
): Promise<SavedFile> {
|
||||
if (file.size === 0) throw new Error("Empty file");
|
||||
if (file.size > MAX_SIZE) throw new Error(`File too large (max ${MAX_SIZE / 1024 / 1024}MB)`);
|
||||
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
const sha256 = createHash("sha256").update(buffer).digest("hex");
|
||||
const kind = classifyKind(file.name, file.type || null);
|
||||
|
||||
const existing = await prisma.fileAsset.findUnique({ where: { sha256 } });
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
kind: existing.kind as FileKind,
|
||||
sha256: existing.sha256,
|
||||
path: existing.path,
|
||||
sizeBytes: existing.sizeBytes,
|
||||
originalName: existing.originalName,
|
||||
mimeType: existing.mimeType,
|
||||
deduped: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { relative, absolute } = storagePathFor(sha256, kind, file.name);
|
||||
await mkdir(join(absolute, ".."), { recursive: true });
|
||||
await writeFile(absolute, buffer);
|
||||
|
||||
const asset = await prisma.fileAsset.create({
|
||||
data: {
|
||||
kind,
|
||||
originalName: file.name,
|
||||
path: relative,
|
||||
sizeBytes: file.size,
|
||||
mimeType: file.type || null,
|
||||
sha256,
|
||||
uploadedBy,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: asset.id,
|
||||
kind: asset.kind as FileKind,
|
||||
sha256: asset.sha256,
|
||||
path: asset.path,
|
||||
sizeBytes: asset.sizeBytes,
|
||||
originalName: asset.originalName,
|
||||
mimeType: asset.mimeType,
|
||||
deduped: false,
|
||||
};
|
||||
}
|
||||
|
||||
export async function readFileBytes(relative: string): Promise<Buffer> {
|
||||
const absolute = resolve(uploadRoot(), relative);
|
||||
const root = uploadRoot();
|
||||
if (!absolute.startsWith(root + "/") && !absolute.startsWith(root + "\\") && absolute !== root) {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
return readFile(absolute);
|
||||
}
|
||||
|
||||
export async function deleteFileFromDisk(relative: string): Promise<void> {
|
||||
const absolute = resolve(uploadRoot(), relative);
|
||||
const root = uploadRoot();
|
||||
if (!absolute.startsWith(root + "/") && !absolute.startsWith(root + "\\") && absolute !== root) {
|
||||
throw new Error("Invalid file path");
|
||||
}
|
||||
await unlink(absolute).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function mimeForKind(kind: FileKind, originalMime: string | null): string {
|
||||
if (originalMime) return originalMime;
|
||||
switch (kind) {
|
||||
case "pdf":
|
||||
return "application/pdf";
|
||||
case "png":
|
||||
return "image/png";
|
||||
case "jpg":
|
||||
return "image/jpeg";
|
||||
case "svg":
|
||||
return "image/svg+xml";
|
||||
case "step":
|
||||
return "application/step";
|
||||
case "dxf":
|
||||
return "application/dxf";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Operation QR tokens are opaque, URL-safe, high-entropy identifiers
|
||||
* printed on traveler cards. 24 bytes = 192 bits of entropy, encoded
|
||||
* as base64url -> 32 characters.
|
||||
*/
|
||||
export function generateQrToken(): string {
|
||||
return randomBytes(24)
|
||||
.toString("base64")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
import { z } from "zod";
|
||||
|
||||
// ---- shared --------------------------------------------------------------
|
||||
|
||||
const NonEmpty = z.string().trim().min(1, "Required").max(200);
|
||||
const Code = z.string().trim().min(1).max(64).regex(/^[A-Za-z0-9._\-/]+$/, "Use letters, digits, . _ - /");
|
||||
const OptionalText = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(5000)
|
||||
.transform((v) => (v.length === 0 ? null : v))
|
||||
.nullable()
|
||||
.optional();
|
||||
const JsonString = z
|
||||
.string()
|
||||
.max(10_000)
|
||||
.refine(
|
||||
(s) => {
|
||||
if (s.length === 0) return true;
|
||||
try {
|
||||
JSON.parse(s);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: "Must be valid JSON" },
|
||||
)
|
||||
.transform((s) => (s.length === 0 ? null : s))
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const Pin = z.string().regex(/^\d{4}$/, "PIN must be exactly 4 digits");
|
||||
|
||||
// ---- users ---------------------------------------------------------------
|
||||
|
||||
export const CreateAdminSchema = z.object({
|
||||
role: z.literal("admin"),
|
||||
name: NonEmpty,
|
||||
email: z.string().email().max(200),
|
||||
password: z.string().min(8).max(200),
|
||||
});
|
||||
|
||||
export const CreateOperatorSchema = z.object({
|
||||
role: z.literal("operator"),
|
||||
name: NonEmpty,
|
||||
pin: Pin,
|
||||
});
|
||||
|
||||
export const CreateUserSchema = z.discriminatedUnion("role", [
|
||||
CreateAdminSchema,
|
||||
CreateOperatorSchema,
|
||||
]);
|
||||
|
||||
export const UpdateUserSchema = z
|
||||
.object({
|
||||
name: NonEmpty.optional(),
|
||||
active: z.boolean().optional(),
|
||||
email: z.string().email().max(200).optional(),
|
||||
password: z.string().min(8).max(200).optional(),
|
||||
pin: Pin.optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---- machines ------------------------------------------------------------
|
||||
|
||||
export const MachineKinds = [
|
||||
"NCT_PUNCH",
|
||||
"PRESS_BRAKE",
|
||||
"RIVET",
|
||||
"WELD",
|
||||
"LASER",
|
||||
"SHEAR",
|
||||
"ASSEMBLY",
|
||||
"OTHER",
|
||||
] as const;
|
||||
|
||||
export const CreateMachineSchema = z.object({
|
||||
name: NonEmpty,
|
||||
kind: z.enum(MachineKinds),
|
||||
location: OptionalText,
|
||||
notes: OptionalText,
|
||||
});
|
||||
|
||||
export const UpdateMachineSchema = z
|
||||
.object({
|
||||
name: NonEmpty.optional(),
|
||||
kind: z.enum(MachineKinds).optional(),
|
||||
location: OptionalText,
|
||||
notes: OptionalText,
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---- operation templates -------------------------------------------------
|
||||
|
||||
export const CreateTemplateSchema = z.object({
|
||||
name: NonEmpty,
|
||||
machineId: z.string().min(1).nullable().optional(),
|
||||
defaultSettings: JsonString,
|
||||
defaultInstructions: OptionalText,
|
||||
qcRequired: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export const UpdateTemplateSchema = z
|
||||
.object({
|
||||
name: NonEmpty.optional(),
|
||||
machineId: z.string().min(1).nullable().optional(),
|
||||
defaultSettings: JsonString,
|
||||
defaultInstructions: OptionalText,
|
||||
qcRequired: z.boolean().optional(),
|
||||
active: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---- projects ------------------------------------------------------------
|
||||
|
||||
export const ProjectStatuses = ["planning", "in_progress", "completed", "cancelled"] as const;
|
||||
|
||||
export const CreateProjectSchema = z.object({
|
||||
code: Code,
|
||||
name: NonEmpty,
|
||||
customerCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(64)
|
||||
.transform((v) => (v.length === 0 ? null : v))
|
||||
.nullable()
|
||||
.optional(),
|
||||
dueDate: z.coerce.date().nullable().optional(),
|
||||
notes: OptionalText,
|
||||
});
|
||||
|
||||
export const UpdateProjectSchema = z
|
||||
.object({
|
||||
code: Code.optional(),
|
||||
name: NonEmpty.optional(),
|
||||
customerCode: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(64)
|
||||
.transform((v) => (v.length === 0 ? null : v))
|
||||
.nullable()
|
||||
.optional(),
|
||||
dueDate: z.coerce.date().nullable().optional(),
|
||||
status: z.enum(ProjectStatuses).optional(),
|
||||
notes: OptionalText,
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---- assemblies / parts --------------------------------------------------
|
||||
|
||||
export const CreateAssemblySchema = z.object({
|
||||
code: Code,
|
||||
name: NonEmpty,
|
||||
qty: z.coerce.number().int().positive().max(100000).default(1),
|
||||
notes: OptionalText,
|
||||
});
|
||||
|
||||
export const UpdateAssemblySchema = z
|
||||
.object({
|
||||
code: Code.optional(),
|
||||
name: NonEmpty.optional(),
|
||||
qty: z.coerce.number().int().positive().max(100000).optional(),
|
||||
notes: OptionalText,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const CreatePartSchema = z.object({
|
||||
code: Code,
|
||||
name: NonEmpty,
|
||||
material: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(120)
|
||||
.transform((v) => (v.length === 0 ? null : v))
|
||||
.nullable()
|
||||
.optional(),
|
||||
qty: z.coerce.number().int().positive().max(100000).default(1),
|
||||
notes: OptionalText,
|
||||
});
|
||||
|
||||
export const UpdatePartSchema = z
|
||||
.object({
|
||||
code: Code.optional(),
|
||||
name: NonEmpty.optional(),
|
||||
material: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(120)
|
||||
.transform((v) => (v.length === 0 ? null : v))
|
||||
.nullable()
|
||||
.optional(),
|
||||
qty: z.coerce.number().int().positive().max(100000).optional(),
|
||||
notes: OptionalText,
|
||||
stepFileId: z.string().min(1).nullable().optional(),
|
||||
drawingFileId: z.string().min(1).nullable().optional(),
|
||||
cutFileId: z.string().min(1).nullable().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
// ---- operations ---------------------------------------------------------
|
||||
|
||||
export const OperationStatuses = ["pending", "in_progress", "completed"] as const;
|
||||
|
||||
export const CreateOperationSchema = z.object({
|
||||
templateId: z.string().min(1).nullable().optional(),
|
||||
name: NonEmpty,
|
||||
machineId: z.string().min(1).nullable().optional(),
|
||||
settings: JsonString,
|
||||
materialNotes: OptionalText,
|
||||
instructions: OptionalText,
|
||||
qcRequired: z.boolean().default(false),
|
||||
plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(),
|
||||
plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(),
|
||||
sequence: z.coerce.number().int().positive().max(10000).optional(),
|
||||
});
|
||||
|
||||
export const UpdateOperationSchema = z
|
||||
.object({
|
||||
templateId: z.string().min(1).nullable().optional(),
|
||||
name: NonEmpty.optional(),
|
||||
machineId: z.string().min(1).nullable().optional(),
|
||||
settings: JsonString,
|
||||
materialNotes: OptionalText,
|
||||
instructions: OptionalText,
|
||||
qcRequired: z.boolean().optional(),
|
||||
plannedMinutes: z.coerce.number().int().positive().max(100000).nullable().optional(),
|
||||
plannedUnits: z.coerce.number().int().positive().max(100000).nullable().optional(),
|
||||
sequence: z.coerce.number().int().positive().max(10000).optional(),
|
||||
status: z.enum(OperationStatuses).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ReorderOperationsSchema = z.object({
|
||||
order: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user