manufacturing layer
This commit is contained in:
@@ -61,6 +61,8 @@ import type {
|
||||
WorkOrderCompletionInput,
|
||||
WorkOrderDetailDto,
|
||||
WorkOrderInput,
|
||||
WorkOrderOperationExecutionInput,
|
||||
WorkOrderOperationLaborEntryInput,
|
||||
WorkOrderOperationScheduleInput,
|
||||
WorkOrderMaterialIssueInput,
|
||||
WorkOrderStatus,
|
||||
@@ -650,6 +652,20 @@ export const api = {
|
||||
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
|
||||
);
|
||||
},
|
||||
issueWorkOrderMaterial(token: string, workOrderId: string, payload: WorkOrderMaterialIssueInput) {
|
||||
return request<WorkOrderDetailDto>(
|
||||
`/api/v1/manufacturing/work-orders/${workOrderId}/issues`,
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { permissions } from "@mrp/shared";
|
||||
import type { WorkOrderCompletionInput, WorkOrderDetailDto, WorkOrderMaterialIssueInput, WorkOrderOperationScheduleInput, WorkOrderStatus } from "@mrp/shared";
|
||||
import type {
|
||||
WorkOrderCompletionInput,
|
||||
WorkOrderDetailDto,
|
||||
WorkOrderMaterialIssueInput,
|
||||
WorkOrderOperationExecutionInput,
|
||||
WorkOrderOperationLaborEntryInput,
|
||||
WorkOrderOperationScheduleInput,
|
||||
WorkOrderStatus,
|
||||
} from "@mrp/shared";
|
||||
import type { WarehouseLocationOptionDto } from "@mrp/shared/dist/inventory/types.js";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
@@ -23,7 +31,10 @@ export function WorkOrderDetailPage() {
|
||||
const [isPostingIssue, setIsPostingIssue] = useState(false);
|
||||
const [isPostingCompletion, setIsPostingCompletion] = useState(false);
|
||||
const [operationScheduleForm, setOperationScheduleForm] = useState<Record<string, WorkOrderOperationScheduleInput>>({});
|
||||
const [operationLaborForm, setOperationLaborForm] = useState<Record<string, WorkOrderOperationLaborEntryInput>>({});
|
||||
const [reschedulingOperationId, setReschedulingOperationId] = useState<string | null>(null);
|
||||
const [executingOperationId, setExecutingOperationId] = useState<string | null>(null);
|
||||
const [postingLaborOperationId, setPostingLaborOperationId] = useState<string | null>(null);
|
||||
const [pendingConfirmation, setPendingConfirmation] = useState<
|
||||
| {
|
||||
kind: "status" | "issue" | "completion";
|
||||
@@ -63,6 +74,11 @@ export function WorkOrderDetailPage() {
|
||||
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: "" }])
|
||||
)
|
||||
);
|
||||
setStatus("Work order loaded.");
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -173,6 +189,64 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStatusChange(nextStatus: WorkOrderStatus) {
|
||||
if (!workOrder) {
|
||||
return;
|
||||
@@ -279,6 +353,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">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">Actual Hours</p><div className="mt-2 text-base font-bold text-text">{(workOrder.totalActualMinutes / 60).toFixed(1)}</div></article>
|
||||
</section>
|
||||
<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">
|
||||
@@ -307,11 +382,12 @@ export function WorkOrderDetailPage() {
|
||||
<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">Station</th>
|
||||
<th className="px-3 py-3">Execution</th>
|
||||
<th className="px-3 py-3">Capacity</th>
|
||||
<th className="px-3 py-3">Start</th>
|
||||
<th className="px-3 py-3">End</th>
|
||||
<th className="px-3 py-3">Minutes</th>
|
||||
{canManage ? <th className="px-3 py-3">Reschedule</th> : null}
|
||||
<th className="px-3 py-3">Planned / Actual</th>
|
||||
{canManage ? <th className="px-3 py-3">Execution Controls</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-line/70">
|
||||
@@ -322,16 +398,39 @@ export function WorkOrderDetailPage() {
|
||||
<div className="font-semibold text-text">{operation.stationCode}</div>
|
||||
<div className="mt-1 text-xs text-muted">{operation.stationName}</div>
|
||||
</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>{operation.laborEntryCount} labor entr{operation.laborEntryCount === 1 ? "y" : "ies"}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 text-xs text-muted">
|
||||
<div>{operation.stationDailyCapacityMinutes} min/day x {operation.stationParallelCapacity}</div>
|
||||
<div>{operation.stationWorkingDays.join(",")}</div>
|
||||
</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">{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 ? (
|
||||
<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>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={(operationScheduleForm[operation.id]?.plannedStart ?? operation.plannedStart).slice(0, 16)}
|
||||
@@ -343,14 +442,57 @@ 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"
|
||||
/>
|
||||
<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"}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
step={1}
|
||||
value={operationLaborForm[operation.id]?.minutes ?? 15}
|
||||
onChange={(event) =>
|
||||
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 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>
|
||||
</td>
|
||||
) : null}
|
||||
|
||||
@@ -565,7 +565,7 @@ export function WorkbenchPage() {
|
||||
<div className="mt-2 flex items-center justify-between gap-3">
|
||||
<span>Current load</span>
|
||||
<span className="font-semibold text-text">
|
||||
{selectedRescheduleLoad.utilizationPercent}% util / {selectedRescheduleLoad.overloaded ? "Overloaded" : "Within load"}
|
||||
{selectedRescheduleLoad.utilizationPercent}% planned / {selectedRescheduleLoad.actualUtilizationPercent}% actual
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -793,6 +793,10 @@ function OverviewBoard({
|
||||
<div>Blocked {station.blockedCount}</div>
|
||||
<div>Late {station.lateCount}</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 ? (
|
||||
<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">
|
||||
|
||||
Reference in New Issue
Block a user