diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx
index 465b609..02de0fd 100644
--- a/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx
+++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/CoveragePanel.tsx
@@ -12,6 +12,9 @@ type CoveragePanelProps = {
/** Тумблер следа покрытия на карте (D4 — локальный, вкл. по умолчанию). */
coverageOn: boolean;
onToggleCoverage: (on: boolean) => void;
+ /** Тумблер подсветки конфликтов выбора (тусклый слой) — рядом со следом на карте. */
+ showConflicts: boolean;
+ onToggleConflicts: (on: boolean) => void;
};
// Панель покрытия над списком слотов (D-D1): «забронировано NN% · с выбором MM%» + двухсегментный
@@ -23,6 +26,8 @@ export function CoveragePanel({
available,
coverageOn,
onToggleCoverage,
+ showConflicts,
+ onToggleConflicts,
}: CoveragePanelProps) {
const bookedPct = booked?.percent ?? 0;
const withPct = withSelection?.percent ?? bookedPct;
@@ -34,14 +39,27 @@ export function CoveragePanel({
Покрытие заявки
-
+
+
+
+
{!available ? (
diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx
index dbcce49..b632634 100644
--- a/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx
+++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx
@@ -41,7 +41,7 @@ import {
} from "./coverage";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { computeConflicts } from "./slotConflicts";
-import { selectionAfterCheck, selectStripRange } from "./slotSelection";
+import { deselectStripRange, selectionAfterCheck, selectStripRange } from "./slotSelection";
import { groupStrips, pickStripAnchor, pickStripEnd, type PolosaId } from "./slotStrips";
import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress";
@@ -153,6 +153,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Якорь жеста «полоса от первого к последнему»: ключ первого кликнутого слота. Состояние (не ref):
// по нему подсвечиваем строки той же полосы, чтобы оператор видел границу выделения.
const [anchorKey, setAnchorKey] = useState(null);
+ // Последний конец Shift-диапазона в таблице: повторный Shift+клик по тому же концу снимает диапазон
+ // (toggle). Ref — для логики жеста, перерисовка не нужна; сбрасывается при смене якоря/набора.
+ const lastShiftEndRef = useRef(null);
// Подсветка строки/контура по клику на карте и центрирование на слоте.
const [activeSlotId, setActiveSlotId] = useState();
@@ -336,6 +339,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Производительность: выбор берём через useDeferredValue — во время Shift+drag (selectedKeys меняется
// на каждый pointermove) пересчёт идёт фоном/после паузы, не блокируя жест. Кандидаты — только видимый
// filteredSlots; источник занятых интервалов — полный displaySlots (выбор может быть вне окна суток).
+ // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
+ // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
+ // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
+ // Нужна и для конфликтов ниже: слоты одной полосы между собой не конфликтуют.
+ const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
+
const deferredSelectedKeys = useDeferredValue(selectedKeys);
const conflictKeys = useMemo(
() =>
@@ -346,9 +355,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
slotKey,
(slot) => deferredSelectedKeys.has(slotKey(slot)),
(slot) => bookedKeys.has(slotKey(slot)) || slotIsBusy(slot),
+ (slot) => polosa.idByKey.get(slotKey(slot)),
)
: new Set(),
- [showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys]
+ [showConflicts, displaySlots, filteredSlots, deferredSelectedKeys, bookedKeys, polosa]
);
// id полигонов конфликтных слотов — приглушение на карте (параллель markedSlotIds).
const dimmedSlotIds = useMemo(
@@ -356,14 +366,26 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
[conflictKeys]
);
- // Разметка полос — над СТАБИЛЬНЫМ набором displaySlots (НЕ filteredSlots/sortedSlots: иначе
- // фильтр-дырка ложно разорвала или склеила бы полосу). Полоса = один КА + один крен + подряд по
- // времени (виток не граница). idByKey — id полосы по ключу слота; membersOf — члены полосы по времени.
- const polosa = useMemo(() => groupStrips(displaySlots, slotKey), [displaySlots]);
+ // Конфликтные слоты нельзя добавлять в выбор под бронь (жёсткая блокировка). Держим текущий набор в
+ // ref, чтобы обработчики жестов (useCallback) читали его без пересоздания на каждый ререндер выбора.
+ const conflictKeysRef = useRef>(conflictKeys);
+ conflictKeysRef.current = conflictKeys;
+ // Убирает заблокированные (конфликтные) ключи из нового набора выбора. Уже выбранные слоты в
+ // conflictKeys не попадают, поэтому снятие выбора и прежний выбор не затрагиваются — режутся только
+ // конфликтные слоты, которые жест попытался добавить. Мутирует переданный (свежий) Set.
+ const dropConflicts = useCallback((next: Set): Set => {
+ const blocked = conflictKeysRef.current;
+ if (blocked.size > 0) for (const key of blocked) next.delete(key);
+ return next;
+ }, []);
// Состояние жеста «выбор полосы мышью на карте» (Shift+drag). Держим в ref: правки идут на каждом
- // движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем).
- const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[] } | null>(null);
+ // движении указателя, перерисовка не нужна. members — члены полосы якоря без BUSY (их не бронируем);
+ // endKey — текущий конец (на старте = якорь; меняется в drag) — нужен commit, чтобы отличить
+ // одиночный клик (end === anchor) от протяжки.
+ const mapStripRef = useRef<{ anchorKey: string; anchorId: PolosaId; members: string[]; endKey: string } | null>(null);
+ // Последний конец одиночного Shift-клика на карте: повтор по тому же слоту снимает его (toggle).
+ const lastMapEndRef = useRef(null);
// Shift+нажатие по слотам на карте: якорь полосы = самый ранний по времени выбираемый слот под
// курсором. Выбор этой полосы сбрасываем к одному якорю (далее drag растянет), как и в таблице.
@@ -373,9 +395,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const slot = slotByKey.get(key);
return slot ? parseLocal(slot.tn) : Number.NaN;
};
+ // Конфликтные слоты в выбор не берём — на них нельзя ни встать якорем, ни попасть в полосу.
+ const blocked = conflictKeysRef.current;
const selectable = keys.filter((key) => {
const slot = slotByKey.get(key);
- return slot && !slotIsBusy(slot);
+ return slot && !slotIsBusy(slot) && !blocked.has(key);
});
const anchor = pickStripAnchor(selectable, tnMsOf);
const anchorId = anchor ? polosa.idByKey.get(anchor) : undefined;
@@ -385,10 +409,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
const members = (polosa.membersOf.get(anchorId) ?? []).filter((key) => {
const slot = slotByKey.get(key);
- return !(slot && slotIsBusy(slot));
+ return !(slot && slotIsBusy(slot)) && !blocked.has(key);
});
- mapStripRef.current = { anchorKey: anchor, anchorId, members };
+ mapStripRef.current = { anchorKey: anchor, anchorId, members, endKey: anchor };
setAnchorKey(anchor);
+ lastShiftEndRef.current = null;
setSelectedKeys((current) => selectStripRange(current, members, anchor, anchor));
},
[polosa, slotByKey]
@@ -402,13 +427,29 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
if (!gesture) return;
const end = pickStripEnd(polosa, gesture.anchorId, keys);
if (!end) return;
+ gesture.endKey = end;
setSelectedKeys((current) => selectStripRange(current, gesture.members, gesture.anchorKey, end));
},
[polosa]
);
const handleStripCommit = useCallback(() => {
+ const gesture = mapStripRef.current;
mapStripRef.current = null;
+ if (!gesture) return;
+ // Одиночный Shift+клик (без протяжки): end === anchor. Повтор по тому же слоту снимает его
+ // (anchor на pointerdown уже переотметил его — на commit гасим). Протяжку (диапазон) не тогглим.
+ const isClick = gesture.endKey === gesture.anchorKey;
+ if (isClick && lastMapEndRef.current === gesture.anchorKey) {
+ setSelectedKeys((current) => {
+ const next = new Set(current);
+ next.delete(gesture.anchorKey);
+ return next;
+ });
+ lastMapEndRef.current = null;
+ } else {
+ lastMapEndRef.current = isClick ? gesture.anchorKey : null;
+ }
}, []);
// Якорь жеста привязан к ВИДИМОМУ набору: смена фильтров/окна суток/брони делает прежний якорь
@@ -416,6 +457,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
useEffect(() => {
setAnchorKey(null);
mapStripRef.current = null;
+ lastShiftEndRef.current = null;
+ lastMapEndRef.current = null;
}, [filteredSlots]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
@@ -609,6 +652,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
if (index >= 0) rowVirtualizer.scrollToIndex(index, { align: "center" });
}, [activeSlotId, sortedSlots, rowVirtualizer]);
+ // Переход на прохождение прокручивает таблицу к началу (без подсветки строки): оператор видит
+ // первый слот изолированного прохождения. Только при выбранном проходе — иначе скролл не трогаем.
+ useEffect(() => {
+ if (effPassCursor >= 0) rowVirtualizer.scrollToIndex(0, { align: "start" });
+ }, [effPassCursor, rowVirtualizer]);
+
// Загружает брони выбранной заявки (для бейджей и слоя карты).
const loadBooked = useCallback(async (requestId: string) => {
try {
@@ -894,15 +943,31 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const slot = slotByKey.get(k);
return !(slot && slotIsBusy(slot));
});
- setSelectedKeys((current) => selectStripRange(current, members, anchorKey, targetKey));
+ if (lastShiftEndRef.current === targetKey) {
+ // Повторный Shift+клик по тому же концу — снимаем выделенный диапазон [якорь..конец].
+ setSelectedKeys((current) => deselectStripRange(current, members, anchorKey, targetKey));
+ lastShiftEndRef.current = null;
+ } else {
+ // Конфликтные члены полосы в выбор не берём (dropConflicts) — их выбрать нельзя.
+ setSelectedKeys((current) => dropConflicts(selectStripRange(current, members, anchorKey, targetKey)));
+ lastShiftEndRef.current = targetKey;
+ }
// Якорь НЕ двигаем: остаётся первым кликом, чтобы повторный Shift+клик задавал диапазон
// от того же якоря — ближе к нему «сжимал» выделение, дальше — расширял.
return;
}
+ // Обычный клик по конфликтному слоту — выбрать нельзя (снять выбор не блокируем: конфликтные
+ // слоты в выбор не попадают, значит targetKey тут заведомо не выбран).
+ if (conflictKeysRef.current.has(targetKey)) {
+ setNotice("Слот конфликтует с выбором (не хватает времени на манёвр того же КА) — выбрать нельзя.");
+ return;
+ }
setSelectedKeys((current) =>
selectionAfterCheck(current, (i) => slotKey(sortedSlots[i]), index, false, null)
);
setAnchorKey(targetKey);
+ lastShiftEndRef.current = null;
+ lastMapEndRef.current = null;
};
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
@@ -913,16 +978,22 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const toggleAllVisible = () => {
setSelectedKeys((current) => {
const next = new Set(current);
- if (allVisibleSelected) for (const slot of selectableVisible) next.delete(slotKey(slot));
- else for (const slot of selectableVisible) next.add(slotKey(slot));
+ if (allVisibleSelected) {
+ for (const slot of selectableVisible) next.delete(slotKey(slot));
+ } else {
+ for (const slot of selectableVisible) next.add(slotKey(slot));
+ dropConflicts(next); // конфликтные видимые слоты не добавляем
+ }
return next;
});
setAnchorKey(null);
};
- // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
+ // Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью и конфликтных видимых), снять весь
+ // выбор. Конфликты считаются по видимому набору, поэтому режем известные (dropConflicts) — бэкенд
+ // остаётся финальным арбитром выполнимости.
const selectAllDays = () =>
- setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
+ setSelectedKeys(dropConflicts(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey))));
const clearSelection = () => {
setSelectedKeys(new Set());
setAnchorKey(null);
@@ -965,9 +1036,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
focusOn({ lat: slot.latitude, lon: slot.longitude });
};
- // Переход на прохождение по индексу: ставит курсор, подтягивает окно суток к проходу (сохраняя
- // ширину), скроллит таблицу к первой строке и подсвечивает проход. Карту НЕ двигаем — оператор
- // листает прохождения, не теряя текущий обзор/зум; центрирование осталось только на клике по слоту.
+ // Переход на прохождение по индексу: ставит курсор и подтягивает окно суток к проходу (сохраняя
+ // ширину). Никакой слот НЕ подсвечиваем — само прохождение изолирует таблицу, а активная подсветка
+ // одной строки появляется только по явному клику оператора. Карту НЕ двигаем — оператор листает
+ // прохождения, не теряя текущий обзор/зум; центрирование осталось только на клике по слоту.
const goToPass = useCallback(
(target: number) => {
if (passes.length === 0) return;
@@ -989,12 +1061,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
}
}
- // Скролл таблицы к проходу — через тот же activeSlotId-эффект, что и клик по слоту, но без
- // focusOn: карту оставляем на месте (правка «карта не прыгает при работе с фильтром»).
- const firstKey = pass.keys[0];
- if (firstKey && slotByKey.has(firstKey)) setActiveSlotId(`slot:${firstKey}`);
+ // Снимаем активную подсветку строки с прошлого прохождения: при переходе не должен светиться
+ // никакой слот (прокрутку к началу делает отдельный эффект по effPassCursor, без подсветки).
+ setActiveSlotId(undefined);
},
- [passes, days, effDayFrom, effDayTo, slotByKey]
+ [passes, days, effDayFrom, effDayTo]
);
// ◀ из «не выбран» входит с конца, ▶ — с начала; дальше клампим в [0, last].
@@ -1349,6 +1420,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
available={coverageGrid !== null}
coverageOn={coverageOn}
onToggleCoverage={setCoverageOn}
+ showConflicts={showConflicts}
+ onToggleConflicts={setShowConflicts}
/>
)}
@@ -1365,17 +1438,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
Полоса: клик+Shift в таблице · Shift+тянуть на карте