From fc5bce4868cd39957f9e816ade8cc1c5fa727cd7 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 21 Apr 2026 09:29:44 -0500 Subject: [PATCH] stage 4 --- .claude/settings.local.json | 3 +- .../parts/[partId]/PartDetailClient.tsx | 89 ++++- app/api/auth/operator/login/route.ts | 19 +- app/api/v1/operations/[id]/claim/route.ts | 72 ++++ app/api/v1/operations/[id]/close/route.ts | 98 ++++++ app/api/v1/operations/[id]/qr/route.ts | 35 ++ app/api/v1/operations/[id]/release/route.ts | 71 ++++ .../v1/operations/by-token/[token]/route.ts | 60 ++++ app/login/operator/OperatorLoginClient.tsx | 179 ++++++++++ app/login/operator/page.tsx | 186 +---------- app/op/layout.tsx | 24 +- app/op/page.tsx | 78 ++++- app/op/scan/[token]/ScanClient.tsx | 313 ++++++++++++++++++ app/op/scan/[token]/page.tsx | 101 ++++++ lib/qr.ts | 25 ++ lib/schemas.ts | 23 ++ package-lock.json | 279 +++++++++++++++- package.json | 2 + tsconfig.tsbuildinfo | 2 +- 19 files changed, 1469 insertions(+), 190 deletions(-) create mode 100644 app/api/v1/operations/[id]/claim/route.ts create mode 100644 app/api/v1/operations/[id]/close/route.ts create mode 100644 app/api/v1/operations/[id]/qr/route.ts create mode 100644 app/api/v1/operations/[id]/release/route.ts create mode 100644 app/api/v1/operations/by-token/[token]/route.ts create mode 100644 app/login/operator/OperatorLoginClient.tsx create mode 100644 app/op/scan/[token]/ScanClient.tsx create mode 100644 app/op/scan/[token]/page.tsx diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 07fd7ac..69fe4ab 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "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(npx tsc *)", - "Bash(npx next *)" + "Bash(npx next *)", + "Bash(npm run *)" ] } } diff --git a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx index 6ec7516..f90672a 100644 --- a/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx +++ b/app/admin/projects/[id]/assemblies/[assemblyId]/parts/[partId]/PartDetailClient.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; import { Badge, @@ -222,6 +222,7 @@ function OperationsSection({ }) { const [newOpen, setNewOpen] = useState(false); const [edit, setEdit] = useState(null); + const [qrFor, setQrFor] = useState(null); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); @@ -324,6 +325,9 @@ function OperationsSection({ > ↓ + @@ -374,10 +378,93 @@ function OperationsSection({ }} /> )} + {qrFor && setQrFor(null)} />} ); } +function QrModal({ operation, onClose }: { operation: OperationRow; onClose: () => void }) { + const [data, setData] = useState< + { dataUrl: string; scanUrl: string; token: string } | null + >(null); + const [error, setError] = useState(null); + + // Fetch lazily so we don't pre-render QRs for every op on the page. The + // data URL is ~1 KB so this is cheap, but it does require a server hop. + useEffect(() => { + let cancelled = false; + apiFetch<{ dataUrl: string; scanUrl: string; token: string }>( + `/api/v1/operations/${operation.id}/qr`, + ) + .then((d) => { + if (!cancelled) setData(d); + }) + .catch((err) => { + if (!cancelled) setError(err instanceof ApiClientError ? err.message : "Load failed"); + }); + return () => { + cancelled = true; + }; + }, [operation.id]); + + return ( + +
+ + {data ? ( + + Download PNG + + ) : null} + + } + > +
+ {error ? ( + + ) : data ? ( + <> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`QR +
+
+
+ Scan URL:{" "} + + {data.scanUrl} + +
+
+ Token:{" "} + {data.token} +
+
+ + ) : ( +
Rendering QR…
+ )} +
+ + ); +} + function OperationModal({ partId, operation, diff --git a/app/api/auth/operator/login/route.ts b/app/api/auth/operator/login/route.ts index 427d4d5..96c0799 100644 --- a/app/api/auth/operator/login/route.ts +++ b/app/api/auth/operator/login/route.ts @@ -10,8 +10,24 @@ import { clientIp, userAgent } from "@/lib/request"; const Body = z.object({ operatorId: z.string().min(1), pin: z.string().regex(/^\d{4}$/, "PIN must be 4 digits"), + next: z.string().optional(), }); +/** + * Accept only same-origin redirects into the operator area. A scanned QR card + * routes the operator to /op/scan/; if they aren't signed in we bounce + * them to the login page with ?next=, log them in, then land them back + * on the scan page. Anything that doesn't look like a plain /op/... path is + * dropped on the floor to avoid open-redirect abuse. + */ +function safeNext(raw: string | undefined): string { + if (!raw) return "/op"; + if (!raw.startsWith("/op")) return "/op"; + // Reject protocol-relative ("//evil.com") or backslash-smuggled inputs. + if (raw.startsWith("//") || raw.includes("\\")) return "/op"; + return raw; +} + export async function POST(req: NextRequest) { const parsed = Body.safeParse(await req.json().catch(() => ({}))); if (!parsed.success) { @@ -19,6 +35,7 @@ export async function POST(req: NextRequest) { } const { operatorId, pin } = parsed.data; + const redirect = safeNext(parsed.data.next); if (!isValidPin(pin)) { return NextResponse.json({ error: "Invalid PIN" }, { status: 400 }); } @@ -76,5 +93,5 @@ export async function POST(req: NextRequest) { await audit({ actorId: user.id, action: "login", entity: "User", entityId: user.id, ipAddress: ip }); - return NextResponse.json({ ok: true, redirect: "/op" }); + return NextResponse.json({ ok: true, redirect }); } diff --git a/app/api/v1/operations/[id]/claim/route.ts b/app/api/v1/operations/[id]/claim/route.ts new file mode 100644 index 0000000..91933b8 --- /dev/null +++ b/app/api/v1/operations/[id]/claim/route.ts @@ -0,0 +1,72 @@ +import { type NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { ok, errorResponse, requireRole, ApiError } from "@/lib/api"; +import { audit } from "@/lib/audit"; +import { clientIp } from "@/lib/request"; + +/** + * Claim an operation for the current operator. We enforce the "single claim" + * invariant at the DB level: updateMany's where clause includes + * { claimedByUserId: null, status: "pending" }, so a second operator racing + * us will match 0 rows and we reject with 409. No transaction needed. + * + * On success we also open a TimeLog row so "hours on machine" telemetry + * later lines up with actual floor time. + */ +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const actor = await requireRole("operator"); + const { id } = await ctx.params; + + const existing = await prisma.operation.findUnique({ + where: { id }, + select: { id: true, status: true, claimedByUserId: true, name: true }, + }); + if (!existing) throw new ApiError(404, "not_found", "Operation not found"); + + if (existing.status === "completed") { + throw new ApiError(409, "op_completed", "This step is already completed"); + } + if (existing.claimedByUserId && existing.claimedByUserId !== actor.id) { + throw new ApiError(409, "op_claimed", "Another operator is already working on this step"); + } + if (existing.claimedByUserId === actor.id) { + // Idempotent: scanning again while already holding the claim is a no-op. + const op = await prisma.operation.findUnique({ where: { id } }); + return ok({ operation: op, alreadyClaimed: true }); + } + + const now = new Date(); + const updateResult = await prisma.operation.updateMany({ + where: { id, claimedByUserId: null, status: "pending" }, + data: { + status: "in_progress", + claimedByUserId: actor.id, + claimedAt: now, + }, + }); + if (updateResult.count === 0) { + // Lost a race to another operator between the check above and the update. + throw new ApiError(409, "op_claimed", "Another operator just claimed this step"); + } + + await prisma.timeLog.create({ + data: { operationId: id, operatorId: actor.id, startedAt: now }, + }); + + const op = await prisma.operation.findUnique({ where: { id } }); + + await audit({ + actorId: actor.id, + action: "claim_op", + entity: "Operation", + entityId: id, + after: { status: "in_progress", claimedByUserId: actor.id }, + ipAddress: clientIp(req), + }); + + return ok({ operation: op }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/app/api/v1/operations/[id]/close/route.ts b/app/api/v1/operations/[id]/close/route.ts new file mode 100644 index 0000000..4006394 --- /dev/null +++ b/app/api/v1/operations/[id]/close/route.ts @@ -0,0 +1,98 @@ +import { type NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api"; +import { CloseOperationSchema } from "@/lib/schemas"; +import { audit } from "@/lib/audit"; +import { clientIp } from "@/lib/request"; + +/** + * Complete an operation. Only the current claim holder may close, and if + * the operation is flagged qcRequired the payload must include an inline + * QC block (pass/fail + optional measurements). Close does four things + * atomically: + * + * 1. marks the operation completed + records completedAt, + * 2. closes the open TimeLog with unitsProcessed / note if provided, + * 3. writes a QCRecord if this op requires QC (or if the operator passed + * one in voluntarily), + * 4. audits the close for later reporting. + */ +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const actor = await requireRole("operator"); + const { id } = await ctx.params; + const body = await parseJson(req, CloseOperationSchema); + + const existing = await prisma.operation.findUnique({ + where: { id }, + select: { id: true, status: true, claimedByUserId: true, qcRequired: true }, + }); + if (!existing) throw new ApiError(404, "not_found", "Operation not found"); + if (existing.claimedByUserId !== actor.id) { + throw new ApiError(409, "not_claim_holder", "Only the current operator can complete this step"); + } + if (existing.status !== "in_progress") { + throw new ApiError(409, "op_not_active", "Step is not active"); + } + if (existing.qcRequired && !body.qc) { + throw new ApiError(400, "qc_required", "This step requires an inline QC check before completing"); + } + + const now = new Date(); + await prisma.$transaction(async (tx) => { + const openLog = await tx.timeLog.findFirst({ + where: { operationId: id, operatorId: actor.id, endedAt: null }, + orderBy: { startedAt: "desc" }, + }); + if (openLog) { + await tx.timeLog.update({ + where: { id: openLog.id }, + data: { + endedAt: now, + unitsProcessed: body.unitsProcessed ?? null, + note: body.note ?? null, + }, + }); + } + if (body.qc) { + await tx.qCRecord.create({ + data: { + operationId: id, + operatorId: actor.id, + kind: "inline", + passed: body.qc.passed, + measurements: body.qc.measurements ?? null, + notes: body.qc.notes ?? null, + }, + }); + } + await tx.operation.update({ + where: { id }, + data: { + status: "completed", + completedAt: now, + claimedByUserId: null, + claimedAt: null, + }, + }); + }); + + const op = await prisma.operation.findUnique({ where: { id } }); + await audit({ + actorId: actor.id, + action: "close_op", + entity: "Operation", + entityId: id, + after: { + status: "completed", + unitsProcessed: body.unitsProcessed ?? null, + qcPassed: body.qc?.passed ?? null, + }, + ipAddress: clientIp(req), + }); + + return ok({ operation: op }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/app/api/v1/operations/[id]/qr/route.ts b/app/api/v1/operations/[id]/qr/route.ts new file mode 100644 index 0000000..4457dcd --- /dev/null +++ b/app/api/v1/operations/[id]/qr/route.ts @@ -0,0 +1,35 @@ +import { type NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { ok, errorResponse, requireRole, ApiError } from "@/lib/api"; +import { renderQrPng, scanUrlForToken } from "@/lib/qr"; + +/** + * Admin-only. Render the operation's QR token as a data-URL PNG so the part + * detail UI can preview (and later print) the traveler. We keep token + * generation out of band from rendering so the DB remains the single source + * of truth for the token value. + */ +export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + await requireRole("admin"); + const { id } = await ctx.params; + + const op = await prisma.operation.findUnique({ + where: { id }, + select: { id: true, qrToken: true, sequence: true, name: true }, + }); + if (!op) throw new ApiError(404, "not_found", "Operation not found"); + + const dataUrl = await renderQrPng(op.qrToken); + return ok({ + operationId: op.id, + sequence: op.sequence, + name: op.name, + token: op.qrToken, + scanUrl: scanUrlForToken(op.qrToken), + dataUrl, + }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/app/api/v1/operations/[id]/release/route.ts b/app/api/v1/operations/[id]/release/route.ts new file mode 100644 index 0000000..02054cc --- /dev/null +++ b/app/api/v1/operations/[id]/release/route.ts @@ -0,0 +1,71 @@ +import { type NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api"; +import { ReleaseOperationSchema } from "@/lib/schemas"; +import { audit } from "@/lib/audit"; +import { clientIp } from "@/lib/request"; + +/** + * Pause an in-progress operation: drop the claim, close the open TimeLog, + * and send the step back to pending so any operator can pick it up next. + * Only the current claim holder may release (admins get their own escape + * hatch via the PATCH endpoint if we ever need one). + */ +export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) { + try { + const actor = await requireRole("operator"); + const { id } = await ctx.params; + const body = await parseJson(req, ReleaseOperationSchema); + + const existing = await prisma.operation.findUnique({ + where: { id }, + select: { id: true, status: true, claimedByUserId: true }, + }); + if (!existing) throw new ApiError(404, "not_found", "Operation not found"); + if (existing.claimedByUserId !== actor.id) { + throw new ApiError(409, "not_claim_holder", "Only the current operator can pause this step"); + } + if (existing.status !== "in_progress") { + throw new ApiError(409, "op_not_active", "Step is not active"); + } + + const now = new Date(); + await prisma.$transaction(async (tx) => { + // Close the most recent open TimeLog for (op, operator). We accept that + // if two rows are open for the same pair something has gone wrong + // elsewhere; close the newest and let the audit log preserve history. + const openLog = await tx.timeLog.findFirst({ + where: { operationId: id, operatorId: actor.id, endedAt: null }, + orderBy: { startedAt: "desc" }, + }); + if (openLog) { + await tx.timeLog.update({ + where: { id: openLog.id }, + data: { + endedAt: now, + unitsProcessed: body.unitsProcessed ?? null, + note: body.note ?? null, + }, + }); + } + await tx.operation.update({ + where: { id }, + data: { status: "pending", claimedByUserId: null, claimedAt: null }, + }); + }); + + const op = await prisma.operation.findUnique({ where: { id } }); + await audit({ + actorId: actor.id, + action: "release_op", + entity: "Operation", + entityId: id, + after: { status: "pending", unitsProcessed: body.unitsProcessed ?? null }, + ipAddress: clientIp(req), + }); + + return ok({ operation: op }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/app/api/v1/operations/by-token/[token]/route.ts b/app/api/v1/operations/by-token/[token]/route.ts new file mode 100644 index 0000000..383961d --- /dev/null +++ b/app/api/v1/operations/by-token/[token]/route.ts @@ -0,0 +1,60 @@ +import { type NextRequest } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { ok, errorResponse, ApiError } from "@/lib/api"; +import { getSessionUser } from "@/lib/session"; + +/** + * Resolve a scanned QR token into enough context for the operator scan page. + * + * A scan can happen from: + * - An admin still logged in on their laptop (shouldn't happen in the field + * but we allow it for testing the print flow). + * - An operator with a valid device session. + * - An unauthenticated phone (the page redirects to /login/operator?next=... + * before calling this route, so in practice we require a session here). + * + * We intentionally return only the operation fields the operator needs. Admin- + * only stuff (audit logs, full file SHAs, etc.) is off-limits. + */ +export async function GET(_req: NextRequest, ctx: { params: Promise<{ token: string }> }) { + try { + const user = await getSessionUser(); + if (!user) throw new ApiError(401, "unauthenticated", "Sign in required"); + + const { token } = await ctx.params; + 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, + stepFileId: true, + drawingFileId: true, + cutFileId: 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) throw new ApiError(404, "unknown_token", "That QR code isn't recognised"); + + const claimedByMe = op.claimedByUserId === user.id; + return ok({ operation: op, viewer: { id: user.id, role: user.role, claimedByMe } }); + } catch (err) { + return errorResponse(err); + } +} diff --git a/app/login/operator/OperatorLoginClient.tsx b/app/login/operator/OperatorLoginClient.tsx new file mode 100644 index 0000000..289fce5 --- /dev/null +++ b/app/login/operator/OperatorLoginClient.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; + +interface Operator { + id: string; + name: string; +} + +export default function OperatorLoginClient() { + const router = useRouter(); + const searchParams = useSearchParams(); + // A scanned QR card lands unauthenticated operators on /login/operator?next=/op/scan/. + // We echo that back to the login API so the redirect after auth returns them to the scan page + // instead of dumping them on the generic /op home. + const nextPath = searchParams.get("next") ?? undefined; + const [operators, setOperators] = useState(null); + const [selected, setSelected] = useState(null); + const [pin, setPin] = useState(""); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + fetch("/api/operators") + .then((r) => r.json()) + .then((d) => setOperators(d.operators ?? [])) + .catch(() => setOperators([])); + }, []); + + function pressKey(k: string) { + setError(null); + if (k === "back") { + setPin((p) => p.slice(0, -1)); + return; + } + if (k === "clear") { + setPin(""); + return; + } + setPin((p) => (p.length >= 4 ? p : p + k)); + } + + async function submit() { + if (!selected || pin.length !== 4) return; + setBusy(true); + setError(null); + try { + const res = await fetch("/api/auth/operator/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ operatorId: selected.id, pin, next: nextPath }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error ?? "Sign-in failed"); + setPin(""); + return; + } + router.push(data.redirect ?? "/op"); + router.refresh(); + } catch { + setError("Network error"); + } finally { + setBusy(false); + } + } + + useEffect(() => { + if (pin.length === 4 && !busy) { + void submit(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pin]); + + if (operators === null) { + return ( +
+

Loading…

+
+ ); + } + + if (!selected) { + return ( +
+
+
+

Who are you?

+

Tap your name to sign in.

+
+ + {operators.length === 0 ? ( +
+

No operators exist yet.

+

Ask an admin to create your account.

+
+ ) : ( +
+ {operators.map((op) => ( + + ))} +
+ )} + +
+ + ← Back + +
+
+
+ ); + } + + const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"]; + + return ( +
+
+
+ +
+ +
+

Hi, {selected.name}

+

Enter your 4-digit PIN

+ +
+ {[0, 1, 2, 3].map((i) => ( +
i ? "bg-slate-900" : "bg-slate-200"}`} + /> + ))} +
+ + {error && ( +

+ {error} +

+ )} + +
+ {keys.map((k) => { + const label = k === "back" ? "⌫" : k === "clear" ? "C" : k; + return ( + + ); + })} +
+
+
+
+ ); +} diff --git a/app/login/operator/page.tsx b/app/login/operator/page.tsx index c557741..3a85730 100644 --- a/app/login/operator/page.tsx +++ b/app/login/operator/page.tsx @@ -1,174 +1,22 @@ -"use client"; - -import { useEffect, useState } from "react"; -import { useRouter } from "next/navigation"; -import Link from "next/link"; - -interface Operator { - id: string; - name: string; -} +import { Suspense } from "react"; +import OperatorLoginClient from "./OperatorLoginClient"; +/** + * Server-component shell that wraps the client login form in a Suspense + * boundary. The client uses useSearchParams() to read ?next=; Next.js + * requires that to live inside Suspense so the CSR bailout doesn't fail the + * prerender during `next build`. + */ export default function OperatorLoginPage() { - const router = useRouter(); - const [operators, setOperators] = useState(null); - const [selected, setSelected] = useState(null); - const [pin, setPin] = useState(""); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - useEffect(() => { - fetch("/api/operators") - .then((r) => r.json()) - .then((d) => setOperators(d.operators ?? [])) - .catch(() => setOperators([])); - }, []); - - function pressKey(k: string) { - setError(null); - if (k === "back") { - setPin((p) => p.slice(0, -1)); - return; - } - if (k === "clear") { - setPin(""); - return; - } - setPin((p) => (p.length >= 4 ? p : p + k)); - } - - async function submit() { - if (!selected || pin.length !== 4) return; - setBusy(true); - setError(null); - try { - const res = await fetch("/api/auth/operator/login", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ operatorId: selected.id, pin }), - }); - const data = await res.json().catch(() => ({})); - if (!res.ok) { - setError(data.error ?? "Sign-in failed"); - setPin(""); - return; - } - router.push(data.redirect ?? "/op"); - router.refresh(); - } catch { - setError("Network error"); - } finally { - setBusy(false); - } - } - - useEffect(() => { - if (pin.length === 4 && !busy) { - void submit(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [pin]); - - if (operators === null) { - return ( -
-

Loading…

-
- ); - } - - if (!selected) { - return ( -
-
-
-

Who are you?

-

Tap your name to sign in.

-
- - {operators.length === 0 ? ( -
-

No operators exist yet.

-

Ask an admin to create your account.

-
- ) : ( -
- {operators.map((op) => ( - - ))} -
- )} - -
- - ← Back - -
-
-
- ); - } - - const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"]; - return ( -
-
-
- -
- -
-

Hi, {selected.name}

-

Enter your 4-digit PIN

- -
- {[0, 1, 2, 3].map((i) => ( -
i ? "bg-slate-900" : "bg-slate-200"}`} - /> - ))} -
- - {error && ( -

- {error} -

- )} - -
- {keys.map((k) => { - const label = k === "back" ? "⌫" : k === "clear" ? "C" : k; - return ( - - ); - })} -
-
-
-
+ +

Loading…

+ + } + > + +
); } diff --git a/app/op/layout.tsx b/app/op/layout.tsx index dcfed71..23c7be4 100644 --- a/app/op/layout.tsx +++ b/app/op/layout.tsx @@ -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= 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 (
@@ -12,8 +20,16 @@ export default async function OperatorLayout({ children }: { children: React.Rea MRP - {user.name} - + {user ? ( + <> + {user.name} + + + ) : ( + + Sign in + + )}
{children}
diff --git a/app/op/page.tsx b/app/op/page.tsx index 1f7aae7..7c782c4 100644 --- a/app/op/page.tsx +++ b/app/op/page.tsx @@ -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 ( -
+
-

Scan a traveler QR code

-

- 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. +

Hi, {user.name}

+

+ Scan a QR card with your phone camera to start a step, or continue an active one below.

-
-

Your active steps will appear here once you claim them.

-
+ + {claims.length === 0 ? ( +
+ You have no active steps. Scan a traveler QR to begin. +
+ ) : ( +
+

+ Active ({claims.length}) +

+ {claims.map((c) => ( + +
+ {c.part.assembly.project.code} · {c.part.assembly.code} +
+
{c.part.name}
+
+ Step {c.sequence}: {c.name} +
+
+ {c.part.code} + {c.machine ? {c.machine.name} : null} + {c.claimedAt ? since {new Date(c.claimedAt).toLocaleTimeString()} : null} +
+ + ))} +
+ )}
); } diff --git a/app/op/scan/[token]/ScanClient.tsx b/app/op/scan/[token]/ScanClient.tsx new file mode 100644 index 0000000..60f7873 --- /dev/null +++ b/app/op/scan/[token]/ScanClient.tsx @@ -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(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); + 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 ( +
+
+
+ {op.part.assembly.project.code} · {op.part.assembly.code} +
+

{op.part.name}

+
+ Part {op.part.code} + {op.part.material ? ` · ${op.part.material}` : null} · qty {op.part.qty} +
+ +
+ + Step {op.sequence} + + + {op.status === "in_progress" ? "in progress" : op.status} + + {op.qcRequired ? ( + + QC + + ) : null} +
+ +
{op.name}
+ {op.machine ? ( +
+ Machine: {op.machine.name} + ({op.machine.kind}) +
+ ) : null} + {op.plannedMinutes || op.plannedUnits ? ( +
+ Plan: + {op.plannedMinutes ? ` ${op.plannedMinutes} min` : ""} + {op.plannedMinutes && op.plannedUnits ? " ·" : ""} + {op.plannedUnits ? ` ${op.plannedUnits} units` : ""} +
+ ) : null} +
+ + {op.instructions ? ( +
+

Instructions

+

{op.instructions}

+
+ ) : null} + + {op.materialNotes ? ( +
+

Material notes

+

{op.materialNotes}

+
+ ) : null} + + {op.settings ? ( +
+

Settings

+
+            {op.settings}
+          
+
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {isOperator && !completed ? ( +
+ {!claimedByMe && op.claimedBy && op.claimedByUserId !== viewer.id ? ( +
+ {op.claimedBy.name} is currently on this step. +
+ ) : null} + + {!claimedByMe ? ( + + ) : ( + <> +
+ +