feat(tgu-slots): подтверждение брони всегда + «Снять всё» снимает всю заявку

- Бронь теперь всегда через диалог подтверждения (симметрично «Снять всё»),
  убран порог в 500 слотов.
- «Снять всё» дочитывает брони заявки через by-request и снимает объединение
  локального и серверного наборов bookedSlotId — гарантированно вся заявка.
- Карта: забронированные слоты вне окна суток подчинены фильтрам видимости
  (статус «бронь»/КА/ветвь) — при выключенном фильтре брони их не видно.
This commit is contained in:
Дмитрий Соловьев
2026-06-15 17:22:35 +03:00
parent 3c94815ffc
commit 5869994690
@@ -36,8 +36,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
/** Размер чанка при бронировании больших наборов (бэк бронирует слоты по одному). */
const BOOK_CHUNK = 200;
/** Выше этого числа слотов бронь требует подтверждения. */
const BOOK_CONFIRM_THRESHOLD = 500;
/** Оценка высоты строки таблицы (px) для виртуализатора. */
const ROW_HEIGHT = 34;
@@ -517,12 +515,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
if (!selectedRequestId || opBusy || selectedBookableCount === 0) return;
const toBook = collectSelected();
if (toBook.length === 0) return;
// Крупная бронь — сначала подтверждение, потом чанкованный POST.
if (toBook.length > BOOK_CONFIRM_THRESHOLD) {
setPendingBook(toBook);
return;
}
void performBook(toBook);
// Бронь — всегда через подтверждение (симметрично «Снять всё»), затем чанкованный POST.
setPendingBook(toBook);
};
const confirmPendingBook = () => {
@@ -579,7 +573,19 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Снять ВСЕ брони заявки — тоже пачками (request-aware batch) с прогрессом. Необратимо →
// подтверждение. Отдельный флаг cancelling, чтобы «Снятие…» горело именно на этой кнопке.
const performCancelAll = () => runCancel(bookedSlots.map((slot) => slot.bookedSlotId), setCancelling);
// Источник истины — backend: перечитываем брони by-request и снимаем ОБЪЕДИНЕНИЕ локального
// набора и серверного. Так «Снять всё» убирает вообще все брони заявки, даже если локальный
// bookedSlots разошёлся с базой (bookedSlotId надёжен независимо от cycle-фрейма брони).
const performCancelAll = async () => {
const idSet = new Set(bookedSlots.map((slot) => slot.bookedSlotId));
try {
const fresh = await fetchBookedSlotsByRequest(selectedRequestId);
for (const slot of fresh) idSet.add(slot.bookedSlotId);
} catch {
// by-request недоступен — снимаем по локальному набору броней.
}
await runCancel(Array.from(idSet), setCancelling);
};
const cancelAll = () => {
if (!selectedRequestId || opBusy || bookedSlots.length === 0) return;
@@ -730,27 +736,45 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}
}
// Брони вне текущего окна суток (их нет в filteredSlots) — показываем на карте отдельным слоем,
// чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше.
const slotKeysShown = new Set(filteredSlots.map(slotKey));
for (const slot of bookedSlots) {
const key = slotKey(slot);
if (slotKeysShown.has(key)) continue;
for (const ring of ringsFor(key, slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `booked:${key}`,
name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`,
kind: "complexPlan",
layer: "complexPlan",
points: ring,
spacecraftId: String(slot.satelliteId),
revolution: slot.revolution,
zIndex: z++
});
// чтобы оператор видел все брони заявки. Те, что в окне, уже нарисованы циклом выше. НО слой
// подчиняем фильтрам видимости: выключен статус «бронь» (или скрыт КА/ветвь) — бронь не рисуем,
// иначе фильтр убирал бы бронь из таблицы, но не с карты.
if (statusFilter.has("booked")) {
const slotKeysShown = new Set(filteredSlots.map(slotKey));
for (const slot of bookedSlots) {
const key = slotKey(slot);
if (slotKeysShown.has(key)) continue;
if (!satFilter.has(slot.satelliteId)) continue;
if (!branchFilter.has(slot.revolutionSign)) continue;
for (const ring of ringsFor(key, slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `booked:${key}`,
name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`,
kind: "complexPlan",
layer: "complexPlan",
points: ring,
spacecraftId: String(slot.satelliteId),
revolution: slot.revolution,
zIndex: z++
});
}
}
}
return { polygons, lines: [], markers: [] };
}, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys, ringsFor]);
}, [
hasRequest,
requestItems,
selectedRequest,
slots,
filteredSlots,
bookedSlots,
bookedKeys,
ringsFor,
statusFilter,
satFilter,
branchFilter
]);
const layers = useMemo<Tgu2DMapLayersState>(
() => ({
@@ -1186,7 +1210,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
)}
{pendingBook && (
<div className="tgu-slots-confirm">
<span>Забронировать {pendingBook.length} слотов? Это крупная бронь.</span>
<span>Забронировать выбранные слоты ({pendingBook.length})?</span>
<div className="tgu-slots-confirm__actions">
<button className="tgu-button tgu-button--primary tgu-button--sm" type="button" onClick={confirmPendingBook}>
Забронировать