import type { DemandPlanningRollupDto, GanttTaskDto, ManufacturingStationDto, PlanningExceptionDto, PlanningStationLoadDto, PlanningTaskActionDto, PlanningTimelineDto, } from "@mrp/shared"; import { useEffect, useMemo, useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { useAuth } from "../../auth/AuthProvider"; 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; ownerLabel: string | null; start: string; end: string; progress: number; detailHref: string | null; parentId: string | null; projectId: string | null; workOrderId: string | null; salesOrderId: string | null; itemId: string | null; itemSku: string | null; stationId: string | null; stationCode: string | null; readinessState: string; readinessScore: number; shortageItemCount: number; totalShortageQuantity: number; openSupplyQuantity: number; releaseReady: boolean; overdue: boolean; blockedReason: string | null; utilizationPercent: number | null; loadMinutes: number; actions: PlanningTaskActionDto[]; }; type HeatmapCell = { dateKey: string; count: number; lateCount: number; blockedCount: number; tasks: FocusRecord[]; }; type WorkbenchGroup = "projects" | "stations" | "exceptions"; type WorkbenchFilter = "all" | "release-ready" | "blocked" | "shortage" | "overdue"; type DraggingOperation = { id: string; title: string; stationId: string | null; start: string; loadMinutes: number; }; const DAY_MS = 24 * 60 * 60 * 1000; function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) { if (!value) { return "Unscheduled"; } return new Intl.DateTimeFormat("en-US", options ?? { month: "short", day: "numeric", }).format(new Date(value)); } function startOfDay(value: Date) { return new Date(value.getFullYear(), value.getMonth(), value.getDate()); } function dateKey(value: Date) { return value.toISOString().slice(0, 10); } function parseFocusKind(task: GanttTaskDto): FocusRecord["kind"] { if (task.type === "project") { return "PROJECT"; } if (task.type === "milestone") { return "MILESTONE"; } if (task.id.startsWith("work-order-operation-")) { return "OPERATION"; } return "WORK_ORDER"; } function densityTone(cell: HeatmapCell) { if (cell.lateCount > 0) { return "border-rose-400/60 bg-rose-500/25"; } if (cell.blockedCount > 0) { return "border-amber-300/60 bg-amber-400/25"; } if (cell.count >= 4) { return "border-brand/80 bg-brand/35"; } if (cell.count >= 2) { return "border-brand/50 bg-brand/20"; } if (cell.count === 1) { return "border-line/80 bg-page/80"; } return "border-line/60 bg-surface/70"; } 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", ownerLabel: task.ownerLabel ?? null, start: task.start, end: task.end, progress: task.progress, detailHref: task.detailHref ?? null, parentId: task.parentId ?? null, projectId: task.projectId ?? null, workOrderId: task.workOrderId ?? null, salesOrderId: task.salesOrderId ?? null, itemId: task.itemId ?? null, itemSku: task.itemSku ?? null, stationId: task.stationId ?? null, stationCode: task.stationCode ?? null, readinessState: task.readinessState ?? "READY", readinessScore: task.readinessScore ?? 0, shortageItemCount: task.shortageItemCount ?? 0, totalShortageQuantity: task.totalShortageQuantity ?? 0, openSupplyQuantity: task.openSupplyQuantity ?? 0, releaseReady: task.releaseReady ?? false, overdue: task.overdue ?? false, blockedReason: task.blockedReason ?? null, utilizationPercent: task.utilizationPercent ?? null, loadMinutes: task.loadMinutes ?? 0, actions: task.actions ?? [], })); } function matchesWorkbenchFilter(record: FocusRecord, filter: WorkbenchFilter) { switch (filter) { case "release-ready": return record.releaseReady; case "blocked": return record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"; case "shortage": return record.totalShortageQuantity > 0 || record.readinessState === "SHORTAGE"; case "overdue": return record.overdue; default: return true; } } export function WorkbenchPage() { const navigate = useNavigate(); const { token } = useAuth(); const [timeline, setTimeline] = useState(null); const [planningRollup, setPlanningRollup] = useState(null); const [status, setStatus] = useState("Loading live planning timeline..."); const [workbenchMode, setWorkbenchMode] = useState("overview"); const [workbenchGroup, setWorkbenchGroup] = useState("projects"); const [workbenchFilter, setWorkbenchFilter] = useState("all"); const [selectedFocusId, setSelectedFocusId] = useState(null); const [selectedHeatmapDate, setSelectedHeatmapDate] = useState(null); const [rescheduleStart, setRescheduleStart] = useState(""); const [rescheduleStationId, setRescheduleStationId] = useState(""); const [isRescheduling, setIsRescheduling] = useState(false); const [stations, setStations] = useState([]); const [draggingOperation, setDraggingOperation] = useState(null); const [dropStationId, setDropStationId] = useState(null); useEffect(() => { if (!token) { return; } 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) => { const message = error instanceof ApiError ? error.message : "Unable to load planning timeline."; setStatus(message); }); }, [token]); const tasks = timeline?.tasks ?? []; const summary = timeline?.summary; const exceptions = timeline?.exceptions ?? []; const stationLoads = timeline?.stationLoads ?? []; const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]); const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]); 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(); for (let index = 0; index < 84; index += 1) { const nextDate = new Date(start.getTime() + index * DAY_MS); cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, tasks: [] }); } for (const record of filteredFocusRecords) { if (record.kind === "PROJECT") { continue; } const startDate = startOfDay(new Date(record.start)); const endDate = startOfDay(new Date(record.end)); for (let cursor = startDate.getTime(); cursor <= endDate.getTime(); cursor += DAY_MS) { const key = dateKey(new Date(cursor)); const current = cells.get(key); if (!current) { continue; } current.count += 1; if (record.status === "AT_RISK" || record.status === "ON_HOLD") { current.blockedCount += 1; } if (new Date(record.end).getTime() < Date.now() && record.status !== "COMPLETE" && record.status !== "CANCELLED") { current.lateCount += 1; } current.tasks.push(record); } } return [...cells.values()]; }, [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") .filter((record) => matchesWorkbenchFilter(record, workbenchFilter)) .sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime()) .slice(0, 18), [focusRecords, workbenchFilter] ); const modeOptions: Array<{ value: WorkbenchMode; label: string; detail: string }> = [ { value: "overview", label: "Overview", detail: "Dense planner board" }, { value: "heatmap", label: "Heatmap", detail: "Load by day" }, { value: "agenda", label: "Agenda", detail: "Upcoming due flow" }, ]; const groupOptions: Array<{ value: WorkbenchGroup; label: string }> = [ { value: "projects", label: "Projects" }, { value: "stations", label: "Stations" }, { value: "exceptions", label: "Exceptions" }, ]; const filterOptions: Array<{ value: WorkbenchFilter; label: string }> = [ { value: "all", label: "All" }, { value: "release-ready", label: "Release Ready" }, { value: "blocked", label: "Blocked" }, { value: "shortage", label: "Shortage" }, { 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"); await refreshWorkbench("Workbench refreshed after release."); return; } if (action.href) { navigate(action.href); } } async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) { if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) { return; } const plannedStart = nextStartIso ?? new Date(record.start).toISOString(); if (!plannedStart) { return; } setIsRescheduling(true); try { await api.updateWorkOrderOperationSchedule(token, record.workOrderId, record.entityId, { plannedStart, stationId: nextStationId ?? record.stationId ?? null, }); await refreshWorkbench("Workbench refreshed after operation rebalance."); setSelectedFocusId(record.id); setRescheduleStart(plannedStart.slice(0, 16)); if (nextStationId) { setRescheduleStationId(nextStationId); } } catch (error: unknown) { const message = error instanceof ApiError ? error.message : "Unable to rebalance operation from Workbench."; setStatus(message); } finally { setIsRescheduling(false); setDraggingOperation(null); setDropStationId(null); } } async function handleRescheduleOperation(nextStartIso?: string, nextStationId?: string | null) { if (!selectedFocus || selectedFocus.kind !== "OPERATION") { return; } const plannedStart = nextStartIso ?? (rescheduleStart ? new Date(rescheduleStart).toISOString() : ""); await rebalanceOperation(selectedFocus, plannedStart, (nextStationId ?? rescheduleStationId) || null); } 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)); } async function handleStationDrop(targetStationId: string) { if (!draggingOperation) { return; } const record = focusById.get(draggingOperation.id); if (!record || record.kind !== "OPERATION") { setDraggingOperation(null); setDropStationId(null); return; } const plannedStart = selectedHeatmapDate ? new Date(`${selectedHeatmapDate}T${new Date(record.start).toTimeString().slice(0, 5)}:00`).toISOString() : new Date(record.start).toISOString(); await rebalanceOperation(record, plannedStart, targetStationId); } return (

Planning

Planning Workbench

A reactive planning surface for projects, work orders, operations, shortages, and schedule risk. Use it as the daily planner cockpit, not just a chart.

Workbench Status
{status}
{modeOptions.map((option) => ( ))}
{groupOptions.map((option) => ( ))}
{filterOptions.map((option) => ( ))}
{workbenchMode === "overview" ? ( ) : null} {workbenchMode === "heatmap" ? : null} {workbenchMode === "agenda" ? : null}
); } function MetricCard({ label, value }: { label: string; value: string | number }) { return (

{label}

{value}
); } function OverviewBoard({ focusRecords, stationLoads, groupMode, onSelect, draggingOperation, dropStationId, selectedHeatmapDate, onDragOperation, onDropStation, onDropStationChange, }: { focusRecords: FocusRecord[]; stationLoads: PlanningStationLoadDto[]; groupMode: WorkbenchGroup; onSelect: (id: string) => void; draggingOperation: DraggingOperation | null; dropStationId: string | null; selectedHeatmapDate: string | null; onDragOperation: (operation: DraggingOperation | null) => void; onDropStation: (stationId: string) => void | Promise; onDropStationChange: (stationId: string | null) => void; }) { const projects = focusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6); const operations = focusRecords.filter((record) => record.kind === "OPERATION"); const workOrders = focusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10); const exceptionRows = focusRecords .filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY")) .slice(0, 10); const stationOperations = new Map(); for (const operation of operations) { if (!operation.stationId) { continue; } const bucket = stationOperations.get(operation.stationId) ?? []; bucket.push(operation); stationOperations.set(operation.stationId, bucket); } for (const bucket of stationOperations.values()) { bucket.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime()); } return (

Overview

Scan project rollups, active work, station load, and release blockers without leaving the planner.

{groupMode === "projects" ? (

Program Queue

{projects.map((record) => ( ))}

Operation Load

{operations.slice(0, 10).map((record) => ( ))}
) : null} {groupMode === "stations" ? (

Work Center Load

Drag operations between stations to rebalance capacity. If a heatmap day is selected, drops target that date on the new station.

{draggingOperation ? (
Dragging {draggingOperation.title}
) : null}
{stationLoads.slice(0, 10).map((station) => (
{ if (!draggingOperation) { return; } event.preventDefault(); onDropStationChange(station.stationId); }} onDragLeave={() => { if (dropStationId === station.stationId) { onDropStationChange(null); } }} onDrop={(event) => { event.preventDefault(); void onDropStation(station.stationId); }} className={`rounded-[16px] border bg-surface/80 p-3 transition ${ dropStationId === station.stationId ? "border-brand bg-brand/10" : station.overloaded ? "border-amber-300/60" : "border-line/70" }`} >
{station.stationCode} - {station.stationName}
{station.operationCount} ops across {station.workOrderCount} work orders
{station.utilizationPercent}% util
{station.overloaded ? "Overloaded" : "Within load"}
Ready {station.readyCount}
Blocked {station.blockedCount}
Late {station.lateCount}
{draggingOperation ? (
Projected util after drop {Math.round(((station.totalPlannedMinutes + draggingOperation.loadMinutes) / Math.max(station.capacityMinutes, 1)) * 100)}%
{station.stationId === draggingOperation.stationId ? "Same station move." : Math.round(((station.totalPlannedMinutes + draggingOperation.loadMinutes) / Math.max(station.capacityMinutes, 1)) * 100) > 100 ? "Drop will overload this station." : "Drop stays within current summarized load."}
{selectedHeatmapDate ?
Target day: {formatDate(selectedHeatmapDate)}
: null}
) : null}
{(stationOperations.get(station.stationId) ?? []).slice(0, 5).map((record) => (
onDragOperation({ id: record.id, title: record.title, stationId: record.stationId, start: record.start, loadMinutes: Math.max(record.loadMinutes, 1), }) } onDragEnd={() => { onDragOperation(null); onDropStationChange(null); }} className="cursor-grab rounded-[14px] border border-line/70 bg-page/60 p-2 active:cursor-grabbing" >
))} {(stationOperations.get(station.stationId)?.length ?? 0) > 5 ? (
+{(stationOperations.get(station.stationId)?.length ?? 0) - 5} more operations
) : null}
))}
) : null} {groupMode === "exceptions" ? (

Dispatch Exceptions

{exceptionRows.map((record) => ( ))}
) : null}

Active Work Orders

{workOrders.map((record) => ( ))}
); } function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: HeatmapCell[]; selectedDate: string | null; onSelectDate: (date: string) => void }) { const weeks = []; for (let index = 0; index < heatmap.length; index += 7) { weeks.push(heatmap.slice(index, index + 7)); } return (

Load Heatmap

Dense daily load scan for operations and work orders, with late and blocked pressure highlighted.

{["M", "T", "W", "T", "F", "S", "S"].map((label) =>
{label}
)}
{weeks.map((week, weekIndex) => (
{formatDate(week[0]?.dateKey ?? null, { month: "short" })}
{week.map((cell) => ( ))}
))}
); } function AgendaBoard({ records, onSelect, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; compact?: boolean }) { return (
{!compact ? (

Agenda

Upcoming projects, work orders, and milestones ordered by due date.

) : null}
{records.map((record) => ( ))}
); } function SelectedDayPanel({ cell, onSelect }: { cell: HeatmapCell; onSelect: (id: string) => void }) { return (
{formatDate(cell.dateKey, { weekday: "short", month: "short", day: "numeric" })}
{cell.count} scheduled {cell.lateCount} late
{cell.tasks.slice(0, 8).map((task) => ( ))}
); }