This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { ok, errorResponse, requireRole, parseJson } from "@/lib/api";
|
||||
import { DuplicateAssemblySchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
import { duplicateAssembly } from "@/lib/duplicate";
|
||||
|
||||
/**
|
||||
* Admin-only. Clone an Assembly (plus every child Part, plus every
|
||||
* Operation when includeOperations is true) into the same project under a
|
||||
* new `code`. Useful when a new project needs the same assembly with minor
|
||||
* tweaks — duplicate then edit rather than rebuilding from scratch.
|
||||
*/
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const body = await parseJson(req, DuplicateAssemblySchema);
|
||||
|
||||
const result = await duplicateAssembly({
|
||||
sourceAssemblyId: id,
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
includeOperations: body.includeOperations ?? true,
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "duplicate",
|
||||
entity: "Assembly",
|
||||
entityId: result.id,
|
||||
after: { sourceAssemblyId: id, ...result },
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok(
|
||||
{
|
||||
assembly: { id: result.id },
|
||||
partsCopied: result.partsCopied,
|
||||
operationsCopied: result.operationsCopied,
|
||||
},
|
||||
{ status: 201 },
|
||||
);
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { ok, errorResponse, requireRole, parseJson } from "@/lib/api";
|
||||
import { DuplicatePartSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
import { duplicatePart } from "@/lib/duplicate";
|
||||
|
||||
/**
|
||||
* Admin-only. Clone a Part into its existing assembly with a new `code`
|
||||
* (and optionally a new `name`). By default every Operation is cloned too —
|
||||
* with fresh qrTokens, reset to `pending` status and zero units. Useful for
|
||||
* spinning up a left/right variant, or re-running a completed part.
|
||||
*
|
||||
* Not used for cross-assembly / cross-project moves — admin can edit the
|
||||
* code / qty / notes on the copy afterwards.
|
||||
*/
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const body = await parseJson(req, DuplicatePartSchema);
|
||||
|
||||
const result = await duplicatePart({
|
||||
sourcePartId: id,
|
||||
code: body.code,
|
||||
name: body.name,
|
||||
includeOperations: body.includeOperations ?? true,
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "duplicate",
|
||||
entity: "Part",
|
||||
entityId: result.id,
|
||||
after: { sourcePartId: id, ...result },
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ part: { id: result.id }, operationsCopied: result.operationsCopied }, { status: 201 });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { type NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { errorResponse, requireRole, ApiError } from "@/lib/api";
|
||||
import { readFileBytes } from "@/lib/files";
|
||||
import {
|
||||
renderProjectTravelers,
|
||||
type OperationCardData,
|
||||
type PartCoverData,
|
||||
} from "@/lib/pdf";
|
||||
|
||||
/**
|
||||
* Admin-only. Bulk traveler PDF for an entire project — one megafile
|
||||
* containing every part's cover + drawings + op cards, prefixed with a
|
||||
* project-level summary sheet.
|
||||
*
|
||||
* Query params:
|
||||
* ?scope=all (default) every part with at least one operation
|
||||
* ?scope=incomplete only parts that still have pending / partial /
|
||||
* in_progress / qc_failed ops (i.e. what's left to run)
|
||||
*
|
||||
* Parts with zero operations are always skipped — they'd render an empty
|
||||
* card anyway. Drawings are inlined best-effort (missing files are logged
|
||||
* and the PDF still renders).
|
||||
*/
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const scope = (new URL(req.url).searchParams.get("scope") ?? "all").toLowerCase();
|
||||
if (scope !== "all" && scope !== "incomplete") {
|
||||
throw new ApiError(400, "bad_scope", "scope must be 'all' or 'incomplete'");
|
||||
}
|
||||
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, code: true, name: true, customerCode: true, dueDate: true },
|
||||
});
|
||||
if (!project) throw new ApiError(404, "not_found", "Project not found");
|
||||
|
||||
const parts = await prisma.part.findMany({
|
||||
where: {
|
||||
assembly: { projectId: id },
|
||||
...(scope === "incomplete"
|
||||
? {
|
||||
operations: {
|
||||
some: {
|
||||
status: { in: ["pending", "partial", "in_progress", "qc_failed"] },
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [{ assembly: { code: "asc" } }, { code: "asc" }],
|
||||
include: {
|
||||
assembly: {
|
||||
include: {
|
||||
project: { select: { code: true, name: true } },
|
||||
drawingFile: { select: { path: true, originalName: true } },
|
||||
},
|
||||
},
|
||||
stepFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
|
||||
drawingFile: {
|
||||
select: { originalName: true, sizeBytes: true, sha256: true, path: true },
|
||||
},
|
||||
cutFile: { select: { originalName: true, sizeBytes: true, sha256: true } },
|
||||
operations: {
|
||||
orderBy: { sequence: "asc" },
|
||||
include: { machine: { select: { name: true, kind: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eligible = parts.filter((p) => p.operations.length > 0);
|
||||
if (eligible.length === 0) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
"no_parts",
|
||||
scope === "incomplete"
|
||||
? "No parts with outstanding operations in this project"
|
||||
: "No parts with operations in this project",
|
||||
);
|
||||
}
|
||||
|
||||
const bundles = await Promise.all(
|
||||
eligible.map(async (part) => {
|
||||
const projectHead = { code: part.assembly.project.code, name: part.assembly.project.name };
|
||||
const assembly = {
|
||||
code: part.assembly.code,
|
||||
name: part.assembly.name,
|
||||
qty: part.assembly.qty,
|
||||
};
|
||||
const partHeader = {
|
||||
code: part.code,
|
||||
name: part.name,
|
||||
material: part.material,
|
||||
qty: part.qty,
|
||||
};
|
||||
|
||||
const drawingPdfBytes = await tryReadPdf(part.drawingFile?.path ?? null);
|
||||
const assemblyDrawingPdfBytes = await tryReadPdf(part.assembly.drawingFile?.path ?? null);
|
||||
|
||||
const cover: PartCoverData = {
|
||||
project: projectHead,
|
||||
assembly,
|
||||
part: { ...partHeader, notes: part.notes },
|
||||
files: [
|
||||
{ label: "STEP / 3D", file: part.stepFile },
|
||||
{ label: "Drawing PDF", file: part.drawingFile },
|
||||
{ label: "Cut file", file: part.cutFile },
|
||||
],
|
||||
operations: part.operations.map((op) => ({
|
||||
sequence: op.sequence,
|
||||
name: op.name,
|
||||
kind: op.kind,
|
||||
machineName: op.machine?.name ?? null,
|
||||
qcRequired: op.qcRequired,
|
||||
qrToken: op.qrToken,
|
||||
unitsCompleted: op.unitsCompleted,
|
||||
status: op.status,
|
||||
})),
|
||||
};
|
||||
|
||||
const cards: OperationCardData[] = part.operations.map((op) => ({
|
||||
project: projectHead,
|
||||
assembly,
|
||||
part: partHeader,
|
||||
operation: {
|
||||
id: op.id,
|
||||
sequence: op.sequence,
|
||||
name: op.name,
|
||||
kind: op.kind,
|
||||
qrToken: op.qrToken,
|
||||
machineName: op.machine?.name ?? null,
|
||||
machineKind: op.machine?.kind ?? null,
|
||||
settings: op.settings,
|
||||
materialNotes: op.materialNotes,
|
||||
instructions: op.instructions,
|
||||
qcRequired: op.qcRequired,
|
||||
plannedMinutes: op.plannedMinutes,
|
||||
plannedUnits: op.plannedUnits,
|
||||
unitsCompleted: op.unitsCompleted,
|
||||
status: op.status,
|
||||
},
|
||||
}));
|
||||
|
||||
return { cover, cards, drawingPdfBytes, assemblyDrawingPdfBytes };
|
||||
}),
|
||||
);
|
||||
|
||||
const pdf = await renderProjectTravelers({
|
||||
project: {
|
||||
code: project.code,
|
||||
name: project.name,
|
||||
customerCode: project.customerCode,
|
||||
dueDate: project.dueDate,
|
||||
},
|
||||
bundles,
|
||||
});
|
||||
const suffix = scope === "incomplete" ? "-incomplete" : "";
|
||||
const safeName = `${project.code}${suffix}-travelers.pdf`.replace(/[^A-Za-z0-9._-]/g, "_");
|
||||
|
||||
return new NextResponse(pdf as unknown as BodyInit, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${safeName}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
async function tryReadPdf(path: string | null): Promise<Uint8Array | null> {
|
||||
if (!path) return null;
|
||||
try {
|
||||
const buf = await readFileBytes(path);
|
||||
return new Uint8Array(buf);
|
||||
} catch (err) {
|
||||
console.warn("[projects/travelers.pdf] could not read drawing file", { path, err });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { type NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { ok, errorResponse, requireRole, parseJson, ApiError } from "@/lib/api";
|
||||
import { UpdateTimeLogSchema } from "@/lib/schemas";
|
||||
import { audit } from "@/lib/audit";
|
||||
import { clientIp } from "@/lib/request";
|
||||
|
||||
/**
|
||||
* Admin-only correction of a TimeLog row. Intended for "operator forgot to
|
||||
* pause overnight" cleanup — plan-vs-actual hours reports are only as good
|
||||
* as the data on the floor, and a 16-hour phantom entry is worse than a
|
||||
* deleted one. We audit the before/after so the raw operator entry is still
|
||||
* traceable.
|
||||
*
|
||||
* Does NOT mutate Operation.status or claims. If the op itself is stuck
|
||||
* in_progress, the admin should use the existing release/close routes.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const body = await parseJson(req, UpdateTimeLogSchema);
|
||||
|
||||
const before = await prisma.timeLog.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Time log not found");
|
||||
|
||||
// Resolve the effective startedAt / endedAt we'd persist and reject obvious
|
||||
// nonsense (endedAt before startedAt). Null endedAt is still allowed —
|
||||
// reopening a log for the operator to close themselves is a legitimate
|
||||
// undo of a premature admin-close.
|
||||
const startedAt = body.startedAt ?? before.startedAt;
|
||||
const endedAt =
|
||||
body.endedAt !== undefined ? body.endedAt : before.endedAt;
|
||||
if (endedAt !== null && endedAt < startedAt) {
|
||||
throw new ApiError(
|
||||
400,
|
||||
"invalid_range",
|
||||
"endedAt must be on or after startedAt",
|
||||
);
|
||||
}
|
||||
|
||||
const updated = await prisma.timeLog.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.startedAt !== undefined ? { startedAt: body.startedAt } : {}),
|
||||
...(body.endedAt !== undefined ? { endedAt: body.endedAt } : {}),
|
||||
...(body.unitsProcessed !== undefined ? { unitsProcessed: body.unitsProcessed } : {}),
|
||||
...(body.note !== undefined ? { note: body.note } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "correct_timelog",
|
||||
entity: "TimeLog",
|
||||
entityId: id,
|
||||
before,
|
||||
after: updated,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
|
||||
return ok({ timeLog: updated });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Admin-only. Deletes a TimeLog row outright — reserve for obviously-bogus
|
||||
* entries (duplicate scans, test pings). Note this does NOT walk back the
|
||||
* operation's `unitsCompleted` counter; if a real unit count was logged
|
||||
* you almost always want to PATCH to zero it out instead.
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const actor = await requireRole("admin");
|
||||
const { id } = await ctx.params;
|
||||
const before = await prisma.timeLog.findUnique({ where: { id } });
|
||||
if (!before) throw new ApiError(404, "not_found", "Time log not found");
|
||||
await prisma.timeLog.delete({ where: { id } });
|
||||
await audit({
|
||||
actorId: actor.id,
|
||||
action: "delete_timelog",
|
||||
entity: "TimeLog",
|
||||
entityId: id,
|
||||
before,
|
||||
ipAddress: clientIp(req),
|
||||
});
|
||||
return ok({ ok: true });
|
||||
} catch (err) {
|
||||
return errorResponse(err);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user