+20
-4
@@ -1,9 +1,17 @@
|
||||
import Link from "next/link";
|
||||
import { requireOperator } from "@/lib/auth";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import LogoutButton from "@/components/LogoutButton";
|
||||
|
||||
/**
|
||||
* The /op layout intentionally does NOT force-redirect unauthenticated
|
||||
* visitors. The scan page (/op/scan/[token]) needs to bounce them to
|
||||
* /login/operator?next=<path> so they come back to the same QR card after
|
||||
* signing in; a blanket redirect here would lose that context. Each page
|
||||
* under /op is responsible for its own auth gate (see requireOperator in
|
||||
* lib/auth.ts, or the scan page's custom redirect).
|
||||
*/
|
||||
export default async function OperatorLayout({ children }: { children: React.ReactNode }) {
|
||||
const user = await requireOperator();
|
||||
const user = await getCurrentUser();
|
||||
|
||||
return (
|
||||
<div className="min-h-dvh flex flex-col">
|
||||
@@ -12,8 +20,16 @@ export default async function OperatorLayout({ children }: { children: React.Rea
|
||||
<Link href="/op" className="font-semibold tracking-tight">
|
||||
MRP
|
||||
</Link>
|
||||
<span className="ml-auto text-sm text-slate-500">{user.name}</span>
|
||||
<LogoutButton />
|
||||
{user ? (
|
||||
<>
|
||||
<span className="ml-auto text-sm text-slate-500">{user.name}</span>
|
||||
<LogoutButton />
|
||||
</>
|
||||
) : (
|
||||
<Link href="/login/operator" className="ml-auto text-sm text-slate-900 hover:underline">
|
||||
Sign in
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<main className="flex-1">{children}</main>
|
||||
|
||||
+69
-9
@@ -1,16 +1,76 @@
|
||||
export default function OperatorHomePage() {
|
||||
import Link from "next/link";
|
||||
import { requireOperator } from "@/lib/auth";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
/**
|
||||
* Operator dashboard. Because phones often have the browser open between scans,
|
||||
* we want this page to answer one question fast: "what am I currently working
|
||||
* on?" Active claims are the headline list; below that we show a generic
|
||||
* "scan a card to start" hint so a fresh operator knows what to do.
|
||||
*/
|
||||
export default async function OperatorHomePage() {
|
||||
const user = await requireOperator();
|
||||
|
||||
const claims = await prisma.operation.findMany({
|
||||
where: { claimedByUserId: user.id, status: "in_progress" },
|
||||
orderBy: { claimedAt: "desc" },
|
||||
include: {
|
||||
machine: { select: { name: true } },
|
||||
part: {
|
||||
select: {
|
||||
code: true,
|
||||
name: true,
|
||||
assembly: {
|
||||
select: {
|
||||
code: true,
|
||||
project: { select: { code: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl px-4 py-10 text-center space-y-6">
|
||||
<div className="mx-auto max-w-3xl px-4 py-6 space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Scan a traveler QR code</h1>
|
||||
<p className="text-slate-500 mt-2">
|
||||
Use your phone camera to scan the QR on a step card. It will open the step here so you can
|
||||
start, log time, and close out.
|
||||
<h1 className="text-2xl font-semibold">Hi, {user.name}</h1>
|
||||
<p className="text-slate-500 mt-1 text-sm">
|
||||
Scan a QR card with your phone camera to start a step, or continue an active one below.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white border border-slate-200 p-6 text-slate-500 text-sm">
|
||||
<p>Your active steps will appear here once you claim them.</p>
|
||||
</div>
|
||||
|
||||
{claims.length === 0 ? (
|
||||
<div className="rounded-xl bg-white border border-slate-200 p-6 text-slate-600 text-sm text-center">
|
||||
You have no active steps. Scan a traveler QR to begin.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-sm font-semibold text-slate-900 uppercase tracking-wide">
|
||||
Active ({claims.length})
|
||||
</h2>
|
||||
{claims.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/op/scan/${c.qrToken}`}
|
||||
className="block rounded-xl bg-white border border-slate-200 p-4 hover:border-slate-900 hover:shadow-sm transition"
|
||||
>
|
||||
<div className="text-xs text-slate-500">
|
||||
{c.part.assembly.project.code} · {c.part.assembly.code}
|
||||
</div>
|
||||
<div className="font-medium mt-0.5">{c.part.name}</div>
|
||||
<div className="text-sm text-slate-700 mt-1">
|
||||
Step {c.sequence}: {c.name}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500 mt-1 flex flex-wrap gap-x-3">
|
||||
<span className="font-mono">{c.part.code}</span>
|
||||
{c.machine ? <span>{c.machine.name}</span> : null}
|
||||
{c.claimedAt ? <span>since {new Date(c.claimedAt).toLocaleTimeString()}</span> : null}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export type ScanOp = {
|
||||
id: string;
|
||||
sequence: number;
|
||||
name: string;
|
||||
status: string;
|
||||
qcRequired: boolean;
|
||||
instructions: string | null;
|
||||
materialNotes: string | null;
|
||||
settings: string | null;
|
||||
plannedMinutes: number | null;
|
||||
plannedUnits: number | null;
|
||||
claimedByUserId: string | null;
|
||||
claimedAt: string | null;
|
||||
machine: { id: string; name: string; kind: string } | null;
|
||||
part: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
material: string | null;
|
||||
qty: number;
|
||||
assembly: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
project: { id: string; code: string; name: string };
|
||||
};
|
||||
};
|
||||
claimedBy: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
type Viewer = {
|
||||
id: string;
|
||||
role: "admin" | "operator";
|
||||
claimedByMe: boolean;
|
||||
};
|
||||
|
||||
export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; viewer: Viewer }) {
|
||||
const router = useRouter();
|
||||
const [op, setOp] = useState(initialOp);
|
||||
const [claimedByMe, setClaimedByMe] = useState(viewer.claimedByMe);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isPending, startTransition] = useTransition();
|
||||
|
||||
// Inline form state for pause/done. We keep one set of fields and let the
|
||||
// active button decide which API to hit — the fields (units processed, note)
|
||||
// are identical between release and close.
|
||||
const [units, setUnits] = useState("");
|
||||
const [note, setNote] = useState("");
|
||||
const [qcPassed, setQcPassed] = useState<null | boolean>(null);
|
||||
const [qcNotes, setQcNotes] = useState("");
|
||||
|
||||
const isOperator = viewer.role === "operator";
|
||||
const active = op.status === "in_progress";
|
||||
const completed = op.status === "completed";
|
||||
|
||||
async function call(path: string, body?: unknown) {
|
||||
setError(null);
|
||||
const res = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
setError(data.error ?? "Request failed");
|
||||
return null;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function onClaim() {
|
||||
startTransition(async () => {
|
||||
const data = await call(`/api/v1/operations/${op.id}/claim`);
|
||||
if (data?.operation) {
|
||||
setOp({ ...op, ...data.operation });
|
||||
setClaimedByMe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onRelease() {
|
||||
startTransition(async () => {
|
||||
const data = await call(`/api/v1/operations/${op.id}/release`, {
|
||||
unitsProcessed: units ? Number(units) : undefined,
|
||||
note: note || undefined,
|
||||
});
|
||||
if (data?.operation) {
|
||||
setOp({ ...op, ...data.operation });
|
||||
setClaimedByMe(false);
|
||||
// Bounce back to /op so the operator sees their queue.
|
||||
router.push("/op");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
if (op.qcRequired && qcPassed === null) {
|
||||
setError("This step requires QC — mark pass or fail before completing");
|
||||
return;
|
||||
}
|
||||
startTransition(async () => {
|
||||
const data = await call(`/api/v1/operations/${op.id}/close`, {
|
||||
unitsProcessed: units ? Number(units) : undefined,
|
||||
note: note || undefined,
|
||||
qc:
|
||||
qcPassed === null
|
||||
? undefined
|
||||
: { passed: qcPassed, notes: qcNotes || undefined, measurements: "" },
|
||||
});
|
||||
if (data?.operation) {
|
||||
setOp({ ...op, ...data.operation });
|
||||
setClaimedByMe(false);
|
||||
router.push("/op");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl bg-white border border-slate-200 p-5 shadow-sm">
|
||||
<div className="text-xs text-slate-500">
|
||||
{op.part.assembly.project.code} · {op.part.assembly.code}
|
||||
</div>
|
||||
<h1 className="text-xl font-semibold mt-1">{op.part.name}</h1>
|
||||
<div className="text-slate-600 text-sm">
|
||||
Part <span className="font-mono">{op.part.code}</span>
|
||||
{op.part.material ? ` · ${op.part.material}` : null} · qty {op.part.qty}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-slate-100 text-slate-700 text-xs px-2 py-1">
|
||||
Step {op.sequence}
|
||||
</span>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full text-xs px-2 py-1 ${
|
||||
completed
|
||||
? "bg-emerald-100 text-emerald-800"
|
||||
: active
|
||||
? "bg-amber-100 text-amber-800"
|
||||
: "bg-slate-100 text-slate-700"
|
||||
}`}
|
||||
>
|
||||
{op.status === "in_progress" ? "in progress" : op.status}
|
||||
</span>
|
||||
{op.qcRequired ? (
|
||||
<span className="inline-flex items-center rounded-full bg-purple-100 text-purple-800 text-xs px-2 py-1">
|
||||
QC
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-lg">{op.name}</div>
|
||||
{op.machine ? (
|
||||
<div className="text-sm text-slate-600 mt-1">
|
||||
Machine: <span className="font-medium">{op.machine.name}</span>
|
||||
<span className="text-slate-400"> ({op.machine.kind})</span>
|
||||
</div>
|
||||
) : null}
|
||||
{op.plannedMinutes || op.plannedUnits ? (
|
||||
<div className="text-sm text-slate-600 mt-1">
|
||||
Plan:
|
||||
{op.plannedMinutes ? ` ${op.plannedMinutes} min` : ""}
|
||||
{op.plannedMinutes && op.plannedUnits ? " ·" : ""}
|
||||
{op.plannedUnits ? ` ${op.plannedUnits} units` : ""}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{op.instructions ? (
|
||||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-slate-900 mb-1">Instructions</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-700">{op.instructions}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{op.materialNotes ? (
|
||||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-slate-900 mb-1">Material notes</h2>
|
||||
<p className="whitespace-pre-wrap text-sm text-slate-700">{op.materialNotes}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{op.settings ? (
|
||||
<div className="rounded-2xl bg-white border border-slate-200 p-5">
|
||||
<h2 className="text-sm font-semibold text-slate-900 mb-1">Settings</h2>
|
||||
<pre className="text-xs bg-slate-50 border border-slate-200 rounded-md p-3 overflow-auto">
|
||||
{op.settings}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isOperator && !completed ? (
|
||||
<div className="rounded-2xl bg-white border border-slate-200 p-5 space-y-4">
|
||||
{!claimedByMe && op.claimedBy && op.claimedByUserId !== viewer.id ? (
|
||||
<div className="rounded-md bg-amber-50 border border-amber-200 text-amber-900 text-sm px-3 py-2">
|
||||
<span className="font-medium">{op.claimedBy.name}</span> is currently on this step.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!claimedByMe ? (
|
||||
<button
|
||||
onClick={onClaim}
|
||||
disabled={isPending || (!!op.claimedByUserId && op.claimedByUserId !== viewer.id)}
|
||||
className="w-full h-14 rounded-xl bg-slate-900 text-white text-lg font-medium disabled:bg-slate-400"
|
||||
>
|
||||
{isPending ? "Claiming…" : "Start this step"}
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-900">Units processed</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={0}
|
||||
value={units}
|
||||
onChange={(e) => setUnits(e.target.value)}
|
||||
placeholder={op.plannedUnits?.toString() ?? "0"}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-base"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-sm font-medium text-slate-900">Note (optional)</span>
|
||||
<textarea
|
||||
value={note}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
rows={2}
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{op.qcRequired ? (
|
||||
<div className="rounded-md bg-purple-50 border border-purple-200 p-3 space-y-2">
|
||||
<div className="text-sm font-medium text-purple-900">QC check (required)</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQcPassed(true)}
|
||||
className={`flex-1 h-10 rounded-md border text-sm font-medium ${
|
||||
qcPassed === true
|
||||
? "bg-emerald-600 text-white border-emerald-700"
|
||||
: "bg-white text-slate-700 border-slate-300"
|
||||
}`}
|
||||
>
|
||||
Pass
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setQcPassed(false)}
|
||||
className={`flex-1 h-10 rounded-md border text-sm font-medium ${
|
||||
qcPassed === false
|
||||
? "bg-red-600 text-white border-red-700"
|
||||
: "bg-white text-slate-700 border-slate-300"
|
||||
}`}
|
||||
>
|
||||
Fail
|
||||
</button>
|
||||
</div>
|
||||
{qcPassed !== null ? (
|
||||
<textarea
|
||||
value={qcNotes}
|
||||
onChange={(e) => setQcNotes(e.target.value)}
|
||||
rows={2}
|
||||
placeholder="QC notes"
|
||||
className="w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
onClick={onRelease}
|
||||
disabled={isPending}
|
||||
className="h-14 rounded-xl bg-white border border-slate-300 text-slate-900 text-lg font-medium disabled:opacity-60"
|
||||
>
|
||||
{isPending ? "…" : "Pause"}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={isPending}
|
||||
className="h-14 rounded-xl bg-emerald-600 text-white text-lg font-medium disabled:bg-emerald-400"
|
||||
>
|
||||
{isPending ? "…" : "Done"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{completed ? (
|
||||
<div className="rounded-2xl bg-emerald-50 border border-emerald-200 p-5 text-center">
|
||||
<div className="text-emerald-900 font-semibold">Step completed</div>
|
||||
<div className="text-sm text-emerald-800 mt-1">Scan the next card or head back to your dashboard.</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/auth";
|
||||
import ScanClient, { type ScanOp } from "./ScanClient";
|
||||
|
||||
/**
|
||||
* Entry point for a QR scan. Resolves the token server-side so we can:
|
||||
* 1. Redirect unauthenticated visitors to /login/operator with ?next=<here>,
|
||||
* so they land back on this same page after PIN entry.
|
||||
* 2. Show a clean "unknown token" page for bad scans (old card, typo) instead
|
||||
* of the generic 404.
|
||||
* 3. Render the interactive ScanClient with everything the operator needs
|
||||
* to read the work card and claim / pause / complete.
|
||||
*
|
||||
* Admins who scan get a read-only preview — we don't let them claim because
|
||||
* the claim holder is supposed to be the one running the machine.
|
||||
*/
|
||||
export default async function ScanPage({ params }: { params: Promise<{ token: string }> }) {
|
||||
const { token } = await params;
|
||||
const user = await getCurrentUser();
|
||||
|
||||
if (!user) {
|
||||
redirect(`/login/operator?next=${encodeURIComponent(`/op/scan/${token}`)}`);
|
||||
}
|
||||
|
||||
const op = await prisma.operation.findUnique({
|
||||
where: { qrToken: token },
|
||||
include: {
|
||||
machine: { select: { id: true, name: true, kind: true } },
|
||||
part: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
material: true,
|
||||
qty: true,
|
||||
assembly: {
|
||||
select: {
|
||||
id: true,
|
||||
code: true,
|
||||
name: true,
|
||||
project: { select: { id: true, code: true, name: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
claimedBy: { select: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!op) {
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-10">
|
||||
<div className="rounded-xl bg-white border border-slate-200 p-6 text-center">
|
||||
<h1 className="text-xl font-semibold">QR code not recognised</h1>
|
||||
<p className="text-slate-600 mt-2 text-sm">
|
||||
This card might be from an older print, or the operation was deleted. Ask your supervisor
|
||||
to reprint the traveler.
|
||||
</p>
|
||||
<Link
|
||||
href="/op"
|
||||
className="inline-block mt-4 rounded-md bg-slate-900 text-white px-4 py-2 text-sm"
|
||||
>
|
||||
Back to dashboard
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (user.role !== "operator") {
|
||||
// Admin preview: render a read-only summary, no action buttons.
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-8">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500 mb-2">Admin preview</p>
|
||||
<ScanClient
|
||||
initialOp={serialise(op)}
|
||||
viewer={{ id: user.id, role: "admin", claimedByMe: false }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const claimedByMe = op.claimedByUserId === user.id;
|
||||
return (
|
||||
<div className="mx-auto max-w-xl px-4 py-6">
|
||||
<ScanClient
|
||||
initialOp={serialise(op)}
|
||||
viewer={{ id: user.id, role: "operator", claimedByMe }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Date fields come back as JS Date objects from Prisma; convert for the client
|
||||
// component payload so it can be serialised into the RSC stream without tripping
|
||||
// on non-plain objects. We widen to `ScanOp` which types the dates as strings.
|
||||
function serialise(obj: unknown): ScanOp {
|
||||
return JSON.parse(JSON.stringify(obj)) as ScanOp;
|
||||
}
|
||||
Reference in New Issue
Block a user