From 354730fb6ccaeb7e8a6d95f640fab91bc95e43e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9=20=D0=A1=D0=BE?= =?UTF-8?q?=D0=BB=D0=BE=D0=B2=D1=8C=D0=B5=D0=B2?= Date: Wed, 17 Jun 2026 14:07:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(tgu-ui):=20=D0=B7=D0=B0=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D1=82=20=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=D0=B0=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=84=D0=BB=D0=B8=D0=BA=D1=82=D0=BD=D1=8B=D1=85=20=D1=81?= =?UTF-8?q?=D0=BB=D0=BE=D1=82=D0=BE=D0=B2=20+=20=D0=BF=D1=80=D0=BE=D1=85?= =?UTF-8?q?=D0=BE=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B1=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=BF=D0=BE=D0=B4=D1=81=D0=B2=D0=B5=D1=82=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - конфликтные слоты нельзя добавить в выбор под бронь (клик по строке, Shift-диапазон, выбор полосы на карте, «выбрать все») — жёсткая блокировка; снятие выбора не блокируется (конфликт считается только для невыбранных) - слоты одной полосы между собой не конфликтуют (groupOf) — иначе нельзя было бы набрать полосу поштучным кликом - чекбокс «Конфликты» теперь и гасит, и блокирует выбор; выкл. — выбирать любые - переход на прохождение (◀/▶) больше не подсвечивает никакой слот: активная подсветка строки — только по явному клику; добавлена прокрутка к началу прохода - повторный Shift+клик по тому же концу снимает выделение (таблица и карта) --- .../src/features/tgu-slots/CoveragePanel.tsx | 34 +++-- .../src/features/tgu-slots/TguSlotsTab.tsx | 132 +++++++++++++----- .../features/tgu-slots/slotConflicts.test.ts | 17 +++ .../src/features/tgu-slots/slotConflicts.ts | 23 ++- .../features/tgu-slots/slotSelection.test.ts | 30 +++- .../src/features/tgu-slots/slotSelection.ts | 26 ++++ .../src/styles/tgu-planning.css | 21 ++- 7 files changed, 220 insertions(+), 63 deletions(-) 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+тянуть на карте
-