more workbench usability
This commit is contained in:
@@ -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"}`}>
|
||||
|
||||
Reference in New Issue
Block a user