@@ -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\" npx tsc --noEmit)",
|
||||||
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npm run build)",
|
"Bash(DATABASE_URL=\"file:./dev.db\" APP_SECRET=\"dev-only-change-me-dev-only-change-me-dev-only-change-me\" npm run build)",
|
||||||
"Bash(npx tsc *)",
|
"Bash(npx tsc *)",
|
||||||
"Bash(npx next *)"
|
"Bash(npx next *)",
|
||||||
|
"Bash(npm run *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-1
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -222,6 +222,7 @@ function OperationsSection({
|
|||||||
}) {
|
}) {
|
||||||
const [newOpen, setNewOpen] = useState(false);
|
const [newOpen, setNewOpen] = useState(false);
|
||||||
const [edit, setEdit] = useState<OperationRow | null>(null);
|
const [edit, setEdit] = useState<OperationRow | null>(null);
|
||||||
|
const [qrFor, setQrFor] = useState<OperationRow | null>(null);
|
||||||
const [busyId, setBusyId] = useState<string | null>(null);
|
const [busyId, setBusyId] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -324,6 +325,9 @@ function OperationsSection({
|
|||||||
>
|
>
|
||||||
↓
|
↓
|
||||||
</button>
|
</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}>
|
<Button variant="ghost" size="sm" onClick={() => setEdit(op)} disabled={busyId !== null}>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -374,10 +378,93 @@ function OperationsSection({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{qrFor && <QrModal operation={qrFor} onClose={() => setQrFor(null)} />}
|
||||||
</section>
|
</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({
|
function OperationModal({
|
||||||
partId,
|
partId,
|
||||||
operation,
|
operation,
|
||||||
|
|||||||
@@ -10,8 +10,24 @@ import { clientIp, userAgent } from "@/lib/request";
|
|||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
operatorId: z.string().min(1),
|
operatorId: z.string().min(1),
|
||||||
pin: z.string().regex(/^\d{4}$/, "PIN must be 4 digits"),
|
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) {
|
export async function POST(req: NextRequest) {
|
||||||
const parsed = Body.safeParse(await req.json().catch(() => ({})));
|
const parsed = Body.safeParse(await req.json().catch(() => ({})));
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -19,6 +35,7 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { operatorId, pin } = parsed.data;
|
const { operatorId, pin } = parsed.data;
|
||||||
|
const redirect = safeNext(parsed.data.next);
|
||||||
if (!isValidPin(pin)) {
|
if (!isValidPin(pin)) {
|
||||||
return NextResponse.json({ error: "Invalid PIN" }, { status: 400 });
|
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 });
|
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-164
@@ -1,174 +1,22 @@
|
|||||||
"use client";
|
import { Suspense } from "react";
|
||||||
|
import OperatorLoginClient from "./OperatorLoginClient";
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface Operator {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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() {
|
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 (
|
return (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
|
<main className="min-h-dvh flex items-center justify-center p-6 bg-slate-50">
|
||||||
<p className="text-slate-500">Loading…</p>
|
<p className="text-slate-500">Loading…</p>
|
||||||
</main>
|
</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}
|
<OperatorLoginClient />
|
||||||
</button>
|
</Suspense>
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-2
@@ -1,9 +1,17 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { requireOperator } from "@/lib/auth";
|
import { getCurrentUser } from "@/lib/auth";
|
||||||
import LogoutButton from "@/components/LogoutButton";
|
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 }) {
|
export default async function OperatorLayout({ children }: { children: React.ReactNode }) {
|
||||||
const user = await requireOperator();
|
const user = await getCurrentUser();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh flex flex-col">
|
<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">
|
<Link href="/op" className="font-semibold tracking-tight">
|
||||||
MRP
|
MRP
|
||||||
</Link>
|
</Link>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
<span className="ml-auto text-sm text-slate-500">{user.name}</span>
|
<span className="ml-auto text-sm text-slate-500">{user.name}</span>
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link href="/login/operator" className="ml-auto text-sm text-slate-900 hover:underline">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
|
|||||||
+68
-8
@@ -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 (
|
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>
|
<div>
|
||||||
<h1 className="text-2xl font-semibold">Scan a traveler QR code</h1>
|
<h1 className="text-2xl font-semibold">Hi, {user.name}</h1>
|
||||||
<p className="text-slate-500 mt-2">
|
<p className="text-slate-500 mt-1 text-sm">
|
||||||
Use your phone camera to scan the QR on a step card. It will open the step here so you can
|
Scan a QR card with your phone camera to start a step, or continue an active one below.
|
||||||
start, log time, and close out.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
{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>
|
||||||
|
) : (
|
||||||
|
<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>
|
</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;
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
import QRCode from "qrcode";
|
||||||
|
import { env } from "@/lib/env";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Operation QR tokens are opaque, URL-safe, high-entropy identifiers
|
* Operation QR tokens are opaque, URL-safe, high-entropy identifiers
|
||||||
@@ -12,3 +14,26 @@ export function generateQrToken(): string {
|
|||||||
.replace(/\//g, "_")
|
.replace(/\//g, "_")
|
||||||
.replace(/=+$/, "");
|
.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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -235,3 +235,26 @@ export const UpdateOperationSchema = z
|
|||||||
export const ReorderOperationsSchema = z.object({
|
export const ReorderOperationsSchema = z.object({
|
||||||
order: z.array(z.string().min(1)).min(1),
|
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(),
|
||||||
|
});
|
||||||
|
|||||||
Generated
+275
-4
@@ -11,6 +11,7 @@
|
|||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
@@ -1861,6 +1863,16 @@
|
|||||||
"undici-types": "~6.21.0"
|
"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": {
|
"node_modules/@types/react": {
|
||||||
"version": "19.2.14",
|
"version": "19.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||||
@@ -2472,11 +2484,19 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -2815,6 +2835,15 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/caniuse-lite": {
|
||||||
"version": "1.0.30001788",
|
"version": "1.0.30001788",
|
||||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
|
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz",
|
||||||
@@ -2858,11 +2887,21 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -2875,7 +2914,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concat-map": {
|
"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": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -3039,6 +3086,12 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/doctrine": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz",
|
||||||
@@ -3933,6 +3986,15 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -4383,6 +4445,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-generator-function": {
|
||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz",
|
"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"
|
"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": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -5499,7 +5579,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -5541,6 +5620,15 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/possible-typed-array-names": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
|
||||||
@@ -5632,6 +5720,23 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
@@ -5725,6 +5830,21 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/resolve": {
|
||||||
"version": "2.0.0-next.6",
|
"version": "2.0.0-next.6",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz",
|
||||||
@@ -5878,6 +5998,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
@@ -6101,6 +6227,26 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/string.prototype.includes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
|
"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"
|
"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": {
|
"node_modules/strip-bom": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
|
||||||
@@ -6679,6 +6837,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/which-typed-array": {
|
||||||
"version": "1.1.20",
|
"version": "1.1.20",
|
||||||
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
"resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz",
|
||||||
@@ -6711,6 +6875,113 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"@prisma/client": "^5.22.0",
|
"@prisma/client": "^5.22.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"@tailwindcss/postcss": "^4.0.0",
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
"@types/bcryptjs": "^2.4.6",
|
"@types/bcryptjs": "^2.4.6",
|
||||||
"@types/node": "^22.10.0",
|
"@types/node": "^22.10.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@types/react-dom": "^19.0.0",
|
"@types/react-dom": "^19.0.0",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user