feat(tgu-ui): слоты — панель фильтров, показ всех слотов и статус «недоступен»

- Панель фильтров видимости (статус/КА/ветвь) над строкой выбора: единый
  filteredSlots сужает и таблицу, и карту; пустое состояние с «Сбросить».
- Убран потолок отрисовки карты — общую картину не режем, объём сужает оператор
  фильтрами.
- Карту не перецентрируем после расчёта (был прыжок на произвольный первый слот).
- «Показать все слоты» шлёт all=true и показывает занятые бронью (BUSY); бронь
  их не берёт (исключены из выбора/счётчика, чекбокс disabled).
- Починен мёртвый маппинг статуса: бэкенд шлёт BUSY, фронт ждал OCCUPIED.
- BUSY отображается как «недоступен» (конфликт с бронью), а не «занят» —
  чтобы не путать с самой бронью.
This commit is contained in:
Дмитрий Соловьев
2026-06-15 16:45:28 +03:00
parent 052ed5f04b
commit b38a97b9a5
8 changed files with 447 additions and 48 deletions
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
@@ -26,6 +26,8 @@ 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 { SlotFilterBar } from "./SlotFilterBar";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { selectionAfterCheck } from "./slotSelection";
import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress";
@@ -36,8 +38,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
const BOOK_CHUNK = 200;
/** Выше этого числа слотов бронь требует подтверждения. */
const BOOK_CONFIRM_THRESHOLD = 500;
/** Выше этого числа полигонов в окне суток показываем подсказку «сузьте сутки». */
const MAP_HINT_THRESHOLD = 800;
/** Оценка высоты строки таблицы (px) для виртуализатора. */
const ROW_HEIGHT = 34;
@@ -94,6 +94,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const [dayFrom, setDayFrom] = useState(1);
const [dayTo, setDayTo] = useState(1);
// Фильтры видимости (статус/КА/ветвь). Единая модель «selected = visible»: множество = что
// видно, пусто = ничего. Дефолт — всё видно; satFilter инициализируется всеми КА при расчёте.
const [statusFilter, setStatusFilter] = useState<ReadonlySet<SlotStatusKey>>(
() => new Set<SlotStatusKey>(["free", "booked", "occupied"])
);
const [satFilter, setSatFilter] = useState<ReadonlySet<number>>(() => new Set());
const [branchFilter, setBranchFilter] = useState<ReadonlySet<RevolutionSign>>(
() => new Set<RevolutionSign>(["ASC", "DESC"])
);
const [calculating, setCalculating] = useState(false);
const [autoFilling, setAutoFilling] = useState(false);
const [booking, setBooking] = useState(false);
@@ -191,9 +201,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}, [selectedKeys, bookedByKey]);
const selectedBookableCount = useMemo(() => {
let n = 0;
for (const key of selectedKeys) if (!bookedKeys.has(key)) n += 1;
for (const key of selectedKeys) {
const slot = slotByKey.get(key);
if (!bookedKeys.has(key) && !(slot && slotIsBusy(slot))) n += 1;
}
return n;
}, [selectedKeys, bookedKeys]);
}, [selectedKeys, bookedKeys, slotByKey]);
// Сутки, по которым раскладываются строки таблицы (для фильтра отображения).
const days = useMemo<SlotDay[]>(() => {
@@ -218,8 +231,8 @@ 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(() => {
// Слоты в окне суток (до фильтров видимости) — база для счётчиков статусов и фильтрации.
const dayWindowed = useMemo(() => {
if (days.length === 0) return displaySlots;
const fromMs = days[effDayFrom - 1].dayMs;
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
@@ -229,6 +242,40 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
});
}, [displaySlots, days, effDayFrom, effDayTo]);
// Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов.
const statusCounts = useMemo<Record<SlotStatusKey, number>>(() => {
const counts: Record<SlotStatusKey, number> = { free: 0, booked: 0, occupied: 0 };
for (const slot of dayWindowed) counts[slotStatusKey(slot, bookedKeys.has(slotKey(slot)))] += 1;
return counts;
}, [dayWindowed, bookedKeys]);
// КА в наборе (для чипов фильтра); сигнатура — чтобы инициализировать satFilter только при
// смене набора КА (новый расчёт), а не при каждой брони.
const satellites = useMemo(() => {
const ids = new Set<number>();
for (const slot of displaySlots) ids.add(slot.satelliteId);
return Array.from(ids).sort((a, b) => a - b);
}, [displaySlots]);
const satSignature = satellites.join(",");
// Новый набор КА → показываем все КА (бронирование набор КА не меняет, поэтому не сбрасываем).
// useLayoutEffect — чтобы satFilter раскрылся ДО отрисовки и не мигнул пустой таблицей.
useLayoutEffect(() => {
setSatFilter(new Set(satellites));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [satSignature]);
// Слоты, видимые в таблице и на карте: окно суток + фильтры видимости (статус/КА/ветвь).
const filteredSlots = useMemo(
() =>
applySlotFilters(
dayWindowed,
{ status: statusFilter, satellites: satFilter, branches: branchFilter },
(slot) => bookedKeys.has(slotKey(slot)),
),
[dayWindowed, statusFilter, satFilter, branchFilter, bookedKeys],
);
// Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
const rowVirtualizer = useVirtualizer({
@@ -320,6 +367,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setNotice(undefined);
setError(undefined);
setActiveSlotId(undefined);
// Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект).
setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
if (selectedRequestId) void loadBooked(selectedRequestId);
}, [selectedRequestId, loadBooked]);
@@ -367,8 +417,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
timeStop: toLocalIso(parsedRange.toMs),
revSign: revSign || undefined,
// cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать
// покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов.
// покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов,
// включая занятые бронью (all=true) — занятые показываем для обзора, бронировать нельзя.
cov: auto,
all: !auto,
coverageStrategy
},
ctrl.signal
@@ -391,9 +443,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
: "Для выбранного интервала слотов покрытия не найдено."
);
}
if (result.length > 0) {
focusOn({ lat: result[0].latitude, lon: result[0].longitude });
}
// Карту НЕ перецентрируем: result[0] — первый по времени/витку, географически произвольный
// угол, а зум до 4.5 спрятал бы остальной набор. Оставляем общий обзор заявки на месте;
// прицельный фокус — по клику на строку таблицы (focusOn в onRowClick).
} catch (caught) {
// Отмена (повтор/смена заявки) — это не ошибка, молча выходим.
if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return;
@@ -456,7 +508,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем).
const collectSelected = (): SlotBookingRequest[] =>
slots
.filter((slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot)))
.filter(
(slot) => selectedKeys.has(slotKey(slot)) && !bookedKeys.has(slotKey(slot)) && !slotIsBusy(slot),
)
.map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
const book = () => {
@@ -546,25 +600,47 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
anchorIndexRef.current = index;
};
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты.
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
// бронью) бронировать нельзя — в массовый выбор не берём.
const selectableVisible = useMemo(() => filteredSlots.filter((slot) => !slotIsBusy(slot)), [filteredSlots]);
const allVisibleSelected =
filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot)));
selectableVisible.length > 0 && selectableVisible.every((slot) => selectedKeys.has(slotKey(slot)));
const toggleAllVisible = () => {
setSelectedKeys((current) => {
const next = new Set(current);
if (allVisibleSelected) for (const slot of filteredSlots) next.delete(slotKey(slot));
else for (const slot of filteredSlots) 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));
return next;
});
anchorIndexRef.current = null;
};
// Выбрать все слоты расчёта (по всем суткам), снять весь выбор.
const selectAllDays = () => setSelectedKeys(new Set(displaySlots.map(slotKey)));
// Выбрать все слоты расчёта (по всем суткам, кроме занятых бронью), снять весь выбор.
const selectAllDays = () =>
setSelectedKeys(new Set(displaySlots.filter((slot) => !slotIsBusy(slot)).map(slotKey)));
const clearSelection = () => {
setSelectedKeys(new Set());
anchorIndexRef.current = null;
};
// Тогглы фильтров видимости (selected = visible: клик добавляет/убирает значение из множества).
const toggleInSet = <T,>(set: ReadonlySet<T>, value: T): Set<T> => {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
};
const toggleStatus = (key: SlotStatusKey) => setStatusFilter((prev) => toggleInSet(prev, key));
const toggleSat = (id: number) => setSatFilter((prev) => toggleInSet(prev, id));
const toggleBranch = (branch: RevolutionSign) => setBranchFilter((prev) => toggleInSet(prev, branch));
// Фильтры в дефолте — всё видно (на всех трёх осях выбраны все значения).
const filtersDefault =
statusFilter.size === 3 && branchFilter.size === 2 && satFilter.size === satellites.length;
const resetFilters = () => {
setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
setSatFilter(new Set(satellites));
};
// Сколько выбрано из видимых суток и сколько — на других сутках (для прозрачности охвата).
const visibleSelectedCount = useMemo(
() => filteredSlots.reduce((n, slot) => (selectedKeys.has(slotKey(slot)) ? n + 1 : n), 0),
@@ -635,6 +711,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
});
}
}
// Рисуем все отфильтрованные слоты без потолка: общую картину не режем, объём сужает
// оператор фильтрами (статус/КА/ветвь/сутки). Таблица виртуализирована, карта — canvas.
for (const slot of filteredSlots) {
const key = slotKey(slot);
for (const ring of ringsFor(key, slot.contour)) {
@@ -776,8 +854,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const virtualTotal = rowVirtualizer.getTotalSize();
const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0;
// Слишком много полигонов в окне суток — подсказываем сузить (карту не блокируем).
const mapOverloaded = filteredSlots.length > MAP_HINT_THRESHOLD;
return (
<main className="tgu-map-workspace">
@@ -873,6 +949,20 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<section className="tgu-slots-list">
{hasRequest ? (
<>
{displaySlots.length > 0 && (
<SlotFilterBar
statusCounts={statusCounts}
statusFilter={statusFilter}
onToggleStatus={toggleStatus}
satellites={satellites}
satFilter={satFilter}
onToggleSat={toggleSat}
branchFilter={branchFilter}
onToggleBranch={toggleBranch}
isDefault={filtersDefault}
onReset={resetFilters}
/>
)}
<div className="tgu-slots-selbar">
<span className="tgu-slots-selbar__count">
Выбрано: {selectedKeys.size}
@@ -906,7 +996,20 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<div className="tgu-slots-scroll" ref={listRef}>
{filteredSlots.length === 0 ? (
<div className="tgu-slots-empty">
{displaySlots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
{displaySlots.length === 0 ? (
"Слоты не рассчитаны."
) : dayWindowed.length === 0 ? (
"На выбранные сутки слотов нет."
) : (
<>
Слоты скрыты фильтром включите статус/КА или сбросьте фильтры.
<div className="tgu-slots-empty__action">
<button type="button" className="tgu-button tgu-button--sm" onClick={resetFilters}>
Сбросить фильтры
</button>
</div>
</>
)}
</div>
) : (
<table className="tgu-slots-table">
@@ -954,6 +1057,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
checked={selectedKeys.has(key)}
onChange={() => {}}
onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)}
disabled={slotIsBusy(slot)}
title={slotIsBusy(slot) ? "Слот недоступен — конфликт с бронью, бронировать нельзя" : undefined}
aria-label="Выбрать слот"
/>
</td>
@@ -987,7 +1092,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
) : (
<span
className={
slot.state === "OCCUPIED"
slot.state === "BUSY"
? "tgu-slots-badge tgu-slots-badge--occupied"
: "tgu-slots-badge"
}
@@ -1113,11 +1218,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
Сброс
</button>
)}
{hasRequest && mapOverloaded && (
<div className="tgu-slots-map-hint">
Много слотов в окне ({filteredSlots.length}) сузьте сутки для удобной карты.
</div>
)}
{hasRequest && days.length > 0 && (
<SlotDaySelector
days={days}
@@ -1174,14 +1274,20 @@ function slotDurationSec(slot: SlotDTO): number {
return Math.max(0, Math.round((parseLocal(slot.tk) - parseLocal(slot.tn)) / 1000));
}
/** Слот занят бронью на этом проходе КА (state BUSY): показываем для обзора, но забронировать
* нельзя — исключаем из выбора под бронь. Бронь самой заявки приходит как BOOKED, не BUSY. */
function slotIsBusy(slot: Pick<SlotDTO, "state">): boolean {
return slot.state === "BUSY";
}
function slotStateLabel(state: string): string {
switch (state) {
case "AVAILABLE":
return "свободен";
case "BOOKED":
return "бронь";
case "OCCUPIED":
return "занят";
case "BUSY":
return "недоступен";
default:
return state || "—";
}