diff --git a/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotViewDTO.kt b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotViewDTO.kt new file mode 100644 index 0000000..0e1f926 --- /dev/null +++ b/libs/pcp-types-lib/src/main/kotlin/space/nstart/pcp/pcp_types_lib/dto/requests/slots/BookedSlotViewDTO.kt @@ -0,0 +1,28 @@ +package space.nstart.pcp.pcp_types_lib.dto.requests.slots + +import space.nstart.pcp.pcp_types_lib.dto.ballistics.RevolutionSign +import java.time.LocalDateTime + +/** + * Забронированный слот для отображения и снятия брони (GET /api/slots/booking/by-request/{id}). + * + * В отличие от [SlotDTO] несёт [bookedSlotId] — он нужен фронту, чтобы снимать бронь по списку id + * (DELETE /api/slots/booking/batch). В отличие от [BookedSlotDTO] несёт контур и координаты — нужны + * карте. Критично: [cycle] — это цикл БРОНИ (BookedSlotEntity.cycle), а не базовый slot.cycle, + * иначе ключ sat:slotNum:cycle на фронте не совпадёт с ответом POST booking/request. + */ +class BookedSlotViewDTO( + val bookedSlotId : Long = 0, + val cycle : Long = 0, + val satelliteId : Long = 0, + val tn : LocalDateTime, + val tk : LocalDateTime, + val roll : Double, + val contour : String, + val revolution : Long = 0, + val revolutionSign: RevolutionSign = RevolutionSign.ASC, + val slotNumber : Long = 0, + val state : SlotStatus = SlotStatus.BOOKED, + val latitude : Double = 0.0, + val longitude : Double = 0.0, +) diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts index e78f813..b078be1 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.test.ts @@ -1,5 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { bookRequestSlots, fetchRequestSlots } from "./slotsApi"; +import { + bookRequestSlots, + cancelBookedSlots, + fetchBookedSlotsByRequest, + fetchRequestSlots, +} from "./slotsApi"; function jsonResponse(body: unknown): Response { return new Response(JSON.stringify(body), { @@ -8,6 +13,10 @@ function jsonResponse(body: unknown): Response { }); } +function textResponse(body: string): Response { + return new Response(body, { status: 200 }); +} + const baseParams = { requestId: "r1", timeStart: "2026-01-01T00:00:00", timeStop: "2026-01-02T00:00:00" }; afterEach(() => vi.restoreAllMocks()); @@ -61,3 +70,59 @@ describe("bookRequestSlots", () => { expect(res).toHaveLength(1); }); }); + +describe("cancelBookedSlots", () => { + it("DELETE booking/batch с requestId и списком ids в query, возвращает count", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("3")); + const res = await cancelBookedSlots("r1", [11, 12, 13]); + const [url, init] = fetchMock.mock.calls[0]; + expect(String(url)).toContain("booking/batch"); + expect(String(url)).toContain("requestId=r1"); + expect(String(url)).toContain("ids=11"); + expect(String(url)).toContain("ids=12"); + expect(String(url)).toContain("ids=13"); + expect(init?.method).toBe("DELETE"); + expect(res).toBe(3); + }); + + it("пустой список ids — не зовёт fetch и возвращает 0", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(textResponse("0")); + const res = await cancelBookedSlots("r1", []); + expect(fetchMock).not.toHaveBeenCalled(); + expect(res).toBe(0); + }); + + it("на не-ok бросает с текстом ошибки сервера", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response("boom", { status: 500 })); + await expect(cancelBookedSlots("r1", [1])).rejects.toThrow("boom"); + }); +}); + +describe("fetchBookedSlotsByRequest", () => { + it("возвращает брони с bookedSlotId и контуром", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + jsonResponse([ + { + bookedSlotId: 42, + cycle: 3, + satelliteId: 12, + slotNumber: 340, + tn: "2026-01-01T10:00:00", + tk: "2026-01-01T10:01:00", + roll: 0, + contour: "POLYGON ((0 0, 1 0, 1 1, 0 0))", + revolution: 1, + revolutionSign: "ASC", + state: "BOOKED", + latitude: 0, + longitude: 0, + }, + ]), + ); + const res = await fetchBookedSlotsByRequest("r1"); + expect(res).toHaveLength(1); + expect(res[0].bookedSlotId).toBe(42); + expect(res[0].cycle).toBe(3); + expect(res[0].contour).toContain("POLYGON"); + }); +}); diff --git a/services/pcp-tgu-ui-service/src/api/slotsApi.ts b/services/pcp-tgu-ui-service/src/api/slotsApi.ts index 36afec8..0d92996 100644 --- a/services/pcp-tgu-ui-service/src/api/slotsApi.ts +++ b/services/pcp-tgu-ui-service/src/api/slotsApi.ts @@ -1,5 +1,6 @@ import type { BookedSlotDTO, + BookedSlotView, RequestSlotsParams, SlotBookingRequest, SlotDTO, @@ -103,13 +104,47 @@ export async function cancelRequestBooking(requestId: string): Promise { return Number.isFinite(value) ? value : 0 } +/** + * Снимает выбранные брони заявки по списку bookedSlotId (slots-service: DELETE + * /api/slots/booking/batch). Один запрос = один чанк; крупный набор чанкует вызывающий + * (runChunked), как при бронировании. Список id передаётся в query (?ids=1&ids=2), а не в теле — + * тело у DELETE через gateway ненадёжно. requestId обязателен: снятие request-aware, чтобы + * шаренный между заявками слот не сносился у других. + * @param requestId Идентификатор заявки. + * @param bookedSlotIds Идентификаторы броней (bookedSlotId) одного чанка. + * + * @returns Сколько броней снято в этом чанке. + */ +export async function cancelBookedSlots( + requestId: string, + bookedSlotIds: number[], +): Promise { + if (bookedSlotIds.length === 0) return 0 + const query = new URLSearchParams() + query.set('requestId', requestId) + bookedSlotIds.forEach((id) => query.append('ids', String(id))) + + const response = await apiFetch(`${BASE}/booking/batch?${query}`, { + method: 'DELETE', + headers: { Accept: 'application/json' }, + }) + + if (!response.ok) { + throw new Error(await readErrorMessage(response, 'Не удалось снять бронь')) + } + + const value = Number(await response.text()) + return Number.isFinite(value) ? value : 0 +} + /** * Загружает уже забронированные слоты заявки (slots-service: GET /api/slots/booking/by-request/{id}). * @param requestId Идентификатор заявки. * - * @returns Список забронированных слотов с контурами. + * @returns Список забронированных слотов с контурами и bookedSlotId (для снятия по id); + * cycle здесь — цикл брони, поэтому slotKey совпадает с ответом POST booking/request. */ -export async function fetchBookedSlotsByRequest(requestId: string): Promise { +export async function fetchBookedSlotsByRequest(requestId: string): Promise { const response = await apiFetch( `${BASE}/booking/by-request/${encodeURIComponent(requestId)}`, { headers: { Accept: 'application/json' } }, @@ -119,5 +154,5 @@ export async function fetchBookedSlotsByRequest(requestId: string): Promise + return response.json() as Promise } diff --git a/services/pcp-tgu-ui-service/src/features/tgu-slots/ChunkProgress.tsx b/services/pcp-tgu-ui-service/src/features/tgu-slots/ChunkProgress.tsx new file mode 100644 index 0000000..3f5655b --- /dev/null +++ b/services/pcp-tgu-ui-service/src/features/tgu-slots/ChunkProgress.tsx @@ -0,0 +1,35 @@ +// Визуальный прогресс-бар чанковой операции (бронь и снятие). Общий компонент, чтобы обе +// операции показывали прогресс одинаково (DRY) — раньше прогресс брони был только текстом в кнопке. + +type ChunkProgressProps = { + /** Подпись операции, напр. «Бронирование» / «Снятие брони». */ + label: string; + /** Сколько элементов обработано. */ + done: number; + /** Всего элементов в операции. */ + total: number; +}; + +export function ChunkProgress({ label, done, total }: ChunkProgressProps) { + const pct = total > 0 ? Math.min(100, Math.round((done / total) * 100)) : 0; + return ( +
+
+ {label}… + + {done}/{total} + +
+
+
+
+
+ ); +} 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 e53b584..c3c3f7c 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 @@ -3,13 +3,13 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import { fetchRequestsForMap } from "../../api/requestApi"; import { bookRequestSlots, - cancelRequestBooking, + cancelBookedSlots, fetchBookedSlotsByRequest, fetchRequestSlots } from "../../api/slotsApi"; import type { RequestMapItem } from "../../model/requestTypes"; import type { - BookedSlotDTO, + BookedSlotView, RevolutionSign, SlotBookingRequest, SlotCoverageStrategy, @@ -26,7 +26,9 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; -import { chunk, selectionAfterCheck } from "./slotSelection"; +import { selectionAfterCheck } from "./slotSelection"; +import { runChunked } from "./runChunked"; +import { ChunkProgress } from "./ChunkProgress"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -80,13 +82,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); const [revSign, setRevSign] = useState<"" | RevolutionSign>(""); - const [withCoverage, setWithCoverage] = useState(false); // Стратегия автоформирования покрытия (кнопка «Автоподбор»). const [strategy, setStrategy] = useState("GREEDY"); - // Результаты: слоты-кандидаты и уже забронированные слоты заявки. + // Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id). const [slots, setSlots] = useState([]); - const [bookedSlots, setBookedSlots] = useState([]); + const [bookedSlots, setBookedSlots] = useState([]); const [selectedKeys, setSelectedKeys] = useState>(() => new Set()); // Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта. @@ -96,9 +97,15 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const [calculating, setCalculating] = useState(false); const [autoFilling, setAutoFilling] = useState(false); const [booking, setBooking] = useState(false); - // Прогресс чанкованной брони «X/Y» и набор, ждущий подтверждения (крупная бронь). - const [bookProgress, setBookProgress] = useState(); + // Снятие выбранных броней пачками (request-aware batch) — отдельный флаг «в полёте». + const [cancellingSel, setCancellingSel] = useState(false); + // Прогресс крупной чанковой операции (бронь / снятие выбранных) — общий бар . + const [progress, setProgress] = useState<{ label: string; done: number; total: number } | null>(null); const [pendingBook, setPendingBook] = useState(null); + // «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение. + // cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных. + const [pendingCancel, setPendingCancel] = useState(false); + const [cancelling, setCancelling] = useState(false); const [error, setError] = useState(); const [notice, setNotice] = useState(); @@ -141,8 +148,14 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { !Number.isFinite(parsedRange.toMs) || parsedRange.fromMs >= parsedRange.toMs; - // Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте. + // Ключи забронированных слотов — для бейджа «бронь» в таблице и слоя на карте. const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]); + // Бронь по ключу слота — чтобы из выбранной/кликнутой строки достать bookedSlotId для снятия. + const bookedByKey = useMemo(() => { + const map = new Map(); + for (const slot of bookedSlots) map.set(slotKey(slot), slot); + return map; + }, [bookedSlots]); // id полигонов слотов, отобранных галочками под бронь — постоянная подсветка на карте. const markedSlotIds = useMemo( @@ -158,10 +171,34 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { return map; }, [slots, bookedSlots]); - // Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения). + // Строки таблицы = кандидаты + брони, которых нет среди кандидатов (Finding 2-A). Так бронь + // выбираема и снимаема, даже если кандидаты ещё не рассчитаны или бронь в другом наборе. + const displaySlots = useMemo(() => { + const slotKeySet = new Set(slots.map(slotKey)); + const orphanBooked = bookedSlots.filter((slot) => !slotKeySet.has(slotKey(slot))); + return orphanBooked.length === 0 ? slots : [...slots, ...orphanBooked]; + }, [slots, bookedSlots]); + + // bookedSlotId выбранных броней (для «Снять выбранные», чанкуется) и число выбранных кандидатов + // под бронь (для «Забронировать»). Бронь и кандидат различаем по bookedKeys. + const selectedBookedIds = useMemo(() => { + const ids: number[] = []; + for (const key of selectedKeys) { + const booked = bookedByKey.get(key); + if (booked) ids.push(booked.bookedSlotId); + } + return ids; + }, [selectedKeys, bookedByKey]); + const selectedBookableCount = useMemo(() => { + let n = 0; + for (const key of selectedKeys) if (!bookedKeys.has(key)) n += 1; + return n; + }, [selectedKeys, bookedKeys]); + + // Сутки, по которым раскладываются строки таблицы (для фильтра отображения). const days = useMemo(() => { const buckets = new Map(); - for (const slot of slots) { + for (const slot of displaySlots) { const ms = parseLocal(slot.tn); if (!Number.isFinite(ms)) continue; const start = dayStartMs(ms); @@ -170,7 +207,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { return Array.from(buckets.entries()) .sort(([a], [b]) => a - b) .map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) })); - }, [slots]); + }, [displaySlots]); // Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон. useEffect(() => { @@ -181,16 +218,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1)); const effDayTo = Math.max(effDayFrom, Math.min(dayTo, days.length || 1)); - // Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте. + // Слоты выбранного диапазона суток — то, что видно и в таблице, и на карте (кандидаты + брони). const filteredSlots = useMemo(() => { - if (days.length === 0) return slots; + if (days.length === 0) return displaySlots; const fromMs = days[effDayFrom - 1].dayMs; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; - return slots.filter((slot) => { + return displaySlots.filter((slot) => { const ms = parseLocal(slot.tn); return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl; }); - }, [slots, days, effDayFrom, effDayTo]); + }, [displaySlots, days, effDayFrom, effDayTo]); // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты — // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). @@ -279,6 +316,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { setSelectedKeys(new Set()); setBookedSlots([]); setPendingBook(null); + setPendingCancel(false); setNotice(undefined); setError(undefined); setActiveSlotId(undefined); @@ -328,7 +366,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { timeStart: toLocalIso(parsedRange.fromMs), timeStop: toLocalIso(parsedRange.toMs), revSign: revSign || undefined, - cov: withCoverage, + // cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать + // покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов. + cov: auto, coverageStrategy }, ctrl.signal @@ -370,29 +410,34 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { const autoCover = () => void runCover(strategy); const cancelCalculation = () => abortRef.current?.abort(); - // Бронирует набор чанками (бэк бронирует слоты по одному в одной транзакции — крупный набор - // бьём на части, чтобы не упереться в таймаут/долгую транзакцию) и показывает прогресс. + // Любая операция с бронью «в полёте» — общий гейт блокировки кнопок (бронь / снятие выбранных / + // снятие всего). Отделён от busy (расчёт/автоподбор). + const opBusy = booking || cancelling || cancellingSel; + + // Бронирует набор чанками (бэк бронирует по одному в транзакции — крупный набор бьём на части + // через runChunked) и показывает прогресс. bookedSlotId берём из ответа POST. const performBook = async (toBook: SlotBookingRequest[]) => { setBooking(true); setError(undefined); setNotice(undefined); - setBookProgress(toBook.length > BOOK_CHUNK ? `0/${toBook.length}` : undefined); + setProgress(toBook.length > BOOK_CHUNK ? { label: "Бронирование", done: 0, total: toBook.length } : null); try { - const collected: BookedSlotDTO[] = []; - let done = 0; - for (const part of chunk(toBook, BOOK_CHUNK)) { - const booked = await bookRequestSlots(selectedRequestId, part); - collected.push(...booked); - done += part.length; - if (toBook.length > BOOK_CHUNK) setBookProgress(`${done}/${toBook.length}`); - } - // Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle - // рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле - // (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и - // бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты - // (с контуром), что вернул POST, не перечитывая by-request. - const bookedSet = new Set(collected.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`)); - const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot))); + const parts = await runChunked( + toBook, + BOOK_CHUNK, + (part) => bookRequestSlots(selectedRequestId, part), + (done, total) => setProgress({ label: "Бронирование", done, total }), + ); + const collected = parts.flat(); + // Источник истины — ответ POST: BookedSlotDTO несёт bookedSlotId и тот же cycle, что мы + // отправили (= cycle рассчитанного слота). Контур берём из рассчитанного слота по ключу; + // by-request не перечитываем. Так bookedSlots получает и контур, и bookedSlotId. + const idByKey = new Map( + collected.map((item) => [`${item.satelliteId}:${item.slotNum}:${item.cycle}`, item.bookedSlotId] as const), + ); + const justBooked: BookedSlotView[] = slots + .filter((slot) => idByKey.has(slotKey(slot))) + .map((slot) => ({ ...slot, bookedSlotId: idByKey.get(slotKey(slot))! })); setBookedSlots((prev) => { const merged = new Map(prev.map((slot) => [slotKey(slot), slot])); for (const slot of justBooked) merged.set(slotKey(slot), slot); @@ -404,17 +449,18 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты."); } finally { setBooking(false); - setBookProgress(undefined); + setProgress(null); } }; + // Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем). const collectSelected = (): SlotBookingRequest[] => slots - .filter((slot) => selectedKeys.has(slotKey(slot))) + .filter((slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot))) .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle })); const book = () => { - if (!selectedRequestId || booking || selectedKeys.size === 0) return; + if (!selectedRequestId || opBusy || selectedBookableCount === 0) return; const toBook = collectSelected(); if (toBook.length === 0) return; // Крупная бронь — сначала подтверждение, потом чанкованный POST. @@ -432,23 +478,65 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }; const cancelPendingBook = () => setPendingBook(null); - const cancelBooking = async () => { - if (!selectedRequestId || booking || bookedSlots.length === 0) return; - setBooking(true); + // Общий чанковый движок снятия: бьёт ids на части (request-aware batch), показывает прогресс + // (тот же бар, что у брони), затем убирает снятые из bookedSlots и из выбора. На него опираются + // и «Снять выбранные», и «Снять всё», и ✕ в строке — снятие везде пачками с прогрессом, а не + // одним bulk-запросом (по одному HTTP нельзя показать ход на 7000 слотов). setBusy — флаг кнопки. + const runCancel = async (ids: number[], setBusy: (value: boolean) => void) => { + setBusy(true); setError(undefined); setNotice(undefined); + setProgress(ids.length > BOOK_CHUNK ? { label: "Снятие брони", done: 0, total: ids.length } : null); try { - const cancelled = await cancelRequestBooking(selectedRequestId); - // Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()). - setBookedSlots([]); - setNotice(`Снято броней: ${cancelled}.`); + const counts = await runChunked( + ids, + BOOK_CHUNK, + (part) => cancelBookedSlots(selectedRequestId, part), + (done, total) => setProgress({ label: "Снятие брони", done, total }), + ); + const removed = counts.reduce((a, b) => a + b, 0); + const idSet = new Set(ids); + setSelectedKeys((prev) => { + const next = new Set(prev); + for (const slot of bookedSlots) if (idSet.has(slot.bookedSlotId)) next.delete(slotKey(slot)); + return next; + }); + setBookedSlots((prev) => prev.filter((slot) => !idSet.has(slot.bookedSlotId))); + setNotice(`Снято броней: ${removed}.`); } catch (caught) { setError(caught instanceof Error ? caught.message : "Не удалось снять бронь."); } finally { - setBooking(false); + setBusy(false); + setProgress(null); } }; + // Снять выбранные брони (по галочкам). Выбор явный — без подтверждения. + const cancelSelected = () => { + if (!selectedRequestId || opBusy || selectedBookedIds.length === 0) return; + void runCancel(selectedBookedIds, setCancellingSel); + }; + + // Снять одну бронь из строки таблицы — батч из одного id (обратимо ре-бронью, без подтверждения). + const cancelOne = (bookedSlotId: number) => { + if (!selectedRequestId || opBusy) return; + void runCancel([bookedSlotId], setCancellingSel); + }; + + // Снять ВСЕ брони заявки — тоже пачками (request-aware batch) с прогрессом. Необратимо → + // подтверждение. Отдельный флаг cancelling, чтобы «Снятие…» горело именно на этой кнопке. + const performCancelAll = () => runCancel(bookedSlots.map((slot) => slot.bookedSlotId), setCancelling); + + const cancelAll = () => { + if (!selectedRequestId || opBusy || bookedSlots.length === 0) return; + setPendingCancel(true); + }; + const confirmPendingCancel = () => { + setPendingCancel(false); + void performCancelAll(); + }; + const cancelPendingCancel = () => setPendingCancel(false); + // Отметка слота из таблицы. Shift+клик — выбрать диапазон от якоря (предыдущего клика) до // текущей строки; направление (выбрать/снять) задаётся новым состоянием текущей строки. const handleSlotCheck = (index: number, shiftKey: boolean) => { @@ -471,7 +559,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { anchorIndexRef.current = null; }; // Выбрать все слоты расчёта (по всем суткам), снять весь выбор. - const selectAllDays = () => setSelectedKeys(new Set(slots.map(slotKey))); + const selectAllDays = () => setSelectedKeys(new Set(displaySlots.map(slotKey))); const clearSelection = () => { setSelectedKeys(new Set()); anchorIndexRef.current = null; @@ -563,8 +651,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { }); } } - // Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно. - const slotKeysShown = new Set(slots.map(slotKey)); + // Брони вне текущего окна суток (их нет в filteredSlots) — показываем на карте отдельным слоем, + // чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше. + const slotKeysShown = new Set(filteredSlots.map(slotKey)); for (const slot of bookedSlots) { const key = slotKey(slot); if (slotKeysShown.has(key)) continue; @@ -679,7 +768,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { ? "Загрузка заявок…" : undefined : slots.length === 0 && bookedSlots.length === 0 - ? "Слоты не рассчитаны — нажмите «Рассчитать слоты»." + ? "Слоты не рассчитаны — нажмите «Показать все слоты» или «Подобрать покрытие»." : undefined; // Видимое окно строк виртуализатора и распорки сверху/снизу (держат суммарную высоту списка). @@ -723,19 +812,11 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) { - - -