From 4949b6033f34dbc42ec4d38acffc7846aed45f49 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 19 Mar 2026 07:38:08 -0500 Subject: [PATCH] more workbench usability --- CHANGELOG.md | 4 + .../src/modules/workbench/WorkbenchPage.tsx | 238 ++++++++++++++++-- server/src/modules/gantt/service.ts | 54 +++- shared/src/gantt/types.ts | 13 + 4 files changed, 293 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2155d12..a66bafa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,10 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh - Workbench dispatch workflow depth with saved planner views, a release queue for visible ready work, queued-record visibility in the sticky control bar, and batch release directly from the workbench - Workbench batch operation rebalance with multi-operation selection, sticky-bar batch reschedule controls, station reassignment across selected operations, and selected-operation visibility in row signals and focus context - Workbench conflict-intelligence pass with projected batch target load, overload warnings before batch station moves, and best-alternate-station suggestions inside the sticky rebalance controls +- Workbench date-aware slot guidance using station working-day calendars and queue settings to suggest the next workable batch landing dates directly from the sticky rebalance controls +- Planning timeline now includes station day-load rollups, and Workbench slot suggestions use that server-backed per-day capacity data instead of only summary-level utilization heuristics +- Workbench now surfaces day-level capacity directly in the planner, including hot-station day counts on heatmap cells, selected-day station load breakdowns, and per-station hot-day chips in station grouping mode +- Workbench exception prioritization now scores and ranks projects, work orders, agenda rows, and dispatch exceptions by lateness, blockage, shortage, readiness, and overload pressure, with inline priority chips for faster triage - Project-side milestone and work-order rollups surfaced on project list and detail pages - Inventory SKU master builder with family-level sequence codes, branch-aware taxonomy management, and generated SKU previews on the item form - Thumbnail image attachment staging on inventory item create/edit pages, with upload-on-save and replacement/removal support diff --git a/client/src/modules/workbench/WorkbenchPage.tsx b/client/src/modules/workbench/WorkbenchPage.tsx index 5e28934..1743f24 100644 --- a/client/src/modules/workbench/WorkbenchPage.tsx +++ b/client/src/modules/workbench/WorkbenchPage.tsx @@ -50,6 +50,7 @@ type HeatmapCell = { count: number; lateCount: number; blockedCount: number; + hotStationCount: number; tasks: FocusRecord[]; }; @@ -92,6 +93,38 @@ function dateKey(value: Date) { return value.toISOString().slice(0, 10); } +function toLocalDateTimeValue(value: Date) { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + const hours = String(value.getHours()).padStart(2, "0"); + const minutes = String(value.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hours}:${minutes}`; +} + +function nextWorkingSlot(base: Date, workingDays: number[], workingDaySkips = 0) { + const next = new Date(base); + const allowedDays = new Set(workingDays); + if (allowedDays.size === 0) { + return next; + } + + let remainingSkips = workingDaySkips; + let attempts = 0; + while (attempts < 21) { + const isWorkingDay = allowedDays.has(next.getDay()); + if (isWorkingDay && remainingSkips <= 0) { + return next; + } + if (isWorkingDay && remainingSkips > 0) { + remainingSkips -= 1; + } + next.setDate(next.getDate() + 1); + attempts += 1; + } + return next; +} + function parseFocusKind(task: GanttTaskDto): FocusRecord["kind"] { if (task.type === "project") { return "PROJECT"; @@ -109,6 +142,9 @@ function densityTone(cell: HeatmapCell) { if (cell.lateCount > 0) { return "border-rose-400/60 bg-rose-500/25"; } + if (cell.hotStationCount > 0) { + return "border-amber-400/70 bg-amber-400/25"; + } if (cell.blockedCount > 0) { return "border-amber-300/60 bg-amber-400/25"; } @@ -156,6 +192,37 @@ function readinessLabel(record: FocusRecord) { return record.readinessState.replaceAll("_", " "); } +function priorityScore(record: FocusRecord) { + let score = 0; + if (record.overdue) { + score += 45; + } + if (record.readinessState === "BLOCKED" || record.blockedReason) { + score += 30; + } + if (record.readinessState === "SHORTAGE" || record.readinessState === "PENDING_SUPPLY" || record.totalShortageQuantity > 0) { + score += 24; + } + if (record.kind === "OPERATION" && record.utilizationPercent && record.utilizationPercent > 100) { + score += 18; + } + if (record.kind === "WORK_ORDER" && record.releaseReady) { + score += 8; + } + score += Math.min(record.shortageItemCount * 3, 12); + score += Math.min(Math.round(record.totalShortageQuantity), 15); + score += Math.max(0, 100 - record.readinessScore) / 5; + return Math.round(score); +} + +function comparePriority(left: FocusRecord, right: FocusRecord) { + const delta = priorityScore(right) - priorityScore(left); + if (delta !== 0) { + return delta; + } + return new Date(left.end).getTime() - new Date(right.end).getTime(); +} + function buildFocusRecords(tasks: GanttTaskDto[]) { return tasks.map((task) => ({ id: task.id, @@ -283,6 +350,7 @@ export function WorkbenchPage() { const summary = timeline?.summary; const exceptions = timeline?.exceptions ?? []; const stationLoads = timeline?.stationLoads ?? []; + const stationDayLoads = timeline?.stationDayLoads ?? []; 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]); @@ -344,7 +412,7 @@ export function WorkbenchPage() { 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: [] }); + cells.set(dateKey(nextDate), { dateKey: dateKey(nextDate), count: 0, lateCount: 0, blockedCount: 0, hotStationCount: 0, tasks: [] }); } for (const record of filteredFocusRecords) { @@ -370,11 +438,40 @@ export function WorkbenchPage() { } } + for (const dayLoad of stationDayLoads) { + if (!dayLoad.overloaded) { + continue; + } + const current = cells.get(dayLoad.dateKey); + if (!current) { + continue; + } + current.hotStationCount += 1; + } + return [...cells.values()]; - }, [filteredFocusRecords, summary]); + }, [filteredFocusRecords, stationDayLoads, 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 stationDayLoadsByKey = useMemo(() => new Map(stationDayLoads.map((entry) => [`${entry.stationId}:${entry.dateKey}`, entry])), [stationDayLoads]); + const selectedDayStationLoads = useMemo(() => selectedHeatmapDate + ? stationDayLoads + .filter((entry) => entry.dateKey === selectedHeatmapDate) + .sort((left, right) => right.utilizationPercent - left.utilizationPercent) + : [], [selectedHeatmapDate, stationDayLoads]); + const stationHotDaysByStationId = useMemo(() => { + const grouped = new Map(); + for (const entry of stationDayLoads) { + const bucket = grouped.get(entry.stationId) ?? []; + bucket.push(entry); + grouped.set(entry.stationId, bucket); + } + for (const bucket of grouped.values()) { + bucket.sort((left, right) => right.utilizationPercent - left.utilizationPercent); + } + return grouped; + }, [stationDayLoads]); const selectedRescheduleStation = rescheduleStationId ? stations.find((station) => station.id === rescheduleStationId) ?? null : null; const selectedRescheduleLoad = selectedRescheduleStation ? stationLoadById.get(selectedRescheduleStation.id) ?? null : null; const selectedOperationLoadMinutes = useMemo(() => selectedOperations.reduce((sum, record) => sum + Math.max(record.loadMinutes, 1), 0), [selectedOperations]); @@ -397,6 +494,35 @@ export function WorkbenchPage() { overloaded: projectedUtilizationPercent > 100, }; }, [batchStationId, selectedOperationLoadMinutes, stationLoadById, stations]); + const batchSlotSuggestions = useMemo(() => { + if (!batchTargetLoad || selectedOperations.length === 0) { + return []; + } + const base = batchRescheduleStart ? new Date(batchRescheduleStart) : new Date(selectedOperations[0]?.start ?? new Date().toISOString()); + const queueOffset = Math.max(batchTargetLoad.station.queueDays, 0); + const suggestions: Array<{ label: string; value: string; utilizationPercent: number; overloaded: boolean }> = []; + let searchOffset = queueOffset; + let attempts = 0; + while (suggestions.length < 3 && attempts < 14) { + const slotDate = nextWorkingSlot(base, batchTargetLoad.station.workingDays, searchOffset); + const slotKey = dateKey(slotDate); + const currentDayLoad = stationDayLoadsByKey.get(`${batchTargetLoad.station.id}:${slotKey}`); + const capacityMinutes = currentDayLoad?.capacityMinutes ?? Math.max(batchTargetLoad.station.dailyCapacityMinutes, 60) * Math.max(batchTargetLoad.station.parallelCapacity, 1); + const plannedMinutes = (currentDayLoad?.plannedMinutes ?? 0) + selectedOperationLoadMinutes; + const utilizationPercent = Math.round((plannedMinutes / Math.max(capacityMinutes, 1)) * 100); + suggestions.push({ + label: formatDate(slotDate.toISOString(), { weekday: "short", month: "short", day: "numeric" }), + value: toLocalDateTimeValue(slotDate), + utilizationPercent, + overloaded: utilizationPercent > 100, + }); + searchOffset += utilizationPercent > 100 ? Math.max(1, Math.ceil(utilizationPercent / 100) - 1) : 1; + attempts += 1; + } + return suggestions.map((suggestion) => ({ + ...suggestion, + })); + }, [batchRescheduleStart, batchTargetLoad, selectedOperationLoadMinutes, selectedOperations, stationDayLoadsByKey]); const batchStationSuggestions = useMemo(() => { if (selectedOperations.length === 0) { return []; @@ -421,7 +547,7 @@ export function WorkbenchPage() { () => [...focusRecords] .filter((record) => record.kind !== "OPERATION") .filter((record) => matchesWorkbenchFilter(record, workbenchFilter)) - .sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime()) + .sort(comparePriority) .slice(0, 18), [focusRecords, workbenchFilter] ); @@ -433,11 +559,12 @@ export function WorkbenchPage() { return (selectedHeatmapCell?.tasks ?? filteredFocusRecords.filter((record) => record.kind !== "PROJECT")).slice(0, 18); } - const projects = filteredFocusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6); - const operations = filteredFocusRecords.filter((record) => record.kind === "OPERATION"); - const workOrders = filteredFocusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10); + const projects = filteredFocusRecords.filter((record) => record.kind === "PROJECT").sort(comparePriority).slice(0, 6); + const operations = filteredFocusRecords.filter((record) => record.kind === "OPERATION").sort(comparePriority); + const workOrders = filteredFocusRecords.filter((record) => record.kind === "WORK_ORDER").sort(comparePriority).slice(0, 10); const exceptionRows = filteredFocusRecords .filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY")) + .sort(comparePriority) .slice(0, 10); if (workbenchGroup === "projects") { @@ -457,7 +584,7 @@ export function WorkbenchPage() { stationBuckets.set(record.stationId, bucket); } for (const bucket of stationBuckets.values()) { - bucket.sort((left, right) => new Date(left.start).getTime() - new Date(right.start).getTime()); + bucket.sort(comparePriority); } return stationLoads .slice(0, 10) @@ -868,6 +995,7 @@ export function WorkbenchPage() {
Projected util: {batchTargetLoad.projectedUtilizationPercent}%
Projected minutes: {batchTargetLoad.projectedMinutes}
{batchTargetLoad.overloaded ? "This batch move will overload the target station." : "This batch move stays within summarized station capacity."}
+
Working days: {batchTargetLoad.station.workingDays.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]).join(", ")}
) : (
Keeping current stations. Pick a station to preview the batch landing load.
@@ -890,6 +1018,26 @@ export function WorkbenchPage() { + {batchSlotSuggestions.length > 0 ? ( +
+
NEXT SLOT OPTIONS
+
+ {batchSlotSuggestions.map((suggestion) => ( + + ))} +
+
+ Suggestions use the selected station calendar and current summarized load to move the batch onto the next workable slot instead of forcing a same-day overload. +
+
+ ) : null}
{selectedOperations.slice(0, 6).map((record) => ( @@ -973,6 +1121,7 @@ export function WorkbenchPage() {

{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}

{workbenchMode === "heatmap" - ? (selectedHeatmapCell ? :
Select a day in the heatmap to inspect its load.
) + ? (selectedHeatmapCell ? :
Select a day in the heatmap to inspect its load.
) : } @@ -1158,6 +1307,9 @@ function MetricCard({ label, value }: { label: string; value: string | number }) function RecordSignals({ record, queued = false, selected = false }: { record: FocusRecord; queued?: boolean; selected?: boolean }) { return ( <> + + P{priorityScore(record)} + {readinessLabel(record)} @@ -1173,6 +1325,7 @@ function RecordSignals({ record, queued = false, selected = false }: { record: F function OverviewBoard({ focusRecords, stationLoads, + stationHotDaysByStationId, groupMode, onSelect, selectedOperationIds, @@ -1186,6 +1339,7 @@ function OverviewBoard({ }: { focusRecords: FocusRecord[]; stationLoads: PlanningStationLoadDto[]; + stationHotDaysByStationId: Map>; groupMode: WorkbenchGroup; onSelect: (id: string) => void; selectedOperationIds: string[]; @@ -1226,7 +1380,10 @@ function OverviewBoard({ {groupMode === "projects" ? (
-

Program Queue

+
+

Program Queue

+ Priority ranked +
{projects.map((record) => (
-

Operation Load

+
+

Operation Load

+ Priority ranked +
{operations.slice(0, 10).map((record) => (
+
+ {(stationHotDaysByStationId.get(station.stationId) ?? []).slice(0, 3).map((entry) => ( + + {formatDate(entry.dateKey)} {entry.utilizationPercent}% + + ))} +
{draggingOperation ? (
@@ -1393,7 +1560,10 @@ function OverviewBoard({ ) : null} {groupMode === "exceptions" ? (
-

Dispatch Exceptions

+
+

Dispatch Exceptions

+ Priority ranked +
{exceptionRows.map((record) => (
) : null}
-

Active Work Orders

+
+

Active Work Orders

+ Priority ranked +
{workOrders.map((record) => (