phase 2 and 3

This commit is contained in:
jason
2026-04-21 08:56:51 -05:00
parent b98837a72c
commit d79aaf6ef8
42 changed files with 4962 additions and 19 deletions
+3 -1
View File
@@ -5,7 +5,9 @@
"Bash(npm install *)", "Bash(npm install *)",
"Bash(DATABASE_URL=\"file:./dev.db\" npx prisma migrate dev --name init --skip-seed)", "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\" 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 *)"
] ]
} }
} }
+196
View File
@@ -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>
);
}
+15
View File
@@ -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() }))}
/>
);
}
+235
View File
@@ -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>
);
}
+33
View File
@@ -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
View File
@@ -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 ( return (
<div className="mx-auto max-w-7xl px-4 py-8"> <div className="mx-auto max-w-7xl px-4 py-8">
<h1 className="text-2xl font-semibold">Dashboard</h1> <h1 className="text-2xl font-semibold tracking-tight">Dashboard</h1>
<p className="text-slate-500 mt-1"> <p className="text-slate-500 mt-1">Snapshot of the shop. Click any tile to dive in.</p>
Project planning, machines, operations, and users will appear here as each area is built.
</p>
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <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." /> <Tile
<Card title="Machines" desc="Manage shop-floor equipment." /> href="/admin/projects"
<Card title="Operation templates" desc="Reusable step recipes." /> title="Projects"
<Card title="Fasteners & POs" desc="Aggregate BOM, generate purchase orders." /> primary={projectsTotal}
<Card title="Users" desc="Admins and operator PIN accounts." /> secondary={`${projectsActive} active · ${assembliesTotal} assemblies · ${partsTotal} parts`}
<Card title="Audit log" desc="Who did what, when." /> />
<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>
</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 ( return (
<div className="rounded-xl bg-white border border-slate-200 p-5"> <Link
<h2 className="font-medium">{title}</h2> href={href}
<p className="text-sm text-slate-500 mt-1">{desc}</p> className="block rounded-xl bg-white border border-slate-200 p-5 transition hover:border-slate-400 hover:shadow-sm"
</div> >
<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>
); );
} }
+279
View File
@@ -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&quot;">
<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,
}))}
/>
);
}
+48
View File
@@ -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,
}))}
/>
);
}
+24
View File
@@ -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,
}))}
/>
);
}
+287
View File
@@ -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>
);
}
+20
View File
@@ -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() }))} />;
}
+54
View File
@@ -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);
}
}
+78
View File
@@ -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);
}
}
+33
View File
@@ -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);
}
}
+64
View File
@@ -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);
}
}
+53
View File
@@ -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);
}
}
+79
View File
@@ -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);
}
}
+46
View File
@@ -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);
}
}
+48
View File
@@ -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);
}
}
+126
View File
@@ -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);
}
}
+133
View File
@@ -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);
}
}
+90
View File
@@ -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);
}
}
+84
View File
@@ -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);
}
}
+48
View File
@@ -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);
}
}
+114
View File
@@ -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);
}
}
+81
View File
@@ -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);
}
}
+226
View File
@@ -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
View File
@@ -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);
}
+29
View File
@@ -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
View File
@@ -18,7 +18,24 @@ const EnvSchema = z.object({
export type Env = z.infer<typeof EnvSchema>; export type Env = z.infer<typeof EnvSchema>;
function load(): Env { 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) { if (!parsed.success) {
const issues = parsed.error.issues const issues = parsed.error.issues
.map((i) => ` - ${i.path.join(".")}: ${i.message}`) .map((i) => ` - ${i.path.join(".")}: ${i.message}`)
+162
View File
@@ -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";
}
}
+14
View File
@@ -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
View File
@@ -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),
});
+1 -1
View File
File diff suppressed because one or more lines are too long