timers
This commit is contained in:
@@ -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 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 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
|
||||||
|
|||||||
@@ -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, 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
|
- 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 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:
|
Current interactions:
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
- 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
|
- 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
|
||||||
|
|||||||
@@ -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, 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
|
- 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
|
||||||
|
|||||||
@@ -61,12 +61,15 @@ import type {
|
|||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderInput,
|
WorkOrderInput,
|
||||||
|
WorkOrderOperationAssignmentInput,
|
||||||
WorkOrderOperationExecutionInput,
|
WorkOrderOperationExecutionInput,
|
||||||
WorkOrderOperationLaborEntryInput,
|
WorkOrderOperationLaborEntryInput,
|
||||||
WorkOrderOperationScheduleInput,
|
WorkOrderOperationScheduleInput,
|
||||||
|
WorkOrderOperationTimerInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
|
ManufacturingUserOptionDto,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
import type {
|
import type {
|
||||||
ProjectCustomerOptionDto,
|
ProjectCustomerOptionDto,
|
||||||
@@ -608,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);
|
||||||
},
|
},
|
||||||
@@ -666,6 +672,20 @@ export const api = {
|
|||||||
token
|
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`,
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { permissions } from "@mrp/shared";
|
import { permissions } from "@mrp/shared";
|
||||||
import type {
|
import type {
|
||||||
|
ManufacturingUserOptionDto,
|
||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
|
WorkOrderOperationAssignmentInput,
|
||||||
WorkOrderOperationExecutionInput,
|
WorkOrderOperationExecutionInput,
|
||||||
WorkOrderOperationLaborEntryInput,
|
WorkOrderOperationLaborEntryInput,
|
||||||
WorkOrderOperationScheduleInput,
|
WorkOrderOperationScheduleInput,
|
||||||
|
WorkOrderOperationTimerInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
} from "@mrp/shared";
|
} from "@mrp/shared";
|
||||||
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||||
@@ -24,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...");
|
||||||
@@ -32,9 +36,13 @@ export function WorkOrderDetailPage() {
|
|||||||
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 [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 [executingOperationId, setExecutingOperationId] = useState<string | null>(null);
|
||||||
const [postingLaborOperationId, setPostingLaborOperationId] = 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";
|
||||||
@@ -79,6 +87,16 @@ export function WorkOrderDetailPage() {
|
|||||||
nextWorkOrder.operations.map((operation) => [operation.id, { minutes: Math.max(Math.round(operation.plannedMinutes / 4), 15), notes: "" }])
|
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) => {
|
||||||
@@ -87,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(
|
||||||
@@ -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) {
|
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||||
if (!workOrder) {
|
if (!workOrder) {
|
||||||
return;
|
return;
|
||||||
@@ -402,6 +475,8 @@ export function WorkOrderDetailPage() {
|
|||||||
<div className="font-semibold text-text">{operation.status.replaceAll("_", " ")}</div>
|
<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 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>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>
|
<div>{operation.laborEntryCount} labor entr{operation.laborEntryCount === 1 ? "y" : "ies"}</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 text-xs text-muted">
|
<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>
|
<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}
|
) : null}
|
||||||
</div>
|
</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)}
|
||||||
@@ -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"
|
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>
|
||||||
|
<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">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -27,6 +27,7 @@ model User {
|
|||||||
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
workOrderMaterialIssues WorkOrderMaterialIssue[]
|
||||||
workOrderCompletions WorkOrderCompletion[]
|
workOrderCompletions WorkOrderCompletion[]
|
||||||
workOrderOperationLaborEntries WorkOrderOperationLaborEntry[]
|
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")
|
||||||
@@ -681,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)
|
||||||
@@ -693,14 +695,17 @@ model WorkOrderOperation {
|
|||||||
actualStart DateTime?
|
actualStart DateTime?
|
||||||
actualEnd DateTime?
|
actualEnd DateTime?
|
||||||
actualMinutes Int @default(0)
|
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[]
|
laborEntries WorkOrderOperationLaborEntry[]
|
||||||
|
|
||||||
@@index([workOrderId, sequence])
|
@@index([workOrderId, sequence])
|
||||||
@@index([stationId, plannedStart])
|
@@index([stationId, plannedStart])
|
||||||
|
@@index([assignedOperatorId, plannedStart])
|
||||||
}
|
}
|
||||||
|
|
||||||
model WorkOrderOperationLaborEntry {
|
model WorkOrderOperationLaborEntry {
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ import {
|
|||||||
listManufacturingItemOptions,
|
listManufacturingItemOptions,
|
||||||
listManufacturingProjectOptions,
|
listManufacturingProjectOptions,
|
||||||
listManufacturingStations,
|
listManufacturingStations,
|
||||||
|
listManufacturingUserOptions,
|
||||||
listWorkOrders,
|
listWorkOrders,
|
||||||
recordWorkOrderCompletion,
|
recordWorkOrderCompletion,
|
||||||
recordWorkOrderOperationLabor,
|
recordWorkOrderOperationLabor,
|
||||||
updateManufacturingStation,
|
updateManufacturingStation,
|
||||||
|
updateWorkOrderOperationAssignment,
|
||||||
updateWorkOrderOperationExecution,
|
updateWorkOrderOperationExecution,
|
||||||
|
updateWorkOrderOperationTimer,
|
||||||
updateWorkOrder,
|
updateWorkOrder,
|
||||||
updateWorkOrderOperationSchedule,
|
updateWorkOrderOperationSchedule,
|
||||||
updateWorkOrderStatus,
|
updateWorkOrderStatus,
|
||||||
@@ -86,6 +89,15 @@ const operationLaborSchema = z.object({
|
|||||||
notes: z.string(),
|
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;
|
||||||
}
|
}
|
||||||
@@ -100,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());
|
||||||
});
|
});
|
||||||
@@ -267,6 +283,46 @@ manufacturingRouter.post("/work-orders/:workOrderId/operations/:operationId/labo
|
|||||||
return ok(response, result.workOrder, 201);
|
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) {
|
||||||
|
|||||||
@@ -3,13 +3,16 @@ import type {
|
|||||||
ManufacturingStationInput,
|
ManufacturingStationInput,
|
||||||
ManufacturingItemOptionDto,
|
ManufacturingItemOptionDto,
|
||||||
ManufacturingProjectOptionDto,
|
ManufacturingProjectOptionDto,
|
||||||
|
ManufacturingUserOptionDto,
|
||||||
WorkOrderCompletionInput,
|
WorkOrderCompletionInput,
|
||||||
WorkOrderDetailDto,
|
WorkOrderDetailDto,
|
||||||
WorkOrderInput,
|
WorkOrderInput,
|
||||||
|
WorkOrderOperationAssignmentInput,
|
||||||
WorkOrderOperationExecutionInput,
|
WorkOrderOperationExecutionInput,
|
||||||
WorkOrderOperationDto,
|
WorkOrderOperationDto,
|
||||||
WorkOrderOperationLaborEntryInput,
|
WorkOrderOperationLaborEntryInput,
|
||||||
WorkOrderOperationScheduleInput,
|
WorkOrderOperationScheduleInput,
|
||||||
|
WorkOrderOperationTimerInput,
|
||||||
WorkOrderMaterialIssueInput,
|
WorkOrderMaterialIssueInput,
|
||||||
WorkOrderStatus,
|
WorkOrderStatus,
|
||||||
WorkOrderSummaryDto,
|
WorkOrderSummaryDto,
|
||||||
@@ -115,6 +118,7 @@ type WorkOrderRecord = {
|
|||||||
actualStart: Date | null;
|
actualStart: Date | null;
|
||||||
actualEnd: Date | null;
|
actualEnd: Date | null;
|
||||||
actualMinutes: number;
|
actualMinutes: number;
|
||||||
|
activeTimerStartedAt: Date | null;
|
||||||
station: {
|
station: {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -123,6 +127,11 @@ type WorkOrderRecord = {
|
|||||||
parallelCapacity: number;
|
parallelCapacity: number;
|
||||||
workingDays: string;
|
workingDays: string;
|
||||||
};
|
};
|
||||||
|
assignedOperator: {
|
||||||
|
id: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
} | null;
|
||||||
laborEntries: Array<{
|
laborEntries: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
minutes: number;
|
minutes: number;
|
||||||
@@ -268,6 +277,13 @@ function buildInclude() {
|
|||||||
workingDays: true,
|
workingDays: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
assignedOperator: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
laborEntries: {
|
laborEntries: {
|
||||||
include: {
|
include: {
|
||||||
createdBy: {
|
createdBy: {
|
||||||
@@ -401,6 +417,9 @@ function mapDetail(
|
|||||||
actualEnd: operation.actualEnd ? operation.actualEnd.toISOString() : null,
|
actualEnd: operation.actualEnd ? operation.actualEnd.toISOString() : null,
|
||||||
actualMinutes: operation.actualMinutes,
|
actualMinutes: operation.actualMinutes,
|
||||||
laborEntryCount: operation.laborEntries.length,
|
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) => ({
|
laborEntries: operation.laborEntries.map((entry) => ({
|
||||||
id: entry.id,
|
id: entry.id,
|
||||||
minutes: entry.minutes,
|
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: {
|
export async function listWorkOrders(filters: {
|
||||||
q?: string;
|
q?: string;
|
||||||
status?: WorkOrderStatus;
|
status?: WorkOrderStatus;
|
||||||
@@ -1553,6 +1593,177 @@ export async function recordWorkOrderOperationLabor(
|
|||||||
return { ok: true as const, workOrder };
|
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 },
|
||||||
|
|||||||
@@ -37,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;
|
||||||
@@ -95,6 +101,9 @@ export interface WorkOrderOperationDto {
|
|||||||
actualEnd: string | null;
|
actualEnd: string | null;
|
||||||
actualMinutes: number;
|
actualMinutes: number;
|
||||||
laborEntryCount: number;
|
laborEntryCount: number;
|
||||||
|
assignedOperatorId: string | null;
|
||||||
|
assignedOperatorName: string | null;
|
||||||
|
activeTimerStartedAt: string | null;
|
||||||
laborEntries: WorkOrderOperationLaborEntryDto[];
|
laborEntries: WorkOrderOperationLaborEntryDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,3 +209,12 @@ export interface WorkOrderOperationLaborEntryInput {
|
|||||||
minutes: number;
|
minutes: number;
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderOperationAssignmentInput {
|
||||||
|
assignedOperatorId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorkOrderOperationTimerInput {
|
||||||
|
action: "START" | "STOP";
|
||||||
|
notes: string;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user