workbench rebalance
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user