|
|
|
@@ -50,6 +50,7 @@ type HeatmapCell = {
|
|
|
|
count: number;
|
|
|
|
count: number;
|
|
|
|
lateCount: number;
|
|
|
|
lateCount: number;
|
|
|
|
blockedCount: number;
|
|
|
|
blockedCount: number;
|
|
|
|
|
|
|
|
hotStationCount: number;
|
|
|
|
tasks: FocusRecord[];
|
|
|
|
tasks: FocusRecord[];
|
|
|
|
};
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
@@ -92,6 +93,38 @@ function dateKey(value: Date) {
|
|
|
|
return value.toISOString().slice(0, 10);
|
|
|
|
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"] {
|
|
|
|
function parseFocusKind(task: GanttTaskDto): FocusRecord["kind"] {
|
|
|
|
if (task.type === "project") {
|
|
|
|
if (task.type === "project") {
|
|
|
|
return "PROJECT";
|
|
|
|
return "PROJECT";
|
|
|
|
@@ -109,6 +142,9 @@ function densityTone(cell: HeatmapCell) {
|
|
|
|
if (cell.lateCount > 0) {
|
|
|
|
if (cell.lateCount > 0) {
|
|
|
|
return "border-rose-400/60 bg-rose-500/25";
|
|
|
|
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) {
|
|
|
|
if (cell.blockedCount > 0) {
|
|
|
|
return "border-amber-300/60 bg-amber-400/25";
|
|
|
|
return "border-amber-300/60 bg-amber-400/25";
|
|
|
|
}
|
|
|
|
}
|
|
|
|
@@ -156,6 +192,37 @@ function readinessLabel(record: FocusRecord) {
|
|
|
|
return record.readinessState.replaceAll("_", " ");
|
|
|
|
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[]) {
|
|
|
|
function buildFocusRecords(tasks: GanttTaskDto[]) {
|
|
|
|
return tasks.map((task) => ({
|
|
|
|
return tasks.map((task) => ({
|
|
|
|
id: task.id,
|
|
|
|
id: task.id,
|
|
|
|
@@ -283,6 +350,7 @@ export function WorkbenchPage() {
|
|
|
|
const summary = timeline?.summary;
|
|
|
|
const summary = timeline?.summary;
|
|
|
|
const exceptions = timeline?.exceptions ?? [];
|
|
|
|
const exceptions = timeline?.exceptions ?? [];
|
|
|
|
const stationLoads = timeline?.stationLoads ?? [];
|
|
|
|
const stationLoads = timeline?.stationLoads ?? [];
|
|
|
|
|
|
|
|
const stationDayLoads = timeline?.stationDayLoads ?? [];
|
|
|
|
const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]);
|
|
|
|
const focusRecords = useMemo(() => buildFocusRecords(tasks), [tasks]);
|
|
|
|
const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]);
|
|
|
|
const filteredFocusRecords = useMemo(() => focusRecords.filter((record) => matchesWorkbenchFilter(record, workbenchFilter)), [focusRecords, workbenchFilter]);
|
|
|
|
const focusById = useMemo(() => new Map(focusRecords.map((record) => [record.id, record])), [focusRecords]);
|
|
|
|
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>();
|
|
|
|
const cells = new Map<string, HeatmapCell>();
|
|
|
|
for (let index = 0; index < 84; index += 1) {
|
|
|
|
for (let index = 0; index < 84; index += 1) {
|
|
|
|
const nextDate = new Date(start.getTime() + index * DAY_MS);
|
|
|
|
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) {
|
|
|
|
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()];
|
|
|
|
return [...cells.values()];
|
|
|
|
}, [filteredFocusRecords, summary]);
|
|
|
|
}, [filteredFocusRecords, stationDayLoads, summary]);
|
|
|
|
|
|
|
|
|
|
|
|
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
|
|
|
|
const selectedHeatmapCell = selectedHeatmapDate ? heatmap.find((cell) => cell.dateKey === selectedHeatmapDate) ?? null : null;
|
|
|
|
const stationLoadById = useMemo(() => new Map(stationLoads.map((station) => [station.stationId, station])), [stationLoads]);
|
|
|
|
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 selectedRescheduleStation = rescheduleStationId ? stations.find((station) => station.id === rescheduleStationId) ?? null : null;
|
|
|
|
const selectedRescheduleLoad = selectedRescheduleStation ? stationLoadById.get(selectedRescheduleStation.id) ?? 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]);
|
|
|
|
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,
|
|
|
|
overloaded: projectedUtilizationPercent > 100,
|
|
|
|
};
|
|
|
|
};
|
|
|
|
}, [batchStationId, selectedOperationLoadMinutes, stationLoadById, stations]);
|
|
|
|
}, [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(() => {
|
|
|
|
const batchStationSuggestions = useMemo(() => {
|
|
|
|
if (selectedOperations.length === 0) {
|
|
|
|
if (selectedOperations.length === 0) {
|
|
|
|
return [];
|
|
|
|
return [];
|
|
|
|
@@ -421,7 +547,7 @@ export function WorkbenchPage() {
|
|
|
|
() => [...focusRecords]
|
|
|
|
() => [...focusRecords]
|
|
|
|
.filter((record) => record.kind !== "OPERATION")
|
|
|
|
.filter((record) => record.kind !== "OPERATION")
|
|
|
|
.filter((record) => matchesWorkbenchFilter(record, workbenchFilter))
|
|
|
|
.filter((record) => matchesWorkbenchFilter(record, workbenchFilter))
|
|
|
|
.sort((left, right) => new Date(left.end).getTime() - new Date(right.end).getTime())
|
|
|
|
.sort(comparePriority)
|
|
|
|
.slice(0, 18),
|
|
|
|
.slice(0, 18),
|
|
|
|
[focusRecords, workbenchFilter]
|
|
|
|
[focusRecords, workbenchFilter]
|
|
|
|
);
|
|
|
|
);
|
|
|
|
@@ -433,11 +559,12 @@ export function WorkbenchPage() {
|
|
|
|
return (selectedHeatmapCell?.tasks ?? filteredFocusRecords.filter((record) => record.kind !== "PROJECT")).slice(0, 18);
|
|
|
|
return (selectedHeatmapCell?.tasks ?? filteredFocusRecords.filter((record) => record.kind !== "PROJECT")).slice(0, 18);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const projects = filteredFocusRecords.filter((record) => record.kind === "PROJECT").slice(0, 6);
|
|
|
|
const projects = filteredFocusRecords.filter((record) => record.kind === "PROJECT").sort(comparePriority).slice(0, 6);
|
|
|
|
const operations = filteredFocusRecords.filter((record) => record.kind === "OPERATION");
|
|
|
|
const operations = filteredFocusRecords.filter((record) => record.kind === "OPERATION").sort(comparePriority);
|
|
|
|
const workOrders = filteredFocusRecords.filter((record) => record.kind === "WORK_ORDER").slice(0, 10);
|
|
|
|
const workOrders = filteredFocusRecords.filter((record) => record.kind === "WORK_ORDER").sort(comparePriority).slice(0, 10);
|
|
|
|
const exceptionRows = filteredFocusRecords
|
|
|
|
const exceptionRows = filteredFocusRecords
|
|
|
|
.filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"))
|
|
|
|
.filter((record) => record.kind !== "PROJECT" && (record.overdue || record.totalShortageQuantity > 0 || record.readinessState === "BLOCKED" || record.readinessState === "PENDING_SUPPLY"))
|
|
|
|
|
|
|
|
.sort(comparePriority)
|
|
|
|
.slice(0, 10);
|
|
|
|
.slice(0, 10);
|
|
|
|
|
|
|
|
|
|
|
|
if (workbenchGroup === "projects") {
|
|
|
|
if (workbenchGroup === "projects") {
|
|
|
|
@@ -457,7 +584,7 @@ export function WorkbenchPage() {
|
|
|
|
stationBuckets.set(record.stationId, bucket);
|
|
|
|
stationBuckets.set(record.stationId, bucket);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (const bucket of stationBuckets.values()) {
|
|
|
|
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
|
|
|
|
return stationLoads
|
|
|
|
.slice(0, 10)
|
|
|
|
.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 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>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>{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>
|
|
|
|
) : (
|
|
|
|
) : (
|
|
|
|
<div className="mt-2">Keeping current stations. Pick a station to preview the batch landing load.</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>
|
|
|
|
</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">
|
|
|
|
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted">
|
|
|
|
{selectedOperations.slice(0, 6).map((record) => (
|
|
|
|
{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">
|
|
|
|
<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
|
|
|
|
<OverviewBoard
|
|
|
|
focusRecords={filteredFocusRecords}
|
|
|
|
focusRecords={filteredFocusRecords}
|
|
|
|
stationLoads={stationLoads}
|
|
|
|
stationLoads={stationLoads}
|
|
|
|
|
|
|
|
stationHotDaysByStationId={stationHotDaysByStationId}
|
|
|
|
groupMode={workbenchGroup}
|
|
|
|
groupMode={workbenchGroup}
|
|
|
|
onSelect={setSelectedFocusId}
|
|
|
|
onSelect={setSelectedFocusId}
|
|
|
|
selectedOperationIds={selectedOperationIds}
|
|
|
|
selectedOperationIds={selectedOperationIds}
|
|
|
|
@@ -1137,7 +1286,7 @@ export function WorkbenchPage() {
|
|
|
|
<section className="surface-panel">
|
|
|
|
<section className="surface-panel">
|
|
|
|
<p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p>
|
|
|
|
<p className="section-kicker">{workbenchMode === "heatmap" ? "SELECTED DAY" : "UPCOMING AGENDA"}</p>
|
|
|
|
{workbenchMode === "heatmap"
|
|
|
|
{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 />}
|
|
|
|
: <AgendaBoard records={agendaItems.slice(0, 8)} onSelect={setSelectedFocusId} selectedOperationIds={selectedOperationIds} selectedId={selectedFocus?.id ?? null} compact />}
|
|
|
|
</section>
|
|
|
|
</section>
|
|
|
|
</aside>
|
|
|
|
</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 }) {
|
|
|
|
function RecordSignals({ record, queued = false, selected = false }: { record: FocusRecord; queued?: boolean; selected?: boolean }) {
|
|
|
|
return (
|
|
|
|
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)}`}>
|
|
|
|
<span className={`rounded-full border px-2 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] ${readinessTone(record)}`}>
|
|
|
|
{readinessLabel(record)}
|
|
|
|
{readinessLabel(record)}
|
|
|
|
</span>
|
|
|
|
</span>
|
|
|
|
@@ -1173,6 +1325,7 @@ function RecordSignals({ record, queued = false, selected = false }: { record: F
|
|
|
|
function OverviewBoard({
|
|
|
|
function OverviewBoard({
|
|
|
|
focusRecords,
|
|
|
|
focusRecords,
|
|
|
|
stationLoads,
|
|
|
|
stationLoads,
|
|
|
|
|
|
|
|
stationHotDaysByStationId,
|
|
|
|
groupMode,
|
|
|
|
groupMode,
|
|
|
|
onSelect,
|
|
|
|
onSelect,
|
|
|
|
selectedOperationIds,
|
|
|
|
selectedOperationIds,
|
|
|
|
@@ -1186,6 +1339,7 @@ function OverviewBoard({
|
|
|
|
}: {
|
|
|
|
}: {
|
|
|
|
focusRecords: FocusRecord[];
|
|
|
|
focusRecords: FocusRecord[];
|
|
|
|
stationLoads: PlanningStationLoadDto[];
|
|
|
|
stationLoads: PlanningStationLoadDto[];
|
|
|
|
|
|
|
|
stationHotDaysByStationId: Map<string, Array<{ dateKey: string; utilizationPercent: number; overloaded: boolean }>>;
|
|
|
|
groupMode: WorkbenchGroup;
|
|
|
|
groupMode: WorkbenchGroup;
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
onSelect: (id: string) => void;
|
|
|
|
selectedOperationIds: string[];
|
|
|
|
selectedOperationIds: string[];
|
|
|
|
@@ -1226,7 +1380,10 @@ function OverviewBoard({
|
|
|
|
{groupMode === "projects" ? (
|
|
|
|
{groupMode === "projects" ? (
|
|
|
|
<div className="grid gap-3 xl:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
|
|
|
<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">
|
|
|
|
<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">
|
|
|
|
<div className="mt-3 space-y-3">
|
|
|
|
{projects.map((record) => (
|
|
|
|
{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"}`}>
|
|
|
|
<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>
|
|
|
|
</div>
|
|
|
|
</section>
|
|
|
|
</section>
|
|
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
|
|
|
<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">
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
{operations.slice(0, 10).map((record) => (
|
|
|
|
{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"}`}>
|
|
|
|
<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>Planned {station.totalPlannedMinutes} min</div>
|
|
|
|
<div>Actual {station.totalActualMinutes} min</div>
|
|
|
|
<div>Actual {station.totalActualMinutes} min</div>
|
|
|
|
</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 ? (
|
|
|
|
{draggingOperation ? (
|
|
|
|
<div className="mt-2 rounded-[14px] border border-line/70 bg-page/60 px-2 py-2 text-xs text-muted">
|
|
|
|
<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">
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
@@ -1393,7 +1560,10 @@ function OverviewBoard({
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
{groupMode === "exceptions" ? (
|
|
|
|
{groupMode === "exceptions" ? (
|
|
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
|
|
|
<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">
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
{exceptionRows.map((record) => (
|
|
|
|
{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"}`}>
|
|
|
|
<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>
|
|
|
|
</section>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
<section className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-2">
|
|
|
|
<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">
|
|
|
|
<div className="mt-3 grid gap-3 xl:grid-cols-2">
|
|
|
|
{workOrders.map((record) => (
|
|
|
|
{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"}`}>
|
|
|
|
<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">
|
|
|
|
<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">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.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}
|
|
|
|
{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>
|
|
|
|
<div className="overflow-x-auto rounded-[18px] border border-line/70 bg-page/60 px-3 py-3">
|
|
|
|
<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 (
|
|
|
|
return (
|
|
|
|
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
|
|
|
|
<div className={compact ? "mt-3 space-y-2" : "space-y-3"}>
|
|
|
|
{!compact ? (
|
|
|
|
{!compact ? (
|
|
|
|
<div>
|
|
|
|
<div className="flex items-center justify-between gap-3">
|
|
|
|
<p className="section-kicker">AGENDA</p>
|
|
|
|
<p className="section-kicker">AGENDA</p>
|
|
|
|
|
|
|
|
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-muted">Priority ranked</span>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
) : null}
|
|
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
|
|
<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 (
|
|
|
|
return (
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
<div className="mt-3 space-y-2">
|
|
|
|
<div className="rounded-[18px] border border-line/70 bg-page/60 px-2 py-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.count} scheduled</span>
|
|
|
|
<span>{cell.lateCount} late</span>
|
|
|
|
<span>{cell.lateCount} late</span>
|
|
|
|
</div>
|
|
|
|
</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>
|
|
|
|
</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">
|
|
|
|
<div className="space-y-2">
|
|
|
|
{cell.tasks.slice(0, 8).map((task) => (
|
|
|
|
{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"}`}>
|
|
|
|
<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"}`}>
|
|
|
|
|