timers
This commit is contained in:
@@ -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`,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user