This commit is contained in:
2026-03-18 06:39:38 -05:00
parent c49ed4bf4a
commit e00639bb8b
11 changed files with 488 additions and 4 deletions

View File

@@ -12,6 +12,7 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- Manufacturing finite-capacity slice with station daily capacity, parallel capacity, working-day calendars, calendar-aware operation scheduling, and operation-level rescheduling from the work-order detail page
- Manufacturing station edit support for working days, active state, queue, and capacity settings directly from the manufacturing screen
- Operation execution controls on work orders, including start/pause/resume/complete actions, labor posting, and actual-minute rollups by operation and work order
- Operation operator assignment and timer-based labor capture, with timer stop posting elapsed minutes back as labor entries
- Workbench rebalance controls for operation rows, including planner-side datetime rescheduling, quick shift moves, and heatmap-day targeting without leaving the dispatch surface
- Workbench station-to-station rebalance so planners can move an operation onto another active work center and rebuild the downstream chain from the same dispatch surface
- Workbench drag scheduling in station grouping mode, with draggable operation cards, station drop targets, heatmap-day-aware drop timing, and projected post-drop load cues before the move is committed

View File

@@ -27,7 +27,7 @@ Current foundation scope includes:
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files
- shipping shipments linked to sales orders with packing slips, shipping labels, bills of lading, and logistics attachments
- projects with customer/commercial/shipment linkage, owners, due dates, milestones, rollups, notes, and attachments
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments
- manufacturing work orders with project linkage, station-based operation templates, editable station calendars/capacity settings, calendar-aware operation scheduling, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, operation rescheduling, and work-order attachments
- planning workbench with live project/manufacturing schedule data, exception rail, heatmap load view, agenda view, focus drawer, station load grouping, readiness filters, overload visibility, inline dispatch actions, planner-side operation rebalance controls including station-to-station moves, and station-lane drag scheduling
- sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- planner-assisted conversion of demand-planning recommendations into prefilled work-order and purchase-order drafts
@@ -110,7 +110,7 @@ Next expansion areas:
## Manufacturing Direction
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, editable station calendars and capacity settings, automatic work-order operation plans, operation-level execution controls, labor posting, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility.
Manufacturing is now a separate execution subsystem rather than being collapsed into Projects. The current slice ships work-order records with build-item linkage, optional project linkage, warehouse/location output posting, BOM-based material requirement visibility, station master data, item-level operation templates, editable station calendars and capacity settings, automatic work-order operation plans, operation-level execution controls, operator assignment, timer-based and manual labor posting, operation-level rescheduling, material issue posting, completion posting, work-order attachments, and dashboard visibility.
Current interactions:

View File

@@ -90,7 +90,7 @@ This file tracks work that still needs to be completed. Shipped phase history an
- Work orders tied more explicitly to sales demand or internal build demand where appropriate
- Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
- Material consumption depth, WIP tracking, and execution traceability
- Deeper labor depth beyond the shipped manual operation labor posting, including crew assignment, live timer capture, and machine/runtime integration
- Deeper labor depth beyond the shipped operator assignment and timer-based labor capture, including crew-level staffing, labor approvals, and machine/runtime integration
- Manufacturing rollups for open work, blockers, shortages, and throughput
- Traveler/job packet output
- Partial completions and split-order execution visibility

View File

@@ -36,7 +36,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Project milestones and project-side milestone/work-order rollups
- Project cockpit section on detail pages for commercial, supply, execution, delivery, purchasing, readiness-risk, and cost-snapshot visibility, with direct launch paths into prefilled project work orders and demand-linked purchase orders plus a project activity timeline
- Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, operation execution controls, labor posting, material issue posting, completion posting, and work-order attachments
- Manufacturing foundation with work orders, project linkage, operation execution controls, operator assignment, timer-based and manual labor posting, material issue posting, completion posting, and work-order attachments
- Manufacturing stations, item routing templates, editable station calendars/capacity settings, automatic work-order operation planning, and operation-level rescheduling for the workbench schedule
- Vendor invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity

View File

@@ -61,12 +61,15 @@ import type {
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
ManufacturingUserOptionDto,
} from "@mrp/shared";
import type {
ProjectCustomerOptionDto,
@@ -608,6 +611,9 @@ export const api = {
getManufacturingProjectOptions(token: string) {
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token);
},
getManufacturingUserOptions(token: string) {
return request<ManufacturingUserOptionDto[]>("/api/v1/manufacturing/users/options", undefined, token);
},
getManufacturingStations(token: string) {
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
},
@@ -666,6 +672,20 @@ export const api = {
token
);
},
updateWorkOrderOperationAssignment(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationAssignmentInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/assignment`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
updateWorkOrderOperationTimer(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationTimerInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/timer`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,

View File

@@ -1,11 +1,14 @@
import { permissions } from "@mrp/shared";
import type {
ManufacturingUserOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderMaterialIssueInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderStatus,
} from "@mrp/shared";
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
@@ -24,6 +27,7 @@ export function WorkOrderDetailPage() {
const { workOrderId } = useParams();
const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
const [status, setStatus] = useState("Loading work order...");
@@ -32,9 +36,13 @@ export function WorkOrderDetailPage() {
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
const [operationLaborForm, setOperationLaborForm] = useState<Record<string, WorkOrderOperationLaborEntryInput>>({});
const [operationAssignmentForm, setOperationAssignmentForm] = useState<Record<string, WorkOrderOperationAssignmentInput>>({});
const [operationTimerForm, setOperationTimerForm] = useState<Record<string, WorkOrderOperationTimerInput>>({});
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
const [executingOperationId, setExecutingOperationId] = useState<string | null>(null);
const [postingLaborOperationId, setPostingLaborOperationId] = useState<string | null>(null);
const [assigningOperationId, setAssigningOperationId] = useState<string | null>(null);
const [timerOperationId, setTimerOperationId] = useState<string | null>(null);
const [pendingConfirmation, setPendingConfirmation] = useState<
| {
kind: "status" | "issue" | "completion";
@@ -79,6 +87,16 @@ export function WorkOrderDetailPage() {
nextWorkOrder.operations.map((operation) => [operation.id, { minutes: Math.max(Math.round(operation.plannedMinutes / 4), 15), notes: "" }])
)
);
setOperationAssignmentForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { assignedOperatorId: operation.assignedOperatorId }])
)
);
setOperationTimerForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { action: operation.activeTimerStartedAt ? "STOP" : "START", notes: "" }])
)
);
setStatus("Work order loaded.");
})
.catch((error: unknown) => {
@@ -87,6 +105,7 @@ export function WorkOrderDetailPage() {
});
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
api.getManufacturingUserOptions(token).then(setOperatorOptions).catch(() => setOperatorOptions([]));
}, [token, workOrderId]);
const filteredLocationOptions = useMemo(
@@ -247,6 +266,60 @@ export function WorkOrderDetailPage() {
}
}
async function submitOperationAssignment(operationId: string) {
if (!token || !workOrder) {
return;
}
const payload = operationAssignmentForm[operationId];
if (!payload) {
return;
}
setAssigningOperationId(operationId);
setStatus("Updating operator assignment...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationAssignment(token, workOrder.id, operationId, payload);
setWorkOrder(nextWorkOrder);
setStatus("Operator assignment updated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update operator assignment.";
setStatus(message);
} finally {
setAssigningOperationId(null);
}
}
async function submitOperationTimer(operationId: string, action: WorkOrderOperationTimerInput["action"]) {
if (!token || !workOrder) {
return;
}
const payload = operationTimerForm[operationId] ?? { action, notes: "" };
setTimerOperationId(operationId);
setStatus(action === "START" ? "Starting timer..." : "Stopping timer...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationTimer(token, workOrder.id, operationId, {
action,
notes: payload.notes,
});
setWorkOrder(nextWorkOrder);
setOperationTimerForm((current) => ({
...current,
[operationId]: {
action: action === "START" ? "STOP" : "START",
notes: "",
},
}));
setStatus(action === "START" ? "Operation timer started." : "Operation timer stopped and labor posted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update operation timer.";
setStatus(message);
} finally {
setTimerOperationId(null);
}
}
function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!workOrder) {
return;
@@ -402,6 +475,8 @@ export function WorkOrderDetailPage() {
<div className="font-semibold text-text">{operation.status.replaceAll("_", " ")}</div>
<div className="mt-1">Start {operation.actualStart ? new Date(operation.actualStart).toLocaleString() : "Not started"}</div>
<div>End {operation.actualEnd ? new Date(operation.actualEnd).toLocaleString() : "Open"}</div>
<div>Operator {operation.assignedOperatorName ?? "Unassigned"}</div>
<div>{operation.activeTimerStartedAt ? `Timer running since ${new Date(operation.activeTimerStartedAt).toLocaleTimeString()}` : "Timer stopped"}</div>
<div>{operation.laborEntryCount} labor entr{operation.laborEntryCount === 1 ? "y" : "ies"}</div>
</td>
<td className="px-3 py-3 text-xs text-muted">
@@ -431,6 +506,35 @@ export function WorkOrderDetailPage() {
<button type="button" onClick={() => void submitOperationExecution(operation.id, "COMPLETE")} disabled={executingOperationId === operation.id} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60">Complete</button>
) : null}
</div>
<div className="flex items-center gap-2">
<select
value={operationAssignmentForm[operation.id]?.assignedOperatorId ?? ""}
onChange={(event) =>
setOperationAssignmentForm((current) => ({
...current,
[operation.id]: {
assignedOperatorId: event.target.value || null,
},
}))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
>
<option value="">Unassigned operator</option>
{operatorOptions.map((operator) => (
<option key={operator.id} value={operator.id}>
{operator.name} ({operator.email})
</option>
))}
</select>
<button
type="button"
onClick={() => void submitOperationAssignment(operation.id)}
disabled={assigningOperationId === operation.id}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{assigningOperationId === operation.id ? "Saving..." : "Assign"}
</button>
</div>
<input
type="datetime-local"
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
@@ -475,6 +579,31 @@ export function WorkOrderDetailPage() {
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
</div>
<div className="flex items-center gap-2">
<input
type="text"
placeholder={operation.activeTimerStartedAt ? "Stop timer note" : "Start timer note"}
value={operationTimerForm[operation.id]?.notes ?? ""}
onChange={(event) =>
setOperationTimerForm((current) => ({
...current,
[operation.id]: {
action: operation.activeTimerStartedAt ? "STOP" : "START",
notes: event.target.value,
},
}))
}
className="w-full rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
<button
type="button"
onClick={() => void submitOperationTimer(operation.id, operation.activeTimerStartedAt ? "STOP" : "START")}
disabled={timerOperationId === operation.id || operation.status === "COMPLETE"}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
{timerOperationId === operation.id ? "Saving..." : operation.activeTimerStartedAt ? "Stop timer" : "Start timer"}
</button>
</div>
<div className="flex gap-2">
<button
type="button"

View File

@@ -0,0 +1,44 @@
-- AlterTable
ALTER TABLE "WorkOrderOperation" ADD COLUMN "assignedOperatorId" TEXT;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "activeTimerStartedAt" DATETIME;
-- CreateIndex
CREATE INDEX "WorkOrderOperation_assignedOperatorId_plannedStart_idx" ON "WorkOrderOperation"("assignedOperatorId", "plannedStart");
-- AddForeignKey
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_WorkOrderOperation" (
"id" TEXT NOT NULL PRIMARY KEY,
"workOrderId" TEXT NOT NULL,
"stationId" TEXT NOT NULL,
"assignedOperatorId" TEXT,
"sequence" INTEGER NOT NULL,
"setupMinutes" INTEGER NOT NULL DEFAULT 0,
"runMinutesPerUnit" INTEGER NOT NULL DEFAULT 0,
"moveMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedMinutes" INTEGER NOT NULL DEFAULT 0,
"plannedStart" DATETIME NOT NULL,
"plannedEnd" DATETIME NOT NULL,
"notes" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'PENDING',
"actualStart" DATETIME,
"actualEnd" DATETIME,
"actualMinutes" INTEGER NOT NULL DEFAULT 0,
"activeTimerStartedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperation_workOrderId_fkey" FOREIGN KEY ("workOrderId") REFERENCES "WorkOrder" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_stationId_fkey" FOREIGN KEY ("stationId") REFERENCES "ManufacturingStation" ("id") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperation_assignedOperatorId_fkey" FOREIGN KEY ("assignedOperatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_WorkOrderOperation" ("actualEnd", "actualMinutes", "actualStart", "activeTimerStartedAt", "assignedOperatorId", "createdAt", "id", "moveMinutes", "notes", "plannedEnd", "plannedMinutes", "plannedStart", "runMinutesPerUnit", "sequence", "setupMinutes", "stationId", "status", "updatedAt", "workOrderId")
SELECT "actualEnd", "actualMinutes", "actualStart", "activeTimerStartedAt", "assignedOperatorId", "createdAt", "id", "moveMinutes", "notes", "plannedEnd", "plannedMinutes", "plannedStart", "runMinutesPerUnit", "sequence", "setupMinutes", "stationId", "status", "updatedAt", "workOrderId" FROM "WorkOrderOperation";
DROP TABLE "WorkOrderOperation";
ALTER TABLE "new_WorkOrderOperation" RENAME TO "WorkOrderOperation";
PRAGMA foreign_key_check;
PRAGMA foreign_keys=ON;
-- RecreateIndex
CREATE INDEX "WorkOrderOperation_workOrderId_sequence_idx" ON "WorkOrderOperation"("workOrderId", "sequence");
CREATE INDEX "WorkOrderOperation_stationId_plannedStart_idx" ON "WorkOrderOperation"("stationId", "plannedStart");
CREATE INDEX "WorkOrderOperation_assignedOperatorId_plannedStart_idx" ON "WorkOrderOperation"("assignedOperatorId", "plannedStart");

View File

@@ -27,6 +27,7 @@ model User {
workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[]
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
assignedWorkOrderOperations WorkOrderOperation[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
@@ -681,6 +682,7 @@ model WorkOrderOperation {
id String @id @default(cuid())
workOrderId String
stationId String
assignedOperatorId String?
sequence Int
setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0)
@@ -693,14 +695,17 @@ model WorkOrderOperation {
actualStart DateTime?
actualEnd DateTime?
actualMinutes Int @default(0)
activeTimerStartedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
assignedOperator User? @relation(fields: [assignedOperatorId], references: [id], onDelete: SetNull)
laborEntries WorkOrderOperationLaborEntry[]
@@index([workOrderId, sequence])
@@index([stationId, plannedStart])
@@index([assignedOperatorId, plannedStart])
}
model WorkOrderOperationLaborEntry {

View File

@@ -13,11 +13,14 @@ import {
listManufacturingItemOptions,
listManufacturingProjectOptions,
listManufacturingStations,
listManufacturingUserOptions,
listWorkOrders,
recordWorkOrderCompletion,
recordWorkOrderOperationLabor,
updateManufacturingStation,
updateWorkOrderOperationAssignment,
updateWorkOrderOperationExecution,
updateWorkOrderOperationTimer,
updateWorkOrder,
updateWorkOrderOperationSchedule,
updateWorkOrderStatus,
@@ -86,6 +89,15 @@ const operationLaborSchema = z.object({
notes: z.string(),
});
const operationAssignmentSchema = z.object({
assignedOperatorId: z.string().trim().min(1).nullable(),
});
const operationTimerSchema = z.object({
action: z.enum(["START", "STOP"]),
notes: z.string(),
});
function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null;
}
@@ -100,6 +112,10 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
return ok(response, await listManufacturingProjectOptions());
});
manufacturingRouter.get("/users/options", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingUserOptions());
});
manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingStations());
});
@@ -267,6 +283,46 @@ manufacturingRouter.post("/work-orders/:workOrderId/operations/:operationId/labo
return ok(response, result.workOrder, 201);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/assignment", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationAssignmentSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation assignment payload is invalid.");
}
const result = await updateWorkOrderOperationAssignment(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/timer", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
const operationId = getRouteParam(request.params.operationId);
if (!workOrderId || !operationId) {
return fail(response, 400, "INVALID_INPUT", "Work-order or operation id is invalid.");
}
const parsed = operationTimerSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation timer payload is invalid.");
}
const result = await updateWorkOrderOperationTimer(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
return ok(response, result.workOrder);
});
manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) {

View File

@@ -3,13 +3,16 @@ import type {
ManufacturingStationInput,
ManufacturingItemOptionDto,
ManufacturingProjectOptionDto,
ManufacturingUserOptionDto,
WorkOrderCompletionInput,
WorkOrderDetailDto,
WorkOrderInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationDto,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderMaterialIssueInput,
WorkOrderStatus,
WorkOrderSummaryDto,
@@ -115,6 +118,7 @@ type WorkOrderRecord = {
actualStart: Date | null;
actualEnd: Date | null;
actualMinutes: number;
activeTimerStartedAt: Date | null;
station: {
id: string;
code: string;
@@ -123,6 +127,11 @@ type WorkOrderRecord = {
parallelCapacity: number;
workingDays: string;
};
assignedOperator: {
id: string;
firstName: string;
lastName: string;
} | null;
laborEntries: Array<{
id: string;
minutes: number;
@@ -268,6 +277,13 @@ function buildInclude() {
workingDays: true,
},
},
assignedOperator: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
laborEntries: {
include: {
createdBy: {
@@ -401,6 +417,9 @@ function mapDetail(
actualEnd: operation.actualEnd ? operation.actualEnd.toISOString() : null,
actualMinutes: operation.actualMinutes,
laborEntryCount: operation.laborEntries.length,
assignedOperatorId: operation.assignedOperator?.id ?? null,
assignedOperatorName: operation.assignedOperator ? `${operation.assignedOperator.firstName} ${operation.assignedOperator.lastName}`.trim() : null,
activeTimerStartedAt: operation.activeTimerStartedAt ? operation.activeTimerStartedAt.toISOString() : null,
laborEntries: operation.laborEntries.map((entry) => ({
id: entry.id,
minutes: entry.minutes,
@@ -1068,6 +1087,27 @@ export async function listManufacturingProjectOptions(): Promise<ManufacturingPr
}));
}
export async function listManufacturingUserOptions(): Promise<ManufacturingUserOptionDto[]> {
const users = await prisma.user.findMany({
where: {
isActive: true,
},
select: {
id: true,
email: true,
firstName: true,
lastName: true,
},
orderBy: [{ firstName: "asc" }, { lastName: "asc" }, { email: "asc" }],
});
return users.map((user) => ({
id: user.id,
name: `${user.firstName} ${user.lastName}`.trim(),
email: user.email,
}));
}
export async function listWorkOrders(filters: {
q?: string;
status?: WorkOrderStatus;
@@ -1553,6 +1593,177 @@ export async function recordWorkOrderOperationLabor(
return { ok: true as const, workOrder };
}
export async function updateWorkOrderOperationAssignment(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationAssignmentInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
assignedOperatorId: true,
workOrder: {
select: {
workOrderNumber: true,
},
},
},
});
if (!existing || existing.workOrderId !== workOrderId) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
if (payload.assignedOperatorId) {
const user = await prisma.user.findUnique({
where: { id: payload.assignedOperatorId },
select: {
id: true,
isActive: true,
},
});
if (!user || !user.isActive) {
return { ok: false as const, reason: "Assigned operator was not found or is inactive." };
}
}
await prisma.workOrderOperation.update({
where: { id: operationId },
data: {
assignedOperatorId: payload.assignedOperatorId,
},
});
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return { ok: false as const, reason: "Unable to load updated work order." };
}
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "operation.assignment.updated",
summary: `Updated operator assignment for operation ${existing.sequence} on ${existing.workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: existing.workOrder.workOrderNumber,
operationId,
sequence: existing.sequence,
previousAssignedOperatorId: existing.assignedOperatorId,
assignedOperatorId: payload.assignedOperatorId,
},
});
return { ok: true as const, workOrder };
}
export async function updateWorkOrderOperationTimer(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationTimerInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
status: true,
actualStart: true,
activeTimerStartedAt: true,
assignedOperatorId: true,
workOrder: {
select: {
status: true,
workOrderNumber: true,
},
},
},
});
if (!existing || existing.workOrderId !== workOrderId) {
return { ok: false as const, reason: "Work-order operation was not found." };
}
if (existing.workOrder.status === "COMPLETE" || existing.workOrder.status === "CANCELLED" || existing.workOrder.status === "DRAFT") {
return { ok: false as const, reason: "Timer actions can only be used on released or active work orders." };
}
const now = new Date();
if (payload.action === "START") {
if (existing.activeTimerStartedAt) {
return { ok: false as const, reason: "Operation timer is already running." };
}
await prisma.workOrderOperation.update({
where: { id: operationId },
data: {
activeTimerStartedAt: now,
actualStart: existing.actualStart ?? now,
status: existing.status === "COMPLETE" ? existing.status : "IN_PROGRESS",
},
});
await syncWorkOrderStatusFromOperationActivity(workOrderId);
} else {
if (!existing.activeTimerStartedAt) {
return { ok: false as const, reason: "Operation timer is not currently running." };
}
const minutes = Math.max(Math.ceil((now.getTime() - existing.activeTimerStartedAt.getTime()) / 60000), 1);
await prisma.$transaction(async (tx) => {
await tx.workOrderOperationLaborEntry.create({
data: {
operationId,
minutes,
notes: payload.notes || `Timer stop on operation ${existing.sequence}`,
createdById: actorId ?? existing.assignedOperatorId ?? null,
},
});
await tx.workOrderOperation.update({
where: { id: operationId },
data: {
activeTimerStartedAt: null,
status: existing.status === "COMPLETE" ? existing.status : "PAUSED",
actualMinutes: {
increment: minutes,
},
},
});
});
await syncWorkOrderStatusFromOperationActivity(workOrderId);
}
const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) {
return { ok: false as const, reason: "Unable to load updated work order." };
}
await logAuditEvent({
actorId,
entityType: "work-order",
entityId: workOrderId,
action: "operation.timer.updated",
summary: `${payload.action} timer on operation ${existing.sequence} for ${existing.workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: existing.workOrder.workOrderNumber,
operationId,
sequence: existing.sequence,
action: payload.action,
notes: payload.notes,
},
});
return { ok: true as const, workOrder };
}
export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId },

View File

@@ -37,6 +37,12 @@ export interface ManufacturingProjectOptionDto {
status: string;
}
export interface ManufacturingUserOptionDto {
id: string;
name: string;
email: string;
}
export interface ManufacturingItemOptionDto {
id: string;
sku: string;
@@ -95,6 +101,9 @@ export interface WorkOrderOperationDto {
actualEnd: string | null;
actualMinutes: number;
laborEntryCount: number;
assignedOperatorId: string | null;
assignedOperatorName: string | null;
activeTimerStartedAt: string | null;
laborEntries: WorkOrderOperationLaborEntryDto[];
}
@@ -200,3 +209,12 @@ export interface WorkOrderOperationLaborEntryInput {
minutes: number;
notes: string;
}
export interface WorkOrderOperationAssignmentInput {
assignedOperatorId: string | null;
}
export interface WorkOrderOperationTimerInput {
action: "START" | "STOP";
notes: string;
}