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

View File

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

View File

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