more workbench usability

This commit is contained in:
2026-03-19 07:38:08 -05:00
parent cf54e4ba58
commit 4949b6033f
4 changed files with 293 additions and 16 deletions

View File

@@ -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<string, HeatmapCell>();
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<string, typeof stationDayLoads>();
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() {
<div>Projected util: <span className="font-semibold text-text">{batchTargetLoad.projectedUtilizationPercent}%</span></div>
<div>Projected minutes: <span className="font-semibold text-text">{batchTargetLoad.projectedMinutes}</span></div>
<div>{batchTargetLoad.overloaded ? "This batch move will overload the target station." : "This batch move stays within summarized station capacity."}</div>
<div>Working days: <span className="font-semibold text-text">{batchTargetLoad.station.workingDays.map((day) => ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][day]).join(", ")}</span></div>
</div>
) : (
<div className="mt-2">Keeping current stations. Pick a station to preview the batch landing load.</div>
@@ -890,6 +1018,26 @@ export function WorkbenchPage() {
</div>
</div>
</div>
{batchSlotSuggestions.length > 0 ? (
<div className="mt-3 rounded-[16px] border border-line/70 bg-surface px-2 py-2 text-xs text-muted">
<div className="section-kicker">NEXT SLOT OPTIONS</div>
<div className="mt-2 flex flex-wrap gap-2">
{batchSlotSuggestions.map((suggestion) => (
<button
key={suggestion.value}
type="button"
onClick={() => setBatchRescheduleStart(suggestion.value)}
className={`rounded-2xl border px-2 py-2 text-xs font-semibold ${batchRescheduleStart === suggestion.value ? "border-brand bg-brand/10 text-text" : "border-line/70 bg-page/60 text-text"}`}
>
{suggestion.label} · {suggestion.utilizationPercent}%
</button>
))}
</div>
<div className="mt-2">
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.
</div>
</div>
) : null}
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted">
{selectedOperations.slice(0, 6).map((record) => (
<span key={record.id} className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">
@@ -973,6 +1121,7 @@ export function WorkbenchPage() {
<OverviewBoard
focusRecords={filteredFocusRecords}
stationLoads={stationLoads}
stationHotDaysByStationId={stationHotDaysByStationId}
groupMode={workbenchGroup}
onSelect={setSelectedFocusId}
selectedOperationIds={selectedOperationIds}
@@ -1137,7 +1286,7 @@ export function WorkbenchPage() {
<section className="surface-panel">
<p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p>
{workbenchMode === "heatmap"
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} /> : <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">Select a day in the heatmap to inspect its load.</div>)
? (selectedHeatmapCell ? <SelectedDayPanel cell={selectedHeatmapCell} stationLoads={selectedDayStationLoads} stationLookup={stationLoadById} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} /> : <div className="mt-3 rounded-[18px] border border-dashed border-line/70 bg-page/60 px-3 py-5 text-center text-sm text-muted">Select a day in the heatmap to inspect its load.</div>)
: <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} compact />}
</section>
</aside>
@@ -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 (
<>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-text">
P{priorityScore(record)}
</span>
<span className={`rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${readinessTone(record)}`}>
{readinessLabel(record)}
</span>
@@ -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<string, Array<{ dateKey: string; utilizationPercent: number; overloaded: boolean }>>;
groupMode: WorkbenchGroup;
onSelect: (id: string) => void;
selectedOperationIds: string[];
@@ -1226,7 +1380,10 @@ function OverviewBoard({
{groupMode === "projects" ? (
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Program Queue</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 space-y-3">
{projects.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`block w-full rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1248,7 +1405,10 @@ function OverviewBoard({
</div>
</section>
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Operation Load</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 space-y-2">
{operations.slice(0, 10).map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1328,6 +1488,13 @@ function OverviewBoard({
<div>Planned {station.totalPlannedMinutes} min</div>
<div>Actual {station.totalActualMinutes} min</div>
</div>
<div className="mt-2 flex flex-wrap gap-2 text-[11px] text-muted">
{(stationHotDaysByStationId.get(station.stationId) ?? []).slice(0, 3).map((entry) => (
<span key={`${station.stationId}-${entry.dateKey}`} className={`rounded-full border px-2 py-1 ${entry.overloaded ? "border-amber-300/60 bg-amber-400/10 text-amber-300" : "border-line/70 bg-page/60"}`}>
{formatDate(entry.dateKey)} {entry.utilizationPercent}%
</span>
))}
</div>
{draggingOperation ? (
<div className="mt-2 rounded-[14px] border border-line/70 bg-page/60 px-2 py-2 text-xs text-muted">
<div className="flex items-center justify-between gap-3">
@@ -1393,7 +1560,10 @@ function OverviewBoard({
) : null}
{groupMode === "exceptions" ? (
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Dispatch Exceptions</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 space-y-2">
{exceptionRows.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`flex w-full items-center justify-between gap-3 rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1413,7 +1583,10 @@ function OverviewBoard({
</section>
) : null}
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-muted">Active Work Orders</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
<div className="mt-3 grid gap-3 xl:grid-cols-2">
{workOrders.map((record) => (
<button key={record.id} type="button" onClick={() => onSelect(record.id)} className={`rounded-[16px] border px-2 py-2 text-left transition hover:bg-surface ${selectedId === record.id ? "border-brand bg-brand/10" : "border-line/70 bg-surface/80"}`}>
@@ -1447,6 +1620,7 @@ function HeatmapBoard({ heatmap, selectedDate, onSelectDate }: { heatmap: Heatma
<div className="flex flex-wrap items-center gap-2 text-xs text-muted">
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">84-day horizon</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{heatmap.reduce((sum, cell) => sum + cell.count, 0)} scheduled touchpoints</span>
<span className="rounded-full border border-line/70 bg-page/60 px-2 py-1">{heatmap.reduce((sum, cell) => sum + cell.hotStationCount, 0)} hot station-days</span>
{selectedDate ? <span className="rounded-full border border-brand/40 bg-brand/10 px-2 py-1 text-text">Selected {formatDate(selectedDate, { weekday: "short", month: "short", day: "numeric" })}</span> : null}
</div>
<div className="overflow-x-auto rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
@@ -1480,8 +1654,9 @@ function AgendaBoard({ records, onSelect, selectedOperationIds, selectedId, comp
return (
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
{!compact ? (
<div>
<div className="flex items-center justify-between gap-3">
<p className="section-kicker">AGENDA</p>
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
</div>
) : null}
<div className="space-y-2">
@@ -1505,7 +1680,21 @@ function AgendaBoard({ records, onSelect, selectedOperationIds, selectedId, comp
);
}
function SelectedDayPanel({ cell, onSelect, selectedOperationIds, selectedId }: { cell: HeatmapCell; onSelect: (id: string) => void; selectedOperationIds: string[]; selectedId: string | null }) {
function SelectedDayPanel({
cell,
stationLoads,
stationLookup,
onSelect,
selectedOperationIds,
selectedId,
}: {
cell: HeatmapCell;
stationLoads: Array<{ stationId: string; utilizationPercent: number; overloaded: boolean; plannedMinutes: number; capacityMinutes: number }>;
stationLookup: Map<string, PlanningStationLoadDto>;
onSelect: (id: string) => void;
selectedOperationIds: string[];
selectedId: string | null;
}) {
return (
<div className="mt-3 space-y-2">
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
@@ -1514,8 +1703,27 @@ function SelectedDayPanel({ cell, onSelect, selectedOperationIds, selectedId }:
<span>{cell.count} scheduled</span>
<span>{cell.lateCount} late</span>
</div>
<div className="mt-1 text-xs text-muted">{cell.blockedCount} blocked</div>
<div className="mt-1 text-xs text-muted">{cell.blockedCount} blocked · {cell.hotStationCount} hot stations</div>
</div>
{stationLoads.length > 0 ? (
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
<div className="section-kicker">STATION LOAD</div>
<div className="mt-2 space-y-2">
{stationLoads.slice(0, 4).map((entry) => {
const station = stationLookup.get(entry.stationId);
return (
<div key={`${entry.stationId}-${cell.dateKey}`} className={`rounded-[14px] border px-2 py-2 text-xs ${entry.overloaded ? "border-amber-300/60 bg-amber-400/10 text-amber-300" : "border-line/70 bg-surface text-muted"}`}>
<div className="flex items-center justify-between gap-3">
<span className="font-semibold text-text">{station?.stationCode ?? entry.stationId}</span>
<span>{entry.utilizationPercent}%</span>
</div>
<div className="mt-1">{entry.plannedMinutes} / {entry.capacityMinutes} min</div>
</div>
);
})}
</div>
</div>
) : null}
<div className="space-y-2">
{cell.tasks.slice(0, 8).map((task) => (
<button key={task.id} type="button" onClick={() => onSelect(task.id)} className={`block w-full rounded-[16px] border px-2 py-2 text-left transition hover:bg-page/80 ${selectedId === task.id ? "border-brand bg-brand/10" : "border-line/70 bg-page/60"}`}>