Compare commits

..

2 Commits

Author SHA1 Message Date
e00639bb8b timers 2026-03-18 06:39:38 -05:00
c49ed4bf4a manufacturing layer 2026-03-18 06:22:37 -05:00
15 changed files with 1045 additions and 20 deletions

View File

@@ -11,9 +11,12 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh
- Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions - Planning workbench dispatch upgrade with station load summaries, readiness scoring, release-ready and blocker filters, richer planner rows, and inline release/build/buy actions
- 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 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 - 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 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 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 - 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
- Workbench station cards now show planned-vs-actual load so planners can compare schedule intent against recorded execution time
- Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow - Project milestones with status, due dates, notes, and edit-time sequencing inside the project workflow
- Project-side milestone and work-order rollups surfaced on project list and detail pages - Project-side milestone and work-order rollups surfaced on project list and detail pages
- Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form

View File

@@ -27,7 +27,7 @@ Current foundation scope includes:
- purchase-order supporting documents for vendor invoices, acknowledgements, certifications, and backup files - 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 - 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 - 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, 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 - 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 - 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 - 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 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 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: Current interactions:
@@ -126,7 +126,7 @@ Next expansion areas:
## Planning Direction ## Planning Direction
Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, station-lane drag scheduling with projected load cues, inline release/build/buy follow-through, and agenda sequencing. Planning is now the live scheduling and visibility layer over projects and manufacturing instead of a placeholder wrapper. The current slice ships a planning workbench backed by active projects, due-date milestones, linked work orders, standalone manufacturing queue visibility, exception rails, dense load heatmaps, station load summaries, readiness scoring, overload visibility, focus-drawer inspection, planner-side operation rebalance controls including station reassignment, station-lane drag scheduling with projected load cues, planned-vs-actual station load visibility, inline release/build/buy follow-through, and agenda sequencing.
Current interactions: 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 - 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 - Routing/work-center structure for manufacturing steps and handoffs beyond the current station templates
- Material consumption depth, WIP tracking, and execution traceability - Material consumption depth, WIP tracking, and execution traceability
- Labor and machine-time capture for production execution - 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 - Manufacturing rollups for open work, blockers, shortages, and throughput
- Traveler/job packet output - Traveler/job packet output
- Partial completions and split-order execution visibility - 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 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 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 - Project list/detail/create/edit workflows and dashboard program widgets
- Manufacturing foundation with work orders, project linkage, 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 - 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 invoice/supporting-document attachments directly on purchase orders
- Vendor-detail purchasing visibility with recent purchase-order activity - Vendor-detail purchasing visibility with recent purchase-order activity
@@ -57,7 +57,7 @@ This file tracks roadmap phases, slices, and major foundations that have already
- Live planning workbench timelines driven by project and manufacturing data - Live planning workbench timelines driven by project and manufacturing data
- Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions - Planning workbench with heatmap, overview, and agenda modes plus exception rail, focus drawer, station load grouping, readiness scoring, and inline dispatch actions
- Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling - Finite-capacity foundation with station working-day calendars, daily/parallel capacity settings, and calendar-aware operation scheduling
- Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, and station-lane drag scheduling - Planner-side workbench rebalance controls for operation scheduling, with quick shift moves, heatmap-day targeting, station-to-station reassignment, station-lane drag scheduling, and planned-vs-actual station load visibility
- Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations - Sales-order demand planning with multi-level BOM explosion, stock/open-supply netting, and build/buy recommendations
- Multi-stage Docker packaging and migration-aware entrypoint - Multi-stage Docker packaging and migration-aware entrypoint
- Docker image validated locally with successful app startup and login flow - Docker image validated locally with successful app startup and login flow

View File

@@ -61,10 +61,15 @@ import type {
WorkOrderCompletionInput, WorkOrderCompletionInput,
WorkOrderDetailDto, WorkOrderDetailDto,
WorkOrderInput, WorkOrderInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput, WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderMaterialIssueInput, WorkOrderMaterialIssueInput,
WorkOrderStatus, WorkOrderStatus,
WorkOrderSummaryDto, WorkOrderSummaryDto,
ManufacturingUserOptionDto,
} from "@mrp/shared"; } from "@mrp/shared";
import type { import type {
ProjectCustomerOptionDto, ProjectCustomerOptionDto,
@@ -606,6 +611,9 @@ export const api = {
getManufacturingProjectOptions(token: string) { getManufacturingProjectOptions(token: string) {
return request<ManufacturingProjectOptionDto[]>("/api/v1/manufacturing/projects/options", undefined, token); 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) { getManufacturingStations(token: string) {
return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token); return request<ManufacturingStationDto[]>("/api/v1/manufacturing/stations", undefined, token);
}, },
@@ -650,6 +658,34 @@ export const api = {
token token
); );
}, },
updateWorkOrderOperationExecution(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationExecutionInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/execution`,
{ method: "PATCH", body: JSON.stringify(payload) },
token
);
},
recordWorkOrderOperationLabor(token: string, workOrderId: string, operationId: string, payload: WorkOrderOperationLaborEntryInput) {
return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/operations/${operationId}/labor`,
{ method: "POST", body: JSON.stringify(payload) },
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) { issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
return request<WorkOrderDetailDto>( return request<WorkOrderDetailDto>(
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`, `/api/v1/manufacturing/work-orders/${workOrderId}/issues`,

View File

@@ -1,5 +1,16 @@
import { permissions } from "@mrp/shared"; import { permissions } from "@mrp/shared";
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderOperationScheduleInput, WorkOrderStatus } 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"; import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
@@ -16,6 +27,7 @@ export function WorkOrderDetailPage() {
const { workOrderId } = useParams(); const { workOrderId } = useParams();
const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null); const [workOrder, setWorkOrder] = useState<WorkOrderDetailDto | null>(null);
const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]); const [locationOptions, setLocationOptions] = useState<WarehouseLocationOptionDto[]>([]);
const [operatorOptions, setOperatorOptions] = useState<ManufacturingUserOptionDto[]>([]);
const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput); const [issueForm, setIssueForm] = useState<WorkOrderMaterialIssueInput>(emptyMaterialIssueInput);
const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput); const [completionForm, setCompletionForm] = useState<WorkOrderCompletionInput>(emptyCompletionInput);
const [status, setStatus] = useState("Loading work order..."); const [status, setStatus] = useState("Loading work order...");
@@ -23,7 +35,14 @@ export function WorkOrderDetailPage() {
const [isPostingIssue, setIsPostingIssue] = useState(false); const [isPostingIssue, setIsPostingIssue] = useState(false);
const [isPostingCompletion, setIsPostingCompletion] = useState(false); const [isPostingCompletion, setIsPostingCompletion] = useState(false);
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({}); 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 [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< const [pendingConfirmation, setPendingConfirmation] = useState<
| { | {
kind: "status" | "issue" | "completion"; kind: "status" | "issue" | "completion";
@@ -63,6 +82,21 @@ export function WorkOrderDetailPage() {
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }]) nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
) )
); );
setOperationLaborForm(
Object.fromEntries(
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."); setStatus("Work order loaded.");
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -71,6 +105,7 @@ export function WorkOrderDetailPage() {
}); });
api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([])); api.getWarehouseLocationOptions(token).then(setLocationOptions).catch(() => setLocationOptions([]));
api.getManufacturingUserOptions(token).then(setOperatorOptions).catch(() => setOperatorOptions([]));
}, [token, workOrderId]); }, [token, workOrderId]);
const filteredLocationOptions = useMemo( const filteredLocationOptions = useMemo(
@@ -173,6 +208,118 @@ export function WorkOrderDetailPage() {
} }
} }
async function submitOperationExecution(operationId: string, action: WorkOrderOperationExecutionInput["action"]) {
if (!token || !workOrder) {
return;
}
setExecutingOperationId(operationId);
setStatus("Updating operation execution...");
try {
const nextWorkOrder = await api.updateWorkOrderOperationExecution(token, workOrder.id, operationId, {
action,
notes: `${action} from work-order detail`,
});
setWorkOrder(nextWorkOrder);
setOperationScheduleForm(
Object.fromEntries(
nextWorkOrder.operations.map((operation) => [operation.id, { plannedStart: operation.plannedStart }])
)
);
setStatus("Operation execution updated.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to update operation execution.";
setStatus(message);
} finally {
setExecutingOperationId(null);
}
}
async function submitOperationLabor(operationId: string) {
if (!token || !workOrder) {
return;
}
const payload = operationLaborForm[operationId];
if (!payload?.minutes) {
return;
}
setPostingLaborOperationId(operationId);
setStatus("Posting labor entry...");
try {
const nextWorkOrder = await api.recordWorkOrderOperationLabor(token, workOrder.id, operationId, payload);
setWorkOrder(nextWorkOrder);
setOperationLaborForm((current) => ({
...current,
[operationId]: {
minutes: Math.max(Math.round((nextWorkOrder.operations.find((operation) => operation.id === operationId)?.plannedMinutes ?? 60) / 4), 15),
notes: "",
},
}));
setStatus("Labor entry posted.");
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to post operation labor.";
setStatus(message);
} finally {
setPostingLaborOperationId(null);
}
}
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) { function handleStatusChange(nextStatus: WorkOrderStatus) {
if (!workOrder) { if (!workOrder) {
return; return;
@@ -279,6 +426,7 @@ export function WorkOrderDetailPage() {
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article> <article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operations</p><div className="mt-2 text-base font-bold text-text">{workOrder.operations.length}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article> <article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Due Date</p><div className="mt-2 text-base font-bold text-text">{workOrder.dueDate ? new Date(workOrder.dueDate).toLocaleDateString() : "Not set"}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article> <article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Material Shortage</p><div className="mt-2 text-base font-bold text-text">{workOrder.materialRequirements.reduce((sum, requirement) => sum + requirement.shortageQuantity, 0)}</div></article>
<article className="rounded-[18px] border border-line/70 bg-surface/90 px-3 py-3 shadow-panel"><p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Actual Hours</p><div className="mt-2 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
</section> </section>
<div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]"> <div className="grid gap-3 xl:grid-cols-[minmax(0,1fr)_minmax(360px,0.9fr)]">
<article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5"> <article className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -307,11 +455,12 @@ export function WorkOrderDetailPage() {
<tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted"> <tr className="text-left text-xs font-semibold uppercase tracking-[0.18em] text-muted">
<th className="px-3 py-3">Seq</th> <th className="px-3 py-3">Seq</th>
<th className="px-3 py-3">Station</th> <th className="px-3 py-3">Station</th>
<th className="px-3 py-3">Execution</th>
<th className="px-3 py-3">Capacity</th> <th className="px-3 py-3">Capacity</th>
<th className="px-3 py-3">Start</th> <th className="px-3 py-3">Start</th>
<th className="px-3 py-3">End</th> <th className="px-3 py-3">End</th>
<th className="px-3 py-3">Minutes</th> <th className="px-3 py-3">Planned / Actual</th>
{canManage ? <th className="px-3 py-3">Reschedule</th> : null} {canManage ? <th className="px-3 py-3">Execution Controls</th> : null}
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-line/70"> <tbody className="divide-y divide-line/70">
@@ -322,16 +471,70 @@ export function WorkOrderDetailPage() {
<div className="font-semibold text-text">{operation.stationCode}</div> <div className="font-semibold text-text">{operation.stationCode}</div>
<div className="mt-1 text-xs text-muted">{operation.stationName}</div> <div className="mt-1 text-xs text-muted">{operation.stationName}</div>
</td> </td>
<td className="px-3 py-3 text-xs text-muted">
<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"> <td className="px-3 py-3 text-xs text-muted">
<div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div> <div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div>
<div>{operation.stationWorkingDays.join(",")}</div> <div>{operation.stationWorkingDays.join(",")}</div>
</td> </td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td> <td className="px-3 py-3 text-text">{new Date(operation.plannedStart).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td> <td className="px-3 py-3 text-text">{new Date(operation.plannedEnd).toLocaleString()}</td>
<td className="px-3 py-3 text-text">{operation.plannedMinutes}</td> <td className="px-3 py-3 text-xs text-text">
<div>{operation.plannedMinutes} planned</div>
<div className="mt-1">{operation.actualMinutes} actual</div>
</td>
{canManage ? ( {canManage ? (
<td className="px-3 py-3"> <td className="px-3 py-3">
<div className="flex min-w-[220px] items-center gap-2"> <div className="min-w-[320px] space-y-2">
<div className="flex flex-wrap gap-2">
{operation.status === "PENDING" ? (
<button type="button" onClick={() => void submitOperationExecution(operation.id, "START")} 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">Start</button>
) : null}
{(operation.status === "PENDING" || operation.status === "PAUSED") ? (
<button type="button" onClick={() => void submitOperationExecution(operation.id, "RESUME")} 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">Resume</button>
) : null}
{operation.status === "IN_PROGRESS" ? (
<button type="button" onClick={() => void submitOperationExecution(operation.id, "PAUSE")} 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">Pause</button>
) : null}
{operation.status !== "COMPLETE" ? (
<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 <input
type="datetime-local" type="datetime-local"
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)} value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
@@ -343,14 +546,82 @@ 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" 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 <div className="flex items-center gap-2">
type="button" <input
onClick={() => void submitOperationReschedule(operation.id)} type="number"
disabled={reschedulingOperationId === operation.id} min={1}
className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60" step={1}
> value={operationLaborForm[operation.id]?.minutes ?? 15}
{reschedulingOperationId === operation.id ? "Saving..." : "Apply"} onChange={(event) =>
</button> setOperationLaborForm((current) => ({
...current,
[operation.id]: {
...(current[operation.id] ?? { notes: "" }),
minutes: Number.parseInt(event.target.value, 10) || 1,
},
}))
}
className="w-24 rounded-2xl border border-line/70 bg-page px-2 py-2 text-xs text-text outline-none transition focus:border-brand"
/>
<input
type="text"
placeholder="Labor note"
value={operationLaborForm[operation.id]?.notes ?? ""}
onChange={(event) =>
setOperationLaborForm((current) => ({
...current,
[operation.id]: {
...(current[operation.id] ?? { minutes: 15 }),
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"
/>
</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"
onClick={() => void submitOperationReschedule(operation.id)}
disabled={reschedulingOperationId === 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"
>
{reschedulingOperationId === operation.id ? "Saving..." : "Apply plan"}
</button>
<button
type="button"
onClick={() => void submitOperationLabor(operation.id)}
disabled={postingLaborOperationId === operation.id || operation.status === "COMPLETE"}
className="rounded-2xl bg-brand px-2 py-2 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{postingLaborOperationId === operation.id ? "Posting..." : "Post labor"}
</button>
</div>
</div> </div>
</td> </td>
) : null} ) : null}

View File

@@ -565,7 +565,7 @@ export function WorkbenchPage() {
<div className="mt-2 flex items-center justify-between gap-3"> <div className="mt-2 flex items-center justify-between gap-3">
<span>Current load</span> <span>Current load</span>
<span className="font-semibold text-text"> <span className="font-semibold text-text">
{selectedRescheduleLoad.utilizationPercent}% util / {selectedRescheduleLoad.overloaded ? "Overloaded" : "Within load"} {selectedRescheduleLoad.utilizationPercent}% planned / {selectedRescheduleLoad.actualUtilizationPercent}% actual
</span> </span>
</div> </div>
) : null} ) : null}
@@ -793,6 +793,10 @@ function OverviewBoard({
<div>Blocked {station.blockedCount}</div> <div>Blocked {station.blockedCount}</div>
<div>Late {station.lateCount}</div> <div>Late {station.lateCount}</div>
</div> </div>
<div className="mt-2 grid grid-cols-2 gap-2 text-xs text-muted">
<div>Planned {station.totalPlannedMinutes} min</div>
<div>Actual {station.totalActualMinutes} min</div>
</div>
{draggingOperation ? ( {draggingOperation ? (
<div className="mt-3 rounded-[14px] border border-line/70 bg-page/60 p-2 text-xs text-muted"> <div className="mt-3 rounded-[14px] border border-line/70 bg-page/60 p-2 text-xs text-muted">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">

View File

@@ -0,0 +1,24 @@
-- AlterTable
ALTER TABLE "WorkOrderOperation" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'PENDING';
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualStart" DATETIME;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualEnd" DATETIME;
ALTER TABLE "WorkOrderOperation" ADD COLUMN "actualMinutes" INTEGER NOT NULL DEFAULT 0;
-- CreateTable
CREATE TABLE "WorkOrderOperationLaborEntry" (
"id" TEXT NOT NULL PRIMARY KEY,
"operationId" TEXT NOT NULL,
"minutes" INTEGER NOT NULL,
"notes" TEXT NOT NULL,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "WorkOrderOperationLaborEntry_operationId_fkey" FOREIGN KEY ("operationId") REFERENCES "WorkOrderOperation" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "WorkOrderOperationLaborEntry_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE INDEX "WorkOrderOperationLaborEntry_operationId_createdAt_idx" ON "WorkOrderOperationLaborEntry"("operationId", "createdAt");
-- CreateIndex
CREATE INDEX "WorkOrderOperationLaborEntry_createdById_createdAt_idx" ON "WorkOrderOperationLaborEntry"("createdById", "createdAt");

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

@@ -26,6 +26,8 @@ model User {
ownedProjects Project[] @relation("ProjectOwner") ownedProjects Project[] @relation("ProjectOwner")
workOrderMaterialIssues WorkOrderMaterialIssue[] workOrderMaterialIssues WorkOrderMaterialIssue[]
workOrderCompletions WorkOrderCompletion[] workOrderCompletions WorkOrderCompletion[]
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
assignedWorkOrderOperations WorkOrderOperation[]
approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy") approvedSalesQuotes SalesQuote[] @relation("SalesQuoteApprovedBy")
approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy") approvedSalesOrders SalesOrder[] @relation("SalesOrderApprovedBy")
salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy") salesQuoteRevisionsCreated SalesQuoteRevision[] @relation("SalesQuoteRevisionCreatedBy")
@@ -680,6 +682,7 @@ model WorkOrderOperation {
id String @id @default(cuid()) id String @id @default(cuid())
workOrderId String workOrderId String
stationId String stationId String
assignedOperatorId String?
sequence Int sequence Int
setupMinutes Int @default(0) setupMinutes Int @default(0)
runMinutesPerUnit Int @default(0) runMinutesPerUnit Int @default(0)
@@ -688,13 +691,36 @@ model WorkOrderOperation {
plannedStart DateTime plannedStart DateTime
plannedEnd DateTime plannedEnd DateTime
notes String notes String
status String @default("PENDING")
actualStart DateTime?
actualEnd DateTime?
actualMinutes Int @default(0)
activeTimerStartedAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade)
station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict) station ManufacturingStation @relation(fields: [stationId], references: [id], onDelete: Restrict)
assignedOperator User? @relation(fields: [assignedOperatorId], references: [id], onDelete: SetNull)
laborEntries WorkOrderOperationLaborEntry[]
@@index([workOrderId, sequence]) @@index([workOrderId, sequence])
@@index([stationId, plannedStart]) @@index([stationId, plannedStart])
@@index([assignedOperatorId, plannedStart])
}
model WorkOrderOperationLaborEntry {
id String @id @default(cuid())
operationId String
minutes Int
notes String
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
operation WorkOrderOperation @relation(fields: [operationId], references: [id], onDelete: Cascade)
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
@@index([operationId, createdAt])
@@index([createdById, createdAt])
} }
model WorkOrderMaterialIssue { model WorkOrderMaterialIssue {

View File

@@ -57,6 +57,8 @@ type PlanningWorkOrderRecord = {
plannedStart: Date; plannedStart: Date;
plannedEnd: Date; plannedEnd: Date;
plannedMinutes: number; plannedMinutes: number;
actualMinutes: number;
status: string;
station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string }; station: { id: string; code: string; name: string; dailyCapacityMinutes: number; parallelCapacity: number; workingDays: string };
}>; }>;
materialIssues: Array<{ componentItemId: string; quantity: number }>; materialIssues: Array<{ componentItemId: string; quantity: number }>;
@@ -82,6 +84,7 @@ type StationAccumulator = {
operationCount: number; operationCount: number;
workOrderIds: Set<string>; workOrderIds: Set<string>;
totalPlannedMinutes: number; totalPlannedMinutes: number;
totalActualMinutes: number;
blockedCount: number; blockedCount: number;
readyCount: number; readyCount: number;
lateCount: number; lateCount: number;
@@ -177,6 +180,7 @@ function getAvailabilityKey(itemId: string, warehouseId: string, locationId: str
function createStationLoad(record: StationAccumulator): PlanningStationLoadDto { function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
const capacityMinutes = Math.max(record.dayKeys.size, 1) * Math.max(record.dailyCapacityMinutes, 60) * Math.max(record.parallelCapacity, 1); const capacityMinutes = Math.max(record.dayKeys.size, 1) * Math.max(record.dailyCapacityMinutes, 60) * Math.max(record.parallelCapacity, 1);
const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0; const utilizationPercent = capacityMinutes > 0 ? Math.round((record.totalPlannedMinutes / capacityMinutes) * 100) : 0;
const actualUtilizationPercent = capacityMinutes > 0 ? Math.round((record.totalActualMinutes / capacityMinutes) * 100) : 0;
return { return {
stationId: record.stationId, stationId: record.stationId,
stationCode: record.stationCode, stationCode: record.stationCode,
@@ -184,8 +188,10 @@ function createStationLoad(record: StationAccumulator): PlanningStationLoadDto {
operationCount: record.operationCount, operationCount: record.operationCount,
workOrderCount: record.workOrderIds.size, workOrderCount: record.workOrderIds.size,
totalPlannedMinutes: record.totalPlannedMinutes, totalPlannedMinutes: record.totalPlannedMinutes,
totalActualMinutes: record.totalActualMinutes,
capacityMinutes, capacityMinutes,
utilizationPercent, utilizationPercent,
actualUtilizationPercent,
overloaded: utilizationPercent > 100, overloaded: utilizationPercent > 100,
blockedCount: record.blockedCount, blockedCount: record.blockedCount,
readyCount: record.readyCount, readyCount: record.readyCount,
@@ -308,6 +314,8 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
plannedStart: true, plannedStart: true,
plannedEnd: true, plannedEnd: true,
plannedMinutes: true, plannedMinutes: true,
actualMinutes: true,
status: true,
station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } }, station: { select: { id: true, code: true, name: true, dailyCapacityMinutes: true, parallelCapacity: true, workingDays: true } },
}, },
orderBy: [{ sequence: "asc" }], orderBy: [{ sequence: "asc" }],
@@ -494,6 +502,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
operationCount: 0, operationCount: 0,
workOrderIds: new Set<string>(), workOrderIds: new Set<string>(),
totalPlannedMinutes: 0, totalPlannedMinutes: 0,
totalActualMinutes: 0,
blockedCount: 0, blockedCount: 0,
readyCount: 0, readyCount: 0,
lateCount: 0, lateCount: 0,
@@ -505,6 +514,7 @@ export async function getPlanningTimeline(): Promise<PlanningTimelineDto> {
current.operationCount += 1; current.operationCount += 1;
current.workOrderIds.add(workOrder.id); current.workOrderIds.add(workOrder.id);
current.totalPlannedMinutes += operation.plannedMinutes; current.totalPlannedMinutes += operation.plannedMinutes;
current.totalActualMinutes += operation.actualMinutes;
if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") { if (insight?.readinessState === "BLOCKED" || insight?.readinessState === "SHORTAGE" || insight?.readinessState === "PENDING_SUPPLY") {
current.blockedCount += 1; current.blockedCount += 1;
} }

View File

@@ -13,9 +13,14 @@ import {
listManufacturingItemOptions, listManufacturingItemOptions,
listManufacturingProjectOptions, listManufacturingProjectOptions,
listManufacturingStations, listManufacturingStations,
listManufacturingUserOptions,
listWorkOrders, listWorkOrders,
recordWorkOrderCompletion, recordWorkOrderCompletion,
recordWorkOrderOperationLabor,
updateManufacturingStation, updateManufacturingStation,
updateWorkOrderOperationAssignment,
updateWorkOrderOperationExecution,
updateWorkOrderOperationTimer,
updateWorkOrder, updateWorkOrder,
updateWorkOrderOperationSchedule, updateWorkOrderOperationSchedule,
updateWorkOrderStatus, updateWorkOrderStatus,
@@ -74,6 +79,25 @@ const operationScheduleSchema = z.object({
stationId: z.string().trim().min(1).nullable().optional(), stationId: z.string().trim().min(1).nullable().optional(),
}); });
const operationExecutionSchema = z.object({
action: z.enum(["START", "PAUSE", "RESUME", "COMPLETE"]),
notes: z.string(),
});
const operationLaborSchema = z.object({
minutes: z.number().int().positive(),
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) { function getRouteParam(value: unknown) {
return typeof value === "string" ? value : null; return typeof value === "string" ? value : null;
} }
@@ -88,6 +112,10 @@ manufacturingRouter.get("/projects/options", requirePermissions([permissions.man
return ok(response, await listManufacturingProjectOptions()); 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) => { manufacturingRouter.get("/stations", requirePermissions([permissions.manufacturingRead]), async (_request, response) => {
return ok(response, await listManufacturingStations()); return ok(response, await listManufacturingStations());
}); });
@@ -215,6 +243,86 @@ manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/sch
return ok(response, result.workOrder); return ok(response, result.workOrder);
}); });
manufacturingRouter.patch("/work-orders/:workOrderId/operations/:operationId/execution", 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 = operationExecutionSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation execution payload is invalid.");
}
const result = await updateWorkOrderOperationExecution(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/operations/:operationId/labor", 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 = operationLaborSchema.safeParse(request.body);
if (!parsed.success) {
return fail(response, 400, "INVALID_INPUT", "Operation labor payload is invalid.");
}
const result = await recordWorkOrderOperationLabor(workOrderId, operationId, parsed.data, request.authUser?.id);
if (!result.ok) {
return fail(response, 400, "INVALID_INPUT", result.reason);
}
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) => { manufacturingRouter.post("/work-orders/:workOrderId/issues", requirePermissions([permissions.manufacturingWrite]), async (request, response) => {
const workOrderId = getRouteParam(request.params.workOrderId); const workOrderId = getRouteParam(request.params.workOrderId);
if (!workOrderId) { if (!workOrderId) {

View File

@@ -3,11 +3,16 @@ import type {
ManufacturingStationInput, ManufacturingStationInput,
ManufacturingItemOptionDto, ManufacturingItemOptionDto,
ManufacturingProjectOptionDto, ManufacturingProjectOptionDto,
ManufacturingUserOptionDto,
WorkOrderCompletionInput, WorkOrderCompletionInput,
WorkOrderDetailDto, WorkOrderDetailDto,
WorkOrderInput, WorkOrderInput,
WorkOrderOperationAssignmentInput,
WorkOrderOperationExecutionInput,
WorkOrderOperationDto, WorkOrderOperationDto,
WorkOrderOperationLaborEntryInput,
WorkOrderOperationScheduleInput, WorkOrderOperationScheduleInput,
WorkOrderOperationTimerInput,
WorkOrderMaterialIssueInput, WorkOrderMaterialIssueInput,
WorkOrderStatus, WorkOrderStatus,
WorkOrderSummaryDto, WorkOrderSummaryDto,
@@ -109,6 +114,11 @@ type WorkOrderRecord = {
plannedStart: Date; plannedStart: Date;
plannedEnd: Date; plannedEnd: Date;
notes: string; notes: string;
status: string;
actualStart: Date | null;
actualEnd: Date | null;
actualMinutes: number;
activeTimerStartedAt: Date | null;
station: { station: {
id: string; id: string;
code: string; code: string;
@@ -117,6 +127,21 @@ type WorkOrderRecord = {
parallelCapacity: number; parallelCapacity: number;
workingDays: string; workingDays: string;
}; };
assignedOperator: {
id: string;
firstName: string;
lastName: string;
} | null;
laborEntries: Array<{
id: string;
minutes: number;
notes: string;
createdAt: Date;
createdBy: {
firstName: string;
lastName: string;
} | null;
}>;
}>; }>;
materialIssues: Array<{ materialIssues: Array<{
id: string; id: string;
@@ -252,6 +277,24 @@ function buildInclude() {
workingDays: true, workingDays: true,
}, },
}, },
assignedOperator: {
select: {
id: true,
firstName: true,
lastName: true,
},
},
laborEntries: {
include: {
createdBy: {
select: {
firstName: true,
lastName: true,
},
},
},
orderBy: [{ createdAt: "desc" }],
},
}, },
orderBy: [{ sequence: "asc" }], orderBy: [{ sequence: "asc" }],
}, },
@@ -352,6 +395,7 @@ function mapDetail(
itemUnitOfMeasure: record.item.unitOfMeasure, itemUnitOfMeasure: record.item.unitOfMeasure,
projectCustomerName: record.project?.customer.name ?? null, projectCustomerName: record.project?.customer.name ?? null,
dueQuantity: record.quantity - record.completedQuantity, dueQuantity: record.quantity - record.completedQuantity,
totalActualMinutes: record.operations.reduce((sum, operation) => sum + operation.actualMinutes, 0),
operations: record.operations.map((operation): WorkOrderOperationDto => ({ operations: record.operations.map((operation): WorkOrderOperationDto => ({
id: operation.id, id: operation.id,
stationId: operation.station.id, stationId: operation.station.id,
@@ -368,6 +412,21 @@ function mapDetail(
plannedStart: operation.plannedStart.toISOString(), plannedStart: operation.plannedStart.toISOString(),
plannedEnd: operation.plannedEnd.toISOString(), plannedEnd: operation.plannedEnd.toISOString(),
notes: operation.notes, notes: operation.notes,
status: operation.status as WorkOrderOperationDto["status"],
actualStart: operation.actualStart ? operation.actualStart.toISOString() : null,
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,
notes: entry.notes,
createdAt: entry.createdAt.toISOString(),
createdByName: getUserName(entry.createdBy),
})),
})), })),
materialRequirements: record.item.bomLines.map((line) => { materialRequirements: record.item.bomLines.map((line) => {
const requiredQuantity = line.quantity * record.quantity; const requiredQuantity = line.quantity * record.quantity;
@@ -566,6 +625,7 @@ async function regenerateWorkOrderOperations(workOrderId: string) {
plannedStart: operation.plannedStart, plannedStart: operation.plannedStart,
plannedEnd: operation.plannedEnd, plannedEnd: operation.plannedEnd,
notes: operation.notes, notes: operation.notes,
status: "PENDING",
})), })),
}); });
} }
@@ -634,6 +694,29 @@ async function rescheduleWorkOrderOperationsToStation(
return getWorkOrderById(workOrderId); return getWorkOrderById(workOrderId);
} }
async function syncWorkOrderStatusFromOperationActivity(workOrderId: string) {
const workOrder = await prisma.workOrder.findUnique({
where: { id: workOrderId },
select: {
id: true,
status: true,
},
});
if (!workOrder) {
return;
}
if (workOrder.status === "RELEASED" || workOrder.status === "ON_HOLD") {
await prisma.workOrder.update({
where: { id: workOrderId },
data: {
status: "IN_PROGRESS",
},
});
}
}
async function syncWorkOrderReservations(workOrderId: string) { async function syncWorkOrderReservations(workOrderId: string) {
const workOrder = await getWorkOrderById(workOrderId); const workOrder = await getWorkOrderById(workOrderId);
if (!workOrder) { if (!workOrder) {
@@ -1004,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: { export async function listWorkOrders(filters: {
q?: string; q?: string;
status?: WorkOrderStatus; status?: WorkOrderStatus;
@@ -1312,6 +1416,354 @@ export async function updateWorkOrderOperationSchedule(
return { ok: true as const, workOrder: rescheduled }; return { ok: true as const, workOrder: rescheduled };
} }
export async function updateWorkOrderOperationExecution(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationExecutionInput,
actorId?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
status: true,
actualStart: true,
actualEnd: true,
actualMinutes: true,
plannedMinutes: 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: "Operation execution can only be updated on released or active work orders." };
}
const now = new Date();
const nextData: Record<string, unknown> = {};
if (payload.action === "START") {
if (existing.status === "COMPLETE") {
return { ok: false as const, reason: "Completed operations cannot be restarted." };
}
nextData.status = "IN_PROGRESS";
nextData.actualStart = existing.actualStart ?? now;
} else if (payload.action === "PAUSE") {
if (existing.status !== "IN_PROGRESS") {
return { ok: false as const, reason: "Only in-progress operations can be paused." };
}
nextData.status = "PAUSED";
nextData.actualStart = existing.actualStart ?? now;
} else if (payload.action === "RESUME") {
if (existing.status !== "PAUSED" && existing.status !== "PENDING") {
return { ok: false as const, reason: "Only paused or pending operations can be resumed." };
}
nextData.status = "IN_PROGRESS";
nextData.actualStart = existing.actualStart ?? now;
} else if (payload.action === "COMPLETE") {
if (existing.status === "COMPLETE") {
return { ok: false as const, reason: "Operation is already complete." };
}
nextData.status = "COMPLETE";
nextData.actualStart = existing.actualStart ?? now;
nextData.actualEnd = now;
}
await prisma.workOrderOperation.update({
where: { id: operationId },
data: nextData,
});
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.execution.updated",
summary: `${payload.action} operation ${existing.sequence} on ${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 recordWorkOrderOperationLabor(
workOrderId: string,
operationId: string,
payload: WorkOrderOperationLaborEntryInput,
createdById?: string | null
) {
const existing = await prisma.workOrderOperation.findUnique({
where: { id: operationId },
select: {
id: true,
workOrderId: true,
sequence: true,
status: true,
actualStart: true,
actualMinutes: 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: "Labor can only be posted to released or active work orders." };
}
if (existing.status === "COMPLETE") {
return { ok: false as const, reason: "Completed operations cannot receive additional labor entries." };
}
await prisma.$transaction(async (tx) => {
await tx.workOrderOperationLaborEntry.create({
data: {
operationId,
minutes: payload.minutes,
notes: payload.notes,
createdById: createdById ?? null,
},
});
await tx.workOrderOperation.update({
where: { id: operationId },
data: {
status: existing.status === "PENDING" ? "IN_PROGRESS" : existing.status,
actualStart: existing.actualStart ?? new Date(),
actualMinutes: {
increment: payload.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: createdById,
entityType: "work-order",
entityId: workOrderId,
action: "operation.labor.recorded",
summary: `Recorded labor on operation ${existing.sequence} for ${existing.workOrder.workOrderNumber}.`,
metadata: {
workOrderNumber: existing.workOrder.workOrderNumber,
operationId,
sequence: existing.sequence,
minutes: payload.minutes,
notes: payload.notes,
},
});
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) { export async function issueWorkOrderMaterial(workOrderId: string, payload: WorkOrderMaterialIssueInput, createdById?: string | null) {
const workOrder = await workOrderModel.findUnique({ const workOrder = await workOrderModel.findUnique({
where: { id: workOrderId }, where: { id: workOrderId },

View File

@@ -85,8 +85,10 @@ export interface PlanningStationLoadDto {
operationCount: number; operationCount: number;
workOrderCount: number; workOrderCount: number;
totalPlannedMinutes: number; totalPlannedMinutes: number;
totalActualMinutes: number;
capacityMinutes: number; capacityMinutes: number;
utilizationPercent: number; utilizationPercent: number;
actualUtilizationPercent: number;
overloaded: boolean; overloaded: boolean;
blockedCount: number; blockedCount: number;
readyCount: number; readyCount: number;

View File

@@ -1,6 +1,8 @@
export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD", "COMPLETE", "CANCELLED"] as const; export const workOrderStatuses = ["DRAFT", "RELEASED", "IN_PROGRESS", "ON_HOLD", "COMPLETE", "CANCELLED"] as const;
export const workOrderOperationStatuses = ["PENDING", "IN_PROGRESS", "PAUSED", "COMPLETE"] as const;
export type WorkOrderStatus = (typeof workOrderStatuses)[number]; export type WorkOrderStatus = (typeof workOrderStatuses)[number];
export type WorkOrderOperationStatus = (typeof workOrderOperationStatuses)[number];
export interface ManufacturingStationDto { export interface ManufacturingStationDto {
id: string; id: string;
@@ -35,6 +37,12 @@ export interface ManufacturingProjectOptionDto {
status: string; status: string;
} }
export interface ManufacturingUserOptionDto {
id: string;
name: string;
email: string;
}
export interface ManufacturingItemOptionDto { export interface ManufacturingItemOptionDto {
id: string; id: string;
sku: string; sku: string;
@@ -88,6 +96,23 @@ export interface WorkOrderOperationDto {
plannedStart: string; plannedStart: string;
plannedEnd: string; plannedEnd: string;
notes: string; notes: string;
status: WorkOrderOperationStatus;
actualStart: string | null;
actualEnd: string | null;
actualMinutes: number;
laborEntryCount: number;
assignedOperatorId: string | null;
assignedOperatorName: string | null;
activeTimerStartedAt: string | null;
laborEntries: WorkOrderOperationLaborEntryDto[];
}
export interface WorkOrderOperationLaborEntryDto {
id: string;
minutes: number;
notes: string;
createdAt: string;
createdByName: string;
} }
export interface WorkOrderMaterialRequirementDto { export interface WorkOrderMaterialRequirementDto {
@@ -137,6 +162,7 @@ export interface WorkOrderDetailDto extends WorkOrderSummaryDto {
itemUnitOfMeasure: string; itemUnitOfMeasure: string;
projectCustomerName: string | null; projectCustomerName: string | null;
dueQuantity: number; dueQuantity: number;
totalActualMinutes: number;
operations: WorkOrderOperationDto[]; operations: WorkOrderOperationDto[];
materialRequirements: WorkOrderMaterialRequirementDto[]; materialRequirements: WorkOrderMaterialRequirementDto[];
materialIssues: WorkOrderMaterialIssueDto[]; materialIssues: WorkOrderMaterialIssueDto[];
@@ -173,3 +199,22 @@ export interface WorkOrderOperationScheduleInput {
plannedStart: string; plannedStart: string;
stationId?: string | null; stationId?: string | null;
} }
export interface WorkOrderOperationExecutionInput {
action: "START" | "PAUSE" | "RESUME" | "COMPLETE";
notes: string;
}
export interface WorkOrderOperationLaborEntryInput {
minutes: number;
notes: string;
}
export interface WorkOrderOperationAssignmentInput {
assignedOperatorId: string | null;
}
export interface WorkOrderOperationTimerInput {
action: "START" | "STOP";
notes: string;
}