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
@@ -0,0 +1,109 @@
import type { RevolutionSign } from "../../model/slotTypes";
import type { SlotStatusKey } from "./slotFilters";
type SlotFilterBarProps = {
/** Счётчики статусов по окну суток (до статус-фильтра) — стабильны при переключении чипов. */
statusCounts: Record<SlotStatusKey, number>;
statusFilter: ReadonlySet<SlotStatusKey>;
onToggleStatus: (key: SlotStatusKey) => void;
/** Список КА в наборе (отсортирован). Чип КА не показываем, если спутник один. */
satellites: number[];
satFilter: ReadonlySet<number>;
onToggleSat: (id: number) => void;
branchFilter: ReadonlySet<RevolutionSign>;
onToggleBranch: (branch: RevolutionSign) => void;
/** Фильтры в дефолте (всё видно) — тогда «Сбросить» не показываем. */
isDefault: boolean;
onReset: () => void;
};
const STATUS_META: { key: SlotStatusKey; label: string; variant: string }[] = [
{ key: "free", label: "свободен", variant: "" },
{ key: "booked", label: "бронь", variant: " is-booked" },
{ key: "occupied", label: "недоступен", variant: " is-occupied" },
];
/**
* Панель фильтров отображения слотов (видимость). Сужает И таблицу, И карту через единый
* filteredSlots. Стоит над строкой выбора: сначала сужаешь, потом отбираешь. Чипы —
* настоящие кнопки aria-pressed; цвет статус-чипа совпадает с бейджем статуса в таблице,
* на каждом текст-метка (не только цвет — дальтоники).
*/
export function SlotFilterBar({
statusCounts,
statusFilter,
onToggleStatus,
satellites,
satFilter,
onToggleSat,
branchFilter,
onToggleBranch,
isDefault,
onReset,
}: SlotFilterBarProps) {
return (
<div className="tgu-slots-filterbar">
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">Статус</span>
{STATUS_META.map(({ key, label, variant }) => {
const active = statusFilter.has(key);
return (
<button
key={key}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${variant}${active ? " is-active" : ""}`}
onClick={() => onToggleStatus(key)}
>
{label} {statusCounts[key]}
</button>
);
})}
</div>
{satellites.length > 1 && (
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">КА</span>
{satellites.map((id) => {
const active = satFilter.has(id);
return (
<button
key={id}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${active ? " is-active" : ""}`}
onClick={() => onToggleSat(id)}
>
{id}
</button>
);
})}
</div>
)}
<div className="tgu-slots-filterbar__group">
<span className="tgu-slots-filterbar__label">Ветвь</span>
{(["ASC", "DESC"] as RevolutionSign[]).map((branch) => {
const active = branchFilter.has(branch);
return (
<button
key={branch}
type="button"
aria-pressed={active}
className={`tgu-slots-chip${active ? " is-active" : ""}`}
onClick={() => onToggleBranch(branch)}
>
{branch === "ASC" ? "восх" : "нисх"}
</button>
);
})}
</div>
{!isDefault && (
<button type="button" className="tgu-button tgu-button--sm tgu-slots-filterbar__reset" onClick={onReset}>
Сбросить фильтры
</button>
)}
</div>
);
}
@@ -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 || "—";
}
@@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import type { SlotDTO } from "../../model/slotTypes";
import { applySlotFilters, slotStatusKey, type SlotFilterState } from "./slotFilters";
function slot(over: Partial<SlotDTO>): SlotDTO {
return {
cycle: 1,
satelliteId: 2,
tn: "2026-06-15T10:00:00",
tk: "2026-06-15T10:00:10",
roll: 30,
contour: "POLYGON EMPTY",
revolution: 281,
revolutionSign: "ASC",
slotNumber: 1,
state: "AVAILABLE",
latitude: 55,
longitude: 37,
...over,
};
}
const allStatuses = new Set(["free", "booked", "occupied"] as const);
const allBranches = new Set(["ASC", "DESC"] as const);
describe("slotStatusKey", () => {
it("бронь важнее state (booked, даже если state=AVAILABLE)", () => {
expect(slotStatusKey({ state: "AVAILABLE" }, true)).toBe("booked");
});
it("BUSY → occupied, когда не бронь", () => {
expect(slotStatusKey({ state: "BUSY" }, false)).toBe("occupied");
});
it("AVAILABLE → free", () => {
expect(slotStatusKey({ state: "AVAILABLE" }, false)).toBe("free");
});
});
describe("applySlotFilters", () => {
const rows = [
slot({ slotNumber: 1, satelliteId: 2, revolutionSign: "ASC", state: "AVAILABLE" }),
slot({ slotNumber: 2, satelliteId: 2, revolutionSign: "DESC", state: "BUSY" }),
slot({ slotNumber: 3, satelliteId: 5, revolutionSign: "ASC", state: "AVAILABLE" }),
];
const booked = new Set([1]); // слот №1 — бронь
const isBooked = (s: SlotDTO) => booked.has(s.slotNumber);
const base: SlotFilterState = {
status: allStatuses,
satellites: new Set([2, 5]),
branches: allBranches,
};
it("дефолт (все оси) пропускает всё", () => {
expect(applySlotFilters(rows, base, isBooked)).toHaveLength(3);
});
it("статус free убирает бронь и занятый", () => {
const out = applySlotFilters(rows, { ...base, status: new Set(["free"]) }, isBooked);
expect(out.map((s) => s.slotNumber)).toEqual([3]);
});
it("фильтр по КА оставляет только выбранный спутник", () => {
const out = applySlotFilters(rows, { ...base, satellites: new Set([5]) }, isBooked);
expect(out.map((s) => s.slotNumber)).toEqual([3]);
});
it("ветвь ASC оставляет только восходящие", () => {
const out = applySlotFilters(rows, { ...base, branches: new Set(["ASC"]) }, isBooked);
expect(out.map((s) => s.slotNumber)).toEqual([1, 3]);
});
it("снятие последней галочки оси (пустое множество) → ничего не видно", () => {
expect(applySlotFilters(rows, { ...base, status: new Set() }, isBooked)).toHaveLength(0);
expect(applySlotFilters(rows, { ...base, satellites: new Set() }, isBooked)).toHaveLength(0);
});
});
@@ -0,0 +1,46 @@
// Чистая логика фильтрации отображения слотов — выделена из TguSlotsTab, чтобы покрыть
// юнит-тестами без рендера компонента. Едина для таблицы и карты: обе читают результат
// applySlotFilters (filteredSlots).
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes"
/** Производный статус слота для фильтра видимости. */
export type SlotStatusKey = "free" | "booked" | "occupied"
/** Состояние фильтров отображения. Единая модель «selected = visible» на всех осях:
* множество = что ВИДНО; пустое множество = не видно ничего (одинаково для всех осей). */
export type SlotFilterState = {
status: ReadonlySet<SlotStatusKey>
satellites: ReadonlySet<number>
branches: ReadonlySet<RevolutionSign>
}
/**
* Производный статус слота. Бронь определяется снаружи (isBooked), а не по slot.state,
* потому что свежезабронированный кандидат приходит со state===AVAILABLE до перечитывания
* (бронь важнее state). Иначе BUSY (занят бронью на проходе КА) → occupied, остальное → free.
*/
export function slotStatusKey(slot: Pick<SlotDTO, "state">, isBooked: boolean): SlotStatusKey {
if (isBooked) return "booked"
if (slot.state === "BUSY") return "occupied"
return "free"
}
/**
* Фильтрует слоты по статусу/КА/ветви одним проходом (selected=visible на каждой оси).
* @param rows Уже суженные по окну суток слоты.
* @param filters Множества видимых значений по каждой оси.
* @param isBooked Признак брони по слоту (bookedKeys в компоненте).
*/
export function applySlotFilters(
rows: readonly SlotDTO[],
filters: SlotFilterState,
isBooked: (slot: SlotDTO) => boolean,
): SlotDTO[] {
return rows.filter(
(slot) =>
filters.status.has(slotStatusKey(slot, isBooked(slot))) &&
filters.satellites.has(slot.satelliteId) &&
filters.branches.has(slot.revolutionSign),
)
}