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