workbench rebalance

This commit is contained in:
2026-03-18 00:10:15 -05:00
parent 14708d7013
commit abc795b4a7
14 changed files with 640 additions and 48 deletions

View File

@@ -1,6 +1,7 @@
import type {
DemandPlanningRollupDto,
GanttTaskDto,
ManufacturingStationDto,
PlanningExceptionDto,
PlanningStationLoadDto,
PlanningTaskActionDto,
@@ -14,6 +15,7 @@ import { ApiError, api } from "../../lib/api";
type WorkbenchMode = "overview" | "heatmap" | "agenda";
type FocusRecord = {
id: string;
entityId: string | null;
title: string;
kind: "PROJECT" | "WORK_ORDER" | "OPERATION" | "MILESTONE";
status: string;
@@ -109,6 +111,7 @@ function densityTone(cell: HeatmapCell) {
function buildFocusRecords(tasks: GanttTaskDto[]) {
return tasks.map((task) => ({
id: task.id,
entityId: task.entityId ?? null,
title: task.text,
kind: parseFocusKind(task),
status: task.status ?? "PLANNED",
@@ -164,16 +167,21 @@ export function WorkbenchPage() {
const [workbenchFilter, setWorkbenchFilter] = useState<WorkbenchFilter>("all");
const [selectedFocusId, setSelectedFocusId] = useState<string | null>(null);
const [selectedHeatmapDate, setSelectedHeatmapDate] = useState<string | null>(null);
const [rescheduleStart, setRescheduleStart] = useState("");
const [rescheduleStationId, setRescheduleStationId] = useState("");
const [isRescheduling, setIsRescheduling] = useState(false);
const [stations, setStations] = useState<ManufacturingStationDto[]>([]);
useEffect(() => {
if (!token) {
return;
}
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token)])
.then(([data, rollup]) => {
Promise.all([api.getPlanningTimeline(token), api.getDemandPlanningRollup(token), api.getManufacturingStations(token)])
.then(([data, rollup, stationOptions]) => {
setTimeline(data);
setPlanningRollup(rollup);
setStations(stationOptions);
setStatus("Planning workbench loaded.");
})
.catch((error: unknown) => {
@@ -191,6 +199,16 @@ export function WorkbenchPage() {
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
const selectedFocus = selectedFocusId ? focusById.get(selectedFocusId) ?? null : filteredFocusRecords[0] ?? focusRecords[0] ?? null;
useEffect(() => {
if (selectedFocus?.kind === "OPERATION") {
setRescheduleStart(selectedFocus.start.slice(0, 16));
setRescheduleStationId(selectedFocus.stationId ?? "");
} else {
setRescheduleStart("");
setRescheduleStationId("");
}
}, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]);
const heatmap = useMemo(() => {
const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date());
const cells = new Map<string, HeatmapCell>();
@@ -226,6 +244,9 @@ export function WorkbenchPage() {
}, [filteredFocusRecords, summary]);
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
const stationLoadById = useMemo(() => new Map(stationLoads.map((station) => [station.stationId, station])), [stationLoads]);
const selectedRescheduleStation = rescheduleStationId ? stations.find((station) => station.id === rescheduleStationId) ?? null : null;
const selectedRescheduleLoad = selectedRescheduleStation ? stationLoadById.get(selectedRescheduleStation.id) ?? null : null;
const agendaItems = useMemo(
() => [...focusRecords]
.filter((record) => record.kind !== "OPERATION")
@@ -253,15 +274,23 @@ export function WorkbenchPage() {
{ value: "overdue", label: "Overdue" },
];
async function refreshWorkbench(message: string) {
if (!token) {
return;
}
const [refreshed, stationOptions] = await Promise.all([api.getPlanningTimeline(token), api.getManufacturingStations(token)]);
setTimeline(refreshed);
setStations(stationOptions);
setStatus(message);
}
async function handleTaskAction(action: PlanningTaskActionDto) {
if (!token) {
return;
}
if (action.kind === "RELEASE_WORK_ORDER" && action.workOrderId) {
await api.updateWorkOrderStatus(token, action.workOrderId, "RELEASED");
const refreshed = await api.getPlanningTimeline(token);
setTimeline(refreshed);
setStatus("Workbench refreshed after release.");
await refreshWorkbench("Workbench refreshed after release.");
return;
}
if (action.href) {
@@ -269,6 +298,50 @@ export function WorkbenchPage() {
}
}
async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) {
if (!token || !selectedFocus || selectedFocus.kind !== "OPERATION" || !selectedFocus.workOrderId || !selectedFocus.entityId) {
return;
}
const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : "");
if (!plannedStart) {
return;
}
setIsRescheduling(true);
try {
await api.updateWorkOrderOperationSchedule(token, selectedFocus.workOrderId, selectedFocus.entityId, {
plannedStart,
stationId: (nextStationId ?? rescheduleStationId) || null,
});
await refreshWorkbench("Workbench refreshed after operation rebalance.");
setRescheduleStart(plannedStart.slice(0, 16));
} catch (error: unknown) {
const message = error instanceof ApiError ? error.message : "Unable to rebalance operation from Workbench.";
setStatus(message);
} finally {
setIsRescheduling(false);
}
}
function shiftRescheduleDraft(hours: number) {
if (!rescheduleStart) {
return;
}
const next = new Date(rescheduleStart);
next.setHours(next.getHours() + hours);
setRescheduleStart(next.toISOString().slice(0, 16));
}
function moveDraftToSelectedHeatmapDay() {
if (!selectedHeatmapDate) {
return;
}
const current = rescheduleStart ? new Date(rescheduleStart) : new Date(`${selectedHeatmapDate}T08:00:00`);
const target = new Date(`${selectedHeatmapDate}T${String(current.getHours()).padStart(2, "0")}:${String(current.getMinutes()).padStart(2, "0")}:00`);
setRescheduleStart(target.toISOString().slice(0, 16));
}
return (
<section className="space-y-4">
<div className="rounded-[20px] border border-line/70 bg-surface/90 p-4 shadow-panel 2xl:p-5">
@@ -391,6 +464,95 @@ export function WorkbenchPage() {
<div className="mt-2 flex items-center justify-between gap-3"><span className="text-muted">Open supply</span><span className="font-semibold text-text">{selectedFocus.openSupplyQuantity}</span></div>
{selectedFocus.blockedReason ? <div className="mt-3 text-xs text-muted">{selectedFocus.blockedReason}</div> : null}
</div>
{selectedFocus.kind === "OPERATION" ? (
<div className="rounded-[18px] border border-line/70 bg-page/60 p-3 text-sm">
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted">Workbench Rebalance</div>
<div className="mt-3">
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Target Station</label>
<select
value={rescheduleStationId}
onChange={(event) => setRescheduleStationId(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
>
{stations
.filter((station) => station.isActive || station.id === selectedFocus.stationId)
.map((station) => (
<option key={station.id} value={station.id}>
{station.code} - {station.name}{station.isActive ? "" : " (Inactive)"}
</option>
))}
</select>
</div>
<div className="mt-3">
<label className="mb-2 block text-xs font-semibold uppercase tracking-[0.16em] text-muted">Planned Start</label>
<input
type="datetime-local"
value={rescheduleStart}
onChange={(event) => setRescheduleStart(event.target.value)}
className="w-full rounded-2xl border border-line/70 bg-surface px-2 py-2 text-text outline-none transition focus:border-brand"
/>
</div>
{selectedRescheduleStation ? (
<div className="mt-3 rounded-[16px] border border-line/70 bg-surface/80 p-3 text-xs text-muted">
<div className="flex items-center justify-between gap-3">
<span>Capacity</span>
<span className="font-semibold text-text">
{selectedRescheduleStation.dailyCapacityMinutes} min/day x {selectedRescheduleStation.parallelCapacity}
</span>
</div>
<div className="mt-2 flex items-center justify-between gap-3">
<span>Working days</span>
<span className="font-semibold text-text">
{selectedRescheduleStation.workingDays.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]).join(", ")}
</span>
</div>
{selectedRescheduleLoad ? (
<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"}
</span>
</div>
) : null}
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2">
<button type="button" onClick={() => shiftRescheduleDraft(1)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1h</button>
<button type="button" onClick={() => shiftRescheduleDraft(8)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 shift</button>
<button type="button" onClick={() => shiftRescheduleDraft(24)} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">+1 day</button>
{selectedHeatmapDate ? (
<button type="button" onClick={moveDraftToSelectedHeatmapDay} className="rounded-2xl border border-line/70 px-2 py-2 text-xs font-semibold text-text">
Move to {formatDate(selectedHeatmapDate)}
</button>
) : null}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
onClick={() => void handleRescheduleOperation()}
disabled={isRescheduling || !rescheduleStart}
className="rounded-2xl bg-brand px-2 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60"
>
{isRescheduling ? "Rebalancing..." : "Apply rebalance"}
</button>
<button
type="button"
onClick={() => {
const originalStationId = selectedFocus.stationId ?? "";
setRescheduleStationId(originalStationId);
void handleRescheduleOperation(new Date(selectedFocus.start).toISOString(), originalStationId);
}}
disabled={isRescheduling}
className="rounded-2xl border border-line/70 px-2 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
>
Reset
</button>
</div>
<div className="mt-3 text-xs text-muted">
Rebalance starts from this operation, can move it onto another active station, and rebuilds downstream operations using station calendars and capacity.
</div>
</div>
) : null}
<div className="flex flex-wrap gap-2">
{selectedFocus.actions.map((action, index) => (
<button