diff --git a/CHANGELOG.md b/CHANGELOG.md index bf0b4cc..5d6a2c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,8 @@ This file is the running release and change log for CODEXIUM. Keep it updated wh - Continued density standardization across admin diagnostics, user management, and CRM contacts, including tighter filter/forms, denser summary cards, and compact contact/account management surfaces - Workbench usability pass with sticky planner controls, stronger selected-row and selected-day state, clearer heatmap/day context, and more explicit dispatch-oriented action affordances - Workbench usability depth with keyboard row navigation, enter-to-open behavior, escape-to-clear, and inline readiness/shortage/hold signal pills across planner rows and day-detail cards +- 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 - 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/ROADMAP.md b/ROADMAP.md index 4502adb..ea312de 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -112,6 +112,7 @@ This file tracks work that still needs to be completed. Shipped phase history an ### Planning and scheduling +- Standardize dense UI primitives and shared page shells so future Workbench, dashboard, and operational screens reuse the same cards, filter bars, empty states, and section wrappers instead of reintroducing ad hoc layout patterns - Task dependencies, milestones, and progress updates - Manufacturing calendar views and deeper bottleneck visibility beyond the shipped station load and overload workbench summaries - Labor and machine scheduling support beyond the shipped station calendar/capacity foundation diff --git a/client/src/modules/workbench/WorkbenchPage.tsx b/client/src/modules/workbench/WorkbenchPage.tsx index c764d7d..3850522 100644 --- a/client/src/modules/workbench/WorkbenchPage.tsx +++ b/client/src/modules/workbench/WorkbenchPage.tsx @@ -62,8 +62,16 @@ type DraggingOperation = { start: string; loadMinutes: number; }; +type SavedWorkbenchView = { + id: string; + name: string; + mode: WorkbenchMode; + group: WorkbenchGroup; + filter: WorkbenchFilter; +}; const DAY_MS = 24 * 60 * 60 * 1000; +const WORKBENCH_VIEW_STORAGE_KEY = "codexium.workbench.savedViews"; function formatDate(value: string | null, options?: Intl.DateTimeFormatOptions) { if (!value) { @@ -201,6 +209,10 @@ function exceptionTargetId(exceptionId: string) { return exceptionId.startsWith("project-") ? exceptionId : exceptionId.replace("work-order-unscheduled-", "work-order-"); } +function canQueueRelease(record: FocusRecord) { + return record.kind === "WORK_ORDER" && record.releaseReady && record.actions.some((action) => action.kind === "RELEASE_WORK_ORDER" && action.workOrderId); +} + export function WorkbenchPage() { const navigate = useNavigate(); const { token } = useAuth(); @@ -218,6 +230,36 @@ export function WorkbenchPage() { const [stations, setStations] = useState([]); const [draggingOperation, setDraggingOperation] = useState(null); const [dropStationId, setDropStationId] = useState(null); + const [queuedWorkOrderIds, setQueuedWorkOrderIds] = useState([]); + const [savedViews, setSavedViews] = useState([]); + const [isBatchReleasing, setIsBatchReleasing] = useState(false); + const [selectedOperationIds, setSelectedOperationIds] = useState([]); + const [batchRescheduleStart, setBatchRescheduleStart] = useState(""); + const [batchStationId, setBatchStationId] = useState(""); + const [isBatchRescheduling, setIsBatchRescheduling] = useState(false); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + const stored = window.localStorage.getItem(WORKBENCH_VIEW_STORAGE_KEY); + if (!stored) { + return; + } + try { + const parsed = JSON.parse(stored) as SavedWorkbenchView[]; + setSavedViews(Array.isArray(parsed) ? parsed : []); + } catch { + setSavedViews([]); + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + window.localStorage.setItem(WORKBENCH_VIEW_STORAGE_KEY, JSON.stringify(savedViews)); + }, [savedViews]); useEffect(() => { if (!token) { @@ -245,6 +287,33 @@ export function WorkbenchPage() { 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; + const queuedRecords = useMemo(() => queuedWorkOrderIds.reduce((records, id) => { + const match = focusRecords.find((record) => (record.workOrderId ?? record.id) === id); + if (match) { + records.push(match); + } + return records; + }, []), [focusRecords, queuedWorkOrderIds]); + const releasableQueuedRecords = useMemo(() => queuedRecords.filter((record) => canQueueRelease(record)), [queuedRecords]); + const visibleReleasableRecords = useMemo(() => filteredFocusRecords.filter((record) => canQueueRelease(record)), [filteredFocusRecords]); + const selectedOperations = useMemo(() => selectedOperationIds.reduce((records, id) => { + const match = focusRecords.find((record) => record.id === id && record.kind === "OPERATION"); + if (match) { + records.push(match); + } + return records; + }, []), [focusRecords, selectedOperationIds]); + + useEffect(() => { + setQueuedWorkOrderIds((current) => current.filter((id) => { + const record = focusRecords.find((candidate) => (candidate.workOrderId ?? candidate.id) === id); + return record ? canQueueRelease(record) : false; + })); + }, [focusRecords]); + + useEffect(() => { + setSelectedOperationIds((current) => current.filter((id) => focusRecords.some((record) => record.id === id && record.kind === "OPERATION"))); + }, [focusRecords]); useEffect(() => { if (selectedFocus?.kind === "OPERATION") { @@ -256,6 +325,20 @@ export function WorkbenchPage() { } }, [selectedFocus?.id, selectedFocus?.kind, selectedFocus?.start, selectedFocus?.stationId]); + useEffect(() => { + if (selectedOperations.length === 0) { + setBatchRescheduleStart(""); + setBatchStationId(""); + return; + } + const first = selectedOperations[0]; + if (!first) { + return; + } + setBatchRescheduleStart((current) => current || first.start.slice(0, 16)); + setBatchStationId((current) => current || first.stationId || ""); + }, [selectedOperations]); + const heatmap = useMemo(() => { const start = summary ? startOfDay(new Date(summary.horizonStart)) : startOfDay(new Date()); const cells = new Map(); @@ -340,6 +423,7 @@ export function WorkbenchPage() { .slice(0, 10) .flatMap((station) => (stationBuckets.get(station.stationId) ?? []).slice(0, 5)); }, [agendaItems, filteredFocusRecords, selectedHeatmapCell?.dateKey, selectedHeatmapCell?.tasks, stationLoads, workbenchGroup, workbenchMode]); + const visibleOperations = useMemo(() => keyboardRecords.filter((record) => record.kind === "OPERATION"), [keyboardRecords]); useEffect(() => { function handleKeydown(event: KeyboardEvent) { @@ -429,6 +513,110 @@ export function WorkbenchPage() { } } + function addRecordToQueue(record: FocusRecord) { + if (!canQueueRelease(record)) { + return; + } + const queueId = record.workOrderId ?? record.id; + setQueuedWorkOrderIds((current) => (current.includes(queueId) ? current : [...current, queueId])); + setStatus(`Queued ${record.title} for batch release.`); + } + + function queueVisibleReady() { + const nextIds = visibleReleasableRecords.map((record) => record.workOrderId ?? record.id); + if (nextIds.length === 0) { + return; + } + setQueuedWorkOrderIds((current) => [...new Set([...current, ...nextIds])]); + setStatus(`Queued ${nextIds.length} visible release-ready work orders.`); + } + + async function releaseQueuedWorkOrders() { + if (!token || releasableQueuedRecords.length === 0) { + return; + } + setIsBatchReleasing(true); + try { + await Promise.all(releasableQueuedRecords.map((record) => api.updateWorkOrderStatus(token, record.workOrderId!, { status: "RELEASED" }))); + setQueuedWorkOrderIds([]); + await refreshWorkbench(`Released ${releasableQueuedRecords.length} queued work orders from Workbench.`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to release queued work orders."; + setStatus(message); + } finally { + setIsBatchReleasing(false); + } + } + + function saveCurrentView() { + if (typeof window === "undefined") { + return; + } + const suggested = `${workbenchMode.toUpperCase()} ${workbenchGroup.toUpperCase()}`; + const name = window.prompt("Save workbench view as:", suggested)?.trim(); + if (!name) { + return; + } + const nextView: SavedWorkbenchView = { + id: `${Date.now()}`, + name, + mode: workbenchMode, + group: workbenchGroup, + filter: workbenchFilter, + }; + setSavedViews((current) => [nextView, ...current].slice(0, 8)); + setStatus(`Saved workbench view: ${name}.`); + } + + function applySavedView(view: SavedWorkbenchView) { + setWorkbenchMode(view.mode); + setWorkbenchGroup(view.group); + setWorkbenchFilter(view.filter); + setStatus(`Loaded saved view: ${view.name}.`); + } + + function toggleOperationSelection(record: FocusRecord) { + if (record.kind !== "OPERATION") { + return; + } + setSelectedOperationIds((current) => current.includes(record.id) ? current.filter((id) => id !== record.id) : [...current, record.id]); + } + + function selectVisibleOperations() { + const nextIds = visibleOperations.map((record) => record.id); + if (nextIds.length === 0) { + return; + } + setSelectedOperationIds((current) => [...new Set([...current, ...nextIds])]); + setStatus(`Selected ${nextIds.length} visible operations for batch rebalance.`); + } + + async function applyBatchReschedule() { + if (!token || selectedOperations.length === 0 || !batchRescheduleStart) { + return; + } + setIsBatchRescheduling(true); + try { + const plannedStart = new Date(batchRescheduleStart).toISOString(); + await Promise.all(selectedOperations.map((record) => { + if (!record.workOrderId || !record.entityId) { + return Promise.resolve(); + } + return api.updateWorkOrderOperationSchedule(token, record.workOrderId, record.entityId, { + plannedStart, + stationId: batchStationId || record.stationId || null, + }); + })); + setSelectedOperationIds([]); + await refreshWorkbench(`Rebalanced ${selectedOperations.length} operations from Workbench.`); + } catch (error: unknown) { + const message = error instanceof ApiError ? error.message : "Unable to rebalance selected operations."; + setStatus(message); + } finally { + setIsBatchRescheduling(false); + } + } + async function rebalanceOperation(record: FocusRecord, nextStartIso?: string, nextStationId?: string | null) { if (!token || record.kind !== "OPERATION" || !record.workOrderId || !record.entityId) { return; @@ -544,6 +732,8 @@ export function WorkbenchPage() { Up/Down navigate Enter open Esc clear + {releasableQueuedRecords.length} queued + {selectedOperations.length} ops selected {selectedFocus ? ( Selected {selectedFocus.kind.toLowerCase()}: {selectedFocus.title} @@ -555,6 +745,90 @@ export function WorkbenchPage() { ) : null} +
+ + + + {queuedWorkOrderIds.length > 0 ? ( + + ) : null} + + {selectedOperationIds.length > 0 ? ( + + ) : null} +
+ {savedViews.length > 0 ? ( +
+ {savedViews.map((view) => ( +
+ + +
+ ))} +
+ ) : null} + {queuedRecords.length > 0 ? ( +
+ {queuedRecords.slice(0, 6).map((record) => ( + + {record.title} + + ))} + {queuedRecords.length > 6 ? +{queuedRecords.length - 6} more : null} +
+ ) : null} + {selectedOperations.length > 0 ? ( +
+
BATCH REBALANCE
+
+ setBatchRescheduleStart(event.target.value)} + className="rounded-2xl border border-line/70 bg-surface px-2 py-2 text-sm text-text outline-none transition focus:border-brand" + /> + + +
+
+ {selectedOperations.slice(0, 6).map((record) => ( + + {record.title} + + ))} + {selectedOperations.length > 6 ? +{selectedOperations.length - 6} more : null} +
+
+ ) : null}
@@ -630,6 +904,7 @@ export function WorkbenchPage() { stationLoads={stationLoads} groupMode={workbenchGroup} onSelect={setSelectedFocusId} + selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} draggingOperation={draggingOperation} dropStationId={dropStationId} @@ -640,7 +915,7 @@ export function WorkbenchPage() { /> ) : null} {workbenchMode === "heatmap" ? : null} - {workbenchMode === "agenda" ? : null} + {workbenchMode === "agenda" ? : null}
@@ -794,12 +1084,14 @@ function MetricCard({ label, value }: { label: string; value: string | number }) ); } -function RecordSignals({ record }: { record: FocusRecord }) { +function RecordSignals({ record, queued = false, selected = false }: { record: FocusRecord; queued?: boolean; selected?: boolean }) { return ( <> {readinessLabel(record)} + {selected ? SELECTED : null} + {queued ? QUEUED : null} {record.releaseReady ? RELEASE : null} {record.totalShortageQuantity > 0 ? SHORT {record.totalShortageQuantity} : null} {record.blockedReason ? HOLD : null} @@ -812,6 +1104,7 @@ function OverviewBoard({ stationLoads, groupMode, onSelect, + selectedOperationIds, selectedId, draggingOperation, dropStationId, @@ -824,6 +1117,7 @@ function OverviewBoard({ stationLoads: PlanningStationLoadDto[]; groupMode: WorkbenchGroup; onSelect: (id: string) => void; + selectedOperationIds: string[]; selectedId: string | null; draggingOperation: DraggingOperation | null; dropStationId: string | null; @@ -870,7 +1164,7 @@ function OverviewBoard({
{record.title}
{record.ownerLabel ?? "No owner context"}
- +
@@ -891,7 +1185,7 @@ function OverviewBoard({
{record.title}
{record.ownerLabel ?? "No parent work order"}
- +
@@ -1007,7 +1301,7 @@ function OverviewBoard({
{record.ownerLabel ?? record.title}
{formatDate(record.start, { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" })}
- +
@@ -1111,7 +1405,7 @@ function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: Heatma ); } -function AgendaBoard({ records, onSelect, selectedId, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; selectedId: string | null; compact?: boolean }) { +function AgendaBoard({ records, onSelect, selectedOperationIds, selectedId, compact = false }: { records: FocusRecord[]; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null; compact?: boolean }) { return (
{!compact ? ( @@ -1126,7 +1420,7 @@ function AgendaBoard({ records, onSelect, selectedId, compact = false }: { recor
{record.title}
{record.kind} - {record.ownerLabel ?? "No context"}
- +
@@ -1140,7 +1434,7 @@ function AgendaBoard({ records, onSelect, selectedId, compact = false }: { recor ); } -function SelectedDayPanel({ cell, onSelect, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedId: string | null }) { +function SelectedDayPanel({ cell, onSelect, selectedOperationIds, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null }) { return (
@@ -1157,7 +1451,7 @@ function SelectedDayPanel({ cell, onSelect, selectedId }: { cell: HeatmapCell; o
{task.title}
{task.status.replaceAll("_", " ")} - {task.ownerLabel ?? "No context"}
- +
))}