stage 4
Build and Push Docker Image / build (push) Successful in 1m6s

This commit is contained in:
jason
2026-04-21 09:29:44 -05:00
parent 41b06f89c0
commit fc5bce4868
19 changed files with 1469 additions and 190 deletions
+2 -1
View File
@@ -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 *)"
]
}
}
@@ -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<OperationRow | null>(null);
const [qrFor, setQrFor] = useState<OperationRow | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -324,6 +325,9 @@ function OperationsSection({
>
</button>
<Button variant="ghost" size="sm" onClick={() => setQrFor(op)} disabled={busyId !== null}>
QR
</Button>
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
Edit
</Button>
@@ -374,10 +378,93 @@ function OperationsSection({
}}
/>
)}
{qrFor && <QrModal operation={qrFor} onClose={() => setQrFor(null)} />}
</section>
);
}
function QrModal({ operation, onClose }: { operation: OperationRow; onClose: () => void }) {
const [data, setData] = useState<
{ dataUrl: string; scanUrl: string; token: string } | null
>(null);
const [error, setError] = useState<string | null>(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 (
<Modal
open
onClose={onClose}
title={`QR: step ${operation.sequence}. ${operation.name}`}
footer={
<>
<div className="flex-1" />
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{data ? (
<a
href={data.dataUrl}
download={`op-${operation.sequence}-${operation.id}.png`}
className="inline-flex items-center rounded-md bg-slate-900 text-white text-sm px-3 py-1.5 hover:bg-slate-800"
>
Download PNG
</a>
) : null}
</>
}
>
<div className="space-y-3">
{error ? (
<ErrorBanner message={error} />
) : data ? (
<>
<div className="flex justify-center bg-white border border-slate-200 rounded-md p-4">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={data.dataUrl}
alt={`QR for ${operation.name}`}
width={256}
height={256}
/>
</div>
<div className="text-xs text-slate-600 space-y-1">
<div>
<span className="font-medium">Scan URL:</span>{" "}
<a href={data.scanUrl} className="text-blue-600 hover:underline break-all">
{data.scanUrl}
</a>
</div>
<div>
<span className="font-medium">Token:</span>{" "}
<code className="text-slate-500">{data.token}</code>
</div>
</div>
</>
) : (
<div className="text-center text-slate-500 text-sm py-10">Rendering QR</div>
)}
</div>
</Modal>
);
}
function OperationModal({
partId,
operation,
+18 -1
View File
@@ -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/<token>; if they aren't signed in we bounce
* them to the login page with ?next=<path>, 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 });
}
+72
View File
@@ -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);
}
}
+98
View File
@@ -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);
}
}
+35
View File
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
+179
View File
@@ -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/<token>.
// 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<Operator[] | null>(null);
const [selected, setSelected] = useState<Operator | null>(null);
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(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 (
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
<p className="text-slate-500">Loading</p>
</main>
);
}
if (!selected) {
return (
<main className="min-h-dvh p-6 bg-slate-50">
<div className="mx-auto max-w-2xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-semibold">Who are you?</h1>
<p className="text-slate-500 mt-1">Tap your name to sign in.</p>
</div>
{operators.length === 0 ? (
<div className="rounded-xl bg-white border border-slate-200 p-6 text-center">
<p className="text-slate-700">No operators exist yet.</p>
<p className="text-slate-500 text-sm mt-1">Ask an admin to create your account.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{operators.map((op) => (
<button
key={op.id}
onClick={() => setSelected(op)}
className="rounded-xl bg-white border border-slate-200 px-4 py-5 text-lg font-medium hover:border-slate-900 hover:shadow-sm transition"
>
{op.name}
</button>
))}
</div>
)}
<div className="text-center mt-8">
<Link href="/login" className="text-sm text-slate-500 hover:text-slate-900">
Back
</Link>
</div>
</div>
</main>
);
}
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
return (
<main className="min-h-dvh p-6 bg-slate-50">
<div className="mx-auto max-w-sm">
<div className="text-center mb-4">
<button
onClick={() => {
setSelected(null);
setPin("");
setError(null);
}}
className="text-sm text-slate-500 hover:text-slate-900"
>
Not {selected.name}?
</button>
</div>
<div className="rounded-2xl bg-white border border-slate-200 p-6 shadow-sm">
<h1 className="text-xl font-semibold text-center">Hi, {selected.name}</h1>
<p className="text-slate-500 text-sm text-center mt-1">Enter your 4-digit PIN</p>
<div className="flex justify-center gap-3 my-6">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className={`w-4 h-4 rounded-full ${pin.length > i ? "bg-slate-900" : "bg-slate-200"}`}
/>
))}
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4 text-center">
{error}
</p>
)}
<div className="grid grid-cols-3 gap-2">
{keys.map((k) => {
const label = k === "back" ? "⌫" : k === "clear" ? "C" : k;
return (
<button
key={k}
onClick={() => pressKey(k)}
disabled={busy}
className="h-14 rounded-lg bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-xl font-medium transition disabled:opacity-60"
>
{label}
</button>
);
})}
</div>
</div>
</div>
</main>
);
}
+17 -169
View File
@@ -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=<path>; 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<Operator[] | null>(null);
const [selected, setSelected] = useState<Operator | null>(null);
const [pin, setPin] = useState("");
const [error, setError] = useState<string | null>(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 (
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
<p className="text-slate-500">Loading</p>
</main>
);
}
if (!selected) {
return (
<main className="min-h-dvh p-6 bg-slate-50">
<div className="mx-auto max-w-2xl">
<div className="text-center mb-6">
<h1 className="text-2xl font-semibold">Who are you?</h1>
<p className="text-slate-500 mt-1">Tap your name to sign in.</p>
</div>
{operators.length === 0 ? (
<div className="rounded-xl bg-white border border-slate-200 p-6 text-center">
<p className="text-slate-700">No operators exist yet.</p>
<p className="text-slate-500 text-sm mt-1">Ask an admin to create your account.</p>
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{operators.map((op) => (
<button
key={op.id}
onClick={() => setSelected(op)}
className="rounded-xl bg-white border border-slate-200 px-4 py-5 text-lg font-medium hover:border-slate-900 hover:shadow-sm transition"
>
{op.name}
</button>
))}
</div>
)}
<div className="text-center mt-8">
<Link href="/login" className="text-sm text-slate-500 hover:text-slate-900">
Back
</Link>
</div>
</div>
</main>
);
}
const keys = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "clear", "0", "back"];
return (
<main className="min-h-dvh p-6 bg-slate-50">
<div className="mx-auto max-w-sm">
<div className="text-center mb-4">
<button
onClick={() => {
setSelected(null);
setPin("");
setError(null);
}}
className="text-sm text-slate-500 hover:text-slate-900"
>
Not {selected.name}?
</button>
</div>
<div className="rounded-2xl bg-white border border-slate-200 p-6 shadow-sm">
<h1 className="text-xl font-semibold text-center">Hi, {selected.name}</h1>
<p className="text-slate-500 text-sm text-center mt-1">Enter your 4-digit PIN</p>
<div className="flex justify-center gap-3 my-6">
{[0, 1, 2, 3].map((i) => (
<div
key={i}
className={`w-4 h-4 rounded-full ${pin.length > i ? "bg-slate-900" : "bg-slate-200"}`}
/>
))}
</div>
{error && (
<p className="text-sm text-red-600 bg-red-50 border border-red-200 rounded-md px-3 py-2 mb-4 text-center">
{error}
</p>
)}
<div className="grid grid-cols-3 gap-2">
{keys.map((k) => {
const label = k === "back" ? "⌫" : k === "clear" ? "C" : k;
return (
<button
key={k}
onClick={() => pressKey(k)}
disabled={busy}
className="h-14 rounded-lg bg-slate-100 hover:bg-slate-200 active:bg-slate-300 text-xl font-medium transition disabled:opacity-60"
>
{label}
</button>
);
})}
</div>
</div>
</div>
</main>
<Suspense
fallback={
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
<p className="text-slate-500">Loading</p>
</main>
}
>
<OperatorLoginClient />
</Suspense>
);
}
+20 -4
View File
@@ -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
View File
@@ -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>
);
}
+313
View File
@@ -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>
);
}
+101
View File
@@ -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;
}
+25
View File
@@ -1,4 +1,6 @@
import { randomBytes } from "node:crypto";
import QRCode from "qrcode";
import { env } from "@/lib/env";
/**
* Operation QR tokens are opaque, URL-safe, high-entropy identifiers
@@ -12,3 +14,26 @@ export function generateQrToken(): string {
.replace(/\//g, "_")
.replace(/=+$/, "");
}
/**
* The payload baked into the QR image is a full absolute URL so any phone
* camera app (which opens scans in a browser) goes straight to the scan page.
* APP_URL must match the externally reachable origin — see docs/DEPLOY.md.
*/
export function scanUrlForToken(token: string): string {
const base = env.APP_URL.replace(/\/+$/, "");
return `${base}/op/scan/${token}`;
}
/**
* Render the scan URL as a data-URL PNG suitable for <img src=...> on the
* admin detail page and, later, on the printable traveler card. Error
* correction level M balances density against smudge tolerance on paper.
*/
export async function renderQrPng(token: string): Promise<string> {
return QRCode.toDataURL(scanUrlForToken(token), {
errorCorrectionLevel: "M",
margin: 1,
width: 256,
});
}
+23
View File
@@ -235,3 +235,26 @@ export const UpdateOperationSchema = z
export const ReorderOperationsSchema = z.object({
order: z.array(z.string().min(1)).min(1),
});
// ---- operator scan actions ----------------------------------------------
// A scan-page "Pause" — stops the clock but does not complete the step. The
// operator can enter a partial unit count before dropping the claim.
export const ReleaseOperationSchema = z.object({
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText,
});
// A scan-page "Done". If the operation was flagged qcRequired the operator
// must stamp an inline pass/fail before we allow the close.
export const CloseOperationSchema = z.object({
unitsProcessed: z.coerce.number().int().min(0).max(1_000_000).nullable().optional(),
note: OptionalText,
qc: z
.object({
passed: z.boolean(),
notes: OptionalText,
measurements: JsonString,
})
.optional(),
});
+275 -4
View File
@@ -11,6 +11,7 @@
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"next": "^15.1.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.8"
@@ -20,6 +21,7 @@
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.17.0",
@@ -1861,6 +1863,16 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -2472,11 +2484,19 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2815,6 +2835,15 @@
"node": ">=6"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001788",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
@@ -2858,11 +2887,21 @@
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -2875,7 +2914,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-map": {
@@ -2986,6 +3024,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/deep-is": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -3039,6 +3086,12 @@
"node": ">=8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/doctrine": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
@@ -3933,6 +3986,15 @@
"node": ">= 0.4"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -4383,6 +4445,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-generator-function": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
@@ -5482,6 +5553,15 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -5499,7 +5579,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5541,6 +5620,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -5632,6 +5720,23 @@
"node": ">=6"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -5725,6 +5830,21 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resolve": {
"version": "2.0.0-next.6",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
@@ -5878,6 +5998,12 @@
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@@ -6101,6 +6227,26 @@
"node": ">= 0.4"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6214,6 +6360,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -6679,6 +6837,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/which-typed-array": {
"version": "1.1.20",
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
@@ -6711,6 +6875,113 @@
"node": ">=0.10.0"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/yargs/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs/node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yargs/node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+2
View File
@@ -18,6 +18,7 @@
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"next": "^15.1.0",
"qrcode": "^1.5.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^3.23.8"
@@ -27,6 +28,7 @@
"@tailwindcss/postcss": "^4.0.0",
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.10.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"eslint": "^9.17.0",
+1 -1
View File
File diff suppressed because one or more lines are too long