feat(slots): снятие брони пачками с прогрессом (симметрично бронированию)
Бэк (slots-service): - DELETE /booking/batch (cancelReqSlots) — снятие выбранных броней пачкой; одиночный DELETE теперь request-aware (нужен requestId), чтобы шаренный между заявками слот рвал только связь своей заявки, а не сносил бронь. - BookedSlotViewDTO (+toViewDTO) с cycle БРОНИ — slotKey совпадает с ответом POST booking/request; GET booking/by-request отдаёт View. Фронт (pcp-tgu-ui-service): - runChunked + ChunkProgress: бронь и снятие идут пачками по 200 с общим прогресс-баром (стиль/позиция как у оверлея «Расчёт слотов…», сверху). - «Снять выбранные», «Снять всё» и ✕ в строке — единый чанковый движок runCancel (а не bulk-запрос), брони слиты в строки таблицы и выбираемы. Тесты: SlotServiceCancelTest (шаринг/единоличная/cycle-регрессия), runChunked, slotsApi.cancelBookedSlots/fetchBookedSlotsByRequest.
This commit is contained in:
@@ -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<SlotCoverageStrategy>("GREEDY");
|
||||
|
||||
// Результаты: слоты-кандидаты и уже забронированные слоты заявки.
|
||||
// Результаты: слоты-кандидаты и уже забронированные слоты заявки (с bookedSlotId для снятия по id).
|
||||
const [slots, setSlots] = useState<SlotDTO[]>([]);
|
||||
const [bookedSlots, setBookedSlots] = useState<SlotDTO[]>([]);
|
||||
const [bookedSlots, setBookedSlots] = useState<BookedSlotView[]>([]);
|
||||
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => 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<string>();
|
||||
// Снятие выбранных броней пачками (request-aware batch) — отдельный флаг «в полёте».
|
||||
const [cancellingSel, setCancellingSel] = useState(false);
|
||||
// Прогресс крупной чанковой операции (бронь / снятие выбранных) — общий бар <ChunkProgress>.
|
||||
const [progress, setProgress] = useState<{ label: string; done: number; total: number } | null>(null);
|
||||
const [pendingBook, setPendingBook] = useState<SlotBookingRequest[] | null>(null);
|
||||
// «Снять всё» убирает ВСЕ брони заявки — необратимо, поэтому всегда через подтверждение.
|
||||
// cancelling отделяет «снятие всего в полёте» от брони/снятия выбранных.
|
||||
const [pendingCancel, setPendingCancel] = useState(false);
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [notice, setNotice] = useState<string>();
|
||||
|
||||
@@ -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<string, BookedSlotView>();
|
||||
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<SlotDTO[]>(() => {
|
||||
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<SlotDay[]>(() => {
|
||||
const buckets = new Map<number, number>();
|
||||
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) {
|
||||
<option value="DESC">нисходящая</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="tgu-checkbox" title="Считать площадь покрытия (дольше)">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withCoverage}
|
||||
onChange={(event) => setWithCoverage(event.target.checked)}
|
||||
/>
|
||||
<span>Покрытие</span>
|
||||
</label>
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate}>
|
||||
{calculating ? "Расчёт…" : "Рассчитать слоты"}
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate} title="Показать весь набор слотов-кандидатов для ручного отбора">
|
||||
{calculating ? "Расчёт…" : "Показать все слоты"}
|
||||
</button>
|
||||
<label className="tgu-field" title="Стратегия автоформирования покрытия">
|
||||
<span>Автоподбор</span>
|
||||
<label className="tgu-field" title="Стратегия отбора покрытия для «Подобрать покрытие»">
|
||||
<span>Стратегия покрытия</span>
|
||||
<select
|
||||
className="tgu-select"
|
||||
value={strategy}
|
||||
@@ -746,28 +827,39 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
<option value="CONTINUOUS">непрерывный</option>
|
||||
</select>
|
||||
</label>
|
||||
<button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Сформировать покрытие автоматически и предложить под бронь">
|
||||
{autoFilling ? "Подбор…" : "Подобрать"}
|
||||
<button className="tgu-button" type="button" onClick={autoCover} disabled={!canCalculate} title="Отобрать достаточное покрытие по стратегии и предложить под бронь">
|
||||
{autoFilling ? "Подбор…" : "Подобрать покрытие"}
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button"
|
||||
type="button"
|
||||
onClick={book}
|
||||
disabled={booking || selectedKeys.size === 0}
|
||||
disabled={opBusy || selectedBookableCount === 0}
|
||||
title="Забронировать выбранные слоты-кандидаты"
|
||||
>
|
||||
{booking
|
||||
? bookProgress
|
||||
? `Бронь… ${bookProgress}`
|
||||
: "Бронь…"
|
||||
: `Забронировать${selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}`}
|
||||
? "Бронь…"
|
||||
: `Забронировать${selectedBookableCount > 0 ? ` (${selectedBookableCount})` : ""}`}
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button"
|
||||
type="button"
|
||||
onClick={cancelBooking}
|
||||
disabled={booking || bookedSlots.length === 0}
|
||||
onClick={cancelSelected}
|
||||
disabled={opBusy || selectedBookedIds.length === 0}
|
||||
title="Снять бронь с выбранных слотов (пачками)"
|
||||
>
|
||||
Снять бронь{bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}
|
||||
{cancellingSel
|
||||
? "Снятие…"
|
||||
: `Снять выбранные${selectedBookedIds.length > 0 ? ` (${selectedBookedIds.length})` : ""}`}
|
||||
</button>
|
||||
<button
|
||||
className="tgu-button"
|
||||
type="button"
|
||||
onClick={cancelAll}
|
||||
disabled={opBusy || bookedSlots.length === 0}
|
||||
title="Снять все брони заявки"
|
||||
>
|
||||
{cancelling ? "Снятие…" : `Снять всё${bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}`}
|
||||
</button>
|
||||
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
|
||||
{(slots.length > 0 || bookedSlots.length > 0) && (
|
||||
@@ -796,8 +888,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
className="tgu-button tgu-button--sm"
|
||||
type="button"
|
||||
onClick={selectAllDays}
|
||||
disabled={slots.length === 0}
|
||||
title="Выбрать все слоты расчёта (по всем суткам)"
|
||||
disabled={displaySlots.length === 0}
|
||||
title="Выбрать все строки (кандидаты и брони, по всем суткам)"
|
||||
>
|
||||
Все сутки
|
||||
</button>
|
||||
@@ -814,7 +906,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
<div className="tgu-slots-scroll" ref={listRef}>
|
||||
{filteredSlots.length === 0 ? (
|
||||
<div className="tgu-slots-empty">
|
||||
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
|
||||
{displaySlots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
|
||||
</div>
|
||||
) : (
|
||||
<table className="tgu-slots-table">
|
||||
@@ -875,7 +967,23 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
<td>{slot.roll.toFixed(1)}°</td>
|
||||
<td>
|
||||
{booked ? (
|
||||
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
|
||||
<span className="tgu-slots-status-cell">
|
||||
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
|
||||
<button
|
||||
type="button"
|
||||
className="tgu-slots-cancel-one"
|
||||
title="Снять бронь с этого слота"
|
||||
aria-label="Снять бронь с этого слота"
|
||||
disabled={opBusy}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const id = bookedByKey.get(key)?.bookedSlotId;
|
||||
if (id != null) cancelOne(id);
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={
|
||||
@@ -984,6 +1092,22 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{pendingCancel && (
|
||||
<div className="tgu-slots-confirm">
|
||||
<span>Снять все брони заявки ({bookedSlots.length})? Действие необратимо.</span>
|
||||
<div className="tgu-slots-confirm__actions">
|
||||
<button className="tgu-button tgu-button--primary tgu-button--sm" type="button" onClick={confirmPendingCancel}>
|
||||
Снять всё
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--sm" type="button" onClick={cancelPendingCancel}>
|
||||
Отмена
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{progress && (
|
||||
<ChunkProgress label={progress.label} done={progress.done} total={progress.total} />
|
||||
)}
|
||||
{hasRequest && (
|
||||
<button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
|
||||
Сброс
|
||||
|
||||
Reference in New Issue
Block a user