diff --git a/app/api/v1/operations/[id]/close/route.ts b/app/api/v1/operations/[id]/close/route.ts index 29c84dd..b6f139f 100644 --- a/app/api/v1/operations/[id]/close/route.ts +++ b/app/api/v1/operations/[id]/close/route.ts @@ -25,7 +25,19 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string const existing = await prisma.operation.findUnique({ where: { id }, - select: { id: true, status: true, claimedByUserId: true, qcRequired: true }, + select: { + id: true, + status: true, + claimedByUserId: true, + qcRequired: true, + unitsCompleted: true, + part: { + select: { + qty: true, + assembly: { select: { qty: true } }, + }, + }, + }, }); if (!existing) throw new ApiError(404, "not_found", "Operation not found"); if (existing.claimedByUserId !== actor.id) { @@ -38,6 +50,25 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string throw new ApiError(400, "qc_required", "This step requires an inline QC check before completing"); } + // --- Partial vs fully-done detection -------------------------------- + // The operator hits "Done" but may have only produced some of the + // assembly.qty × part.qty total units (e.g. end-of-shift handoff). In + // that case the step should end up `partial` so the NEXT operator can + // resume it — NOT `completed`, which locks it and requires admin to + // reset. We infer the operator's intent from the units they typed in: + // + // units == 0 (blank) → "trust me, it's done" → completed + // units >= remaining → math agrees → completed + // units < remaining → partial (clear claim, no completedAt) + // + // QC-required ops still demand a QC record even on partial close — + // that's about material checks, not about finishing the batch. + const units = body.unitsProcessed ?? 0; + const totalUnits = existing.part.assembly.qty * existing.part.qty; + const remaining = Math.max(0, totalUnits - existing.unitsCompleted); + const wouldFinish = units === 0 || units >= remaining; + const nextStatus: "completed" | "partial" = wouldFinish ? "completed" : "partial"; + const now = new Date(); await prisma.$transaction(async (tx) => { const openLog = await tx.timeLog.findFirst({ @@ -68,13 +99,13 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string } // unitsCompleted is cumulative across pause/resume cycles; on close we // add this session's batch so the total reflects everything the step - // actually produced. - const units = body.unitsProcessed ?? 0; + // actually produced. Partial close releases the claim so the next + // operator can resume; completed close sets completedAt for reporting. await tx.operation.update({ where: { id }, data: { - status: "completed", - completedAt: now, + status: nextStatus, + completedAt: nextStatus === "completed" ? now : null, claimedByUserId: null, claimedAt: null, ...(units > 0 ? { unitsCompleted: { increment: units } } : {}), @@ -85,18 +116,19 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string const op = await prisma.operation.findUnique({ where: { id } }); await audit({ actorId: actor.id, - action: "close_op", + action: nextStatus === "completed" ? "close_op" : "partial_close_op", entity: "Operation", entityId: id, after: { - status: "completed", + status: nextStatus, unitsProcessed: body.unitsProcessed ?? null, + unitsRemaining: Math.max(0, remaining - units), qcPassed: body.qc?.passed ?? null, }, ipAddress: clientIp(req), }); - return ok({ operation: op }); + return ok({ operation: op, partial: nextStatus === "partial" }); } catch (err) { return errorResponse(err); } diff --git a/app/op/scan/[token]/ScanClient.tsx b/app/op/scan/[token]/ScanClient.tsx index 3ce0bd7..8f40b6c 100644 --- a/app/op/scan/[token]/ScanClient.tsx +++ b/app/op/scan/[token]/ScanClient.tsx @@ -397,6 +397,28 @@ export default function ScanClient({ initialOp, viewer }: { initialOp: ScanOp; v ) : null} + {/* + Preview what Done will do. Keeps the match in lockstep with the + close route's partial-detection logic: units blank or >= + remaining means "fully done", anything less is a partial + handoff that releases the claim so the next operator can pick + it up. + */} + {(() => { + const typed = units ? Number(units) : 0; + const remaining = Math.max(0, totalUnits - op.unitsCompleted); + const willPartial = typed > 0 && typed < remaining; + return willPartial ? ( +
+ Pressing Done with {typed} of {remaining}{" "} + remaining will mark this step Partial and + release the claim so another operator can resume. Enter{" "} + {remaining} (or leave blank) if you actually + finished the batch. +
+ ) : null; + })()} +