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
@@ -36,6 +36,18 @@ describe("fetchRequestSlots", () => {
expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy"); expect(String(fetchMock.mock.calls[0][0])).not.toContain("coverageStrategy");
}); });
it("прокидывает all=true в query (показать и занятые бронью)", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots({ ...baseParams, all: true });
expect(String(fetchMock.mock.calls[0][0])).toContain("all=true");
});
it("не добавляет all, если не задан", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
await fetchRequestSlots(baseParams);
expect(String(fetchMock.mock.calls[0][0])).not.toContain("all=");
});
it("пробрасывает AbortSignal в fetch", async () => { it("пробрасывает AbortSignal в fetch", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([])); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
const ctrl = new AbortController(); const ctrl = new AbortController();
@@ -46,6 +46,7 @@ export async function fetchRequestSlots(
if (params.cov != null) query.set('cov', String(params.cov)) if (params.cov != null) query.set('cov', String(params.cov))
if (params.sun != null) query.set('sun', String(params.sun)) if (params.sun != null) query.set('sun', String(params.sun))
if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy) if (params.coverageStrategy) query.set('coverageStrategy', params.coverageStrategy)
if (params.all != null) query.set('all', String(params.all))
params.satellites?.forEach((id) => query.append('satellites', String(id))) params.satellites?.forEach((id) => query.append('satellites', String(id)))
const response = await apiFetch(`${BASE}/request-cover?${query}`, { const response = await apiFetch(`${BASE}/request-cover?${query}`, {
@@ -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 { useVirtualizer } from "@tanstack/react-virtual";
import { fetchRequestsForMap } from "../../api/requestApi"; import { fetchRequestsForMap } from "../../api/requestApi";
import { import {
@@ -26,6 +26,8 @@ import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatLocalDateTimeSeconds, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector"; import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu"; import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
import { SlotFilterBar } from "./SlotFilterBar";
import { applySlotFilters, slotStatusKey, type SlotStatusKey } from "./slotFilters";
import { selectionAfterCheck } from "./slotSelection"; import { selectionAfterCheck } from "./slotSelection";
import { runChunked } from "./runChunked"; import { runChunked } from "./runChunked";
import { ChunkProgress } from "./ChunkProgress"; import { ChunkProgress } from "./ChunkProgress";
@@ -36,8 +38,6 @@ const DAY_MS = 24 * 60 * 60 * 1000;
const BOOK_CHUNK = 200; const BOOK_CHUNK = 200;
/** Выше этого числа слотов бронь требует подтверждения. */ /** Выше этого числа слотов бронь требует подтверждения. */
const BOOK_CONFIRM_THRESHOLD = 500; const BOOK_CONFIRM_THRESHOLD = 500;
/** Выше этого числа полигонов в окне суток показываем подсказку «сузьте сутки». */
const MAP_HINT_THRESHOLD = 800;
/** Оценка высоты строки таблицы (px) для виртуализатора. */ /** Оценка высоты строки таблицы (px) для виртуализатора. */
const ROW_HEIGHT = 34; const ROW_HEIGHT = 34;
@@ -94,6 +94,16 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const [dayFrom, setDayFrom] = useState(1); const [dayFrom, setDayFrom] = useState(1);
const [dayTo, setDayTo] = 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 [calculating, setCalculating] = useState(false);
const [autoFilling, setAutoFilling] = useState(false); const [autoFilling, setAutoFilling] = useState(false);
const [booking, setBooking] = useState(false); const [booking, setBooking] = useState(false);
@@ -191,9 +201,12 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}, [selectedKeys, bookedByKey]); }, [selectedKeys, bookedByKey]);
const selectedBookableCount = useMemo(() => { const selectedBookableCount = useMemo(() => {
let n = 0; 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; return n;
}, [selectedKeys, bookedKeys]); }, [selectedKeys, bookedKeys, slotByKey]);
// Сутки, по которым раскладываются строки таблицы (для фильтра отображения). // Сутки, по которым раскладываются строки таблицы (для фильтра отображения).
const days = useMemo<SlotDay[]>(() => { 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 effDayFrom = Math.max(1, Math.min(dayFrom, days.length || 1));
const effDayTo = Math.max(effDayFrom, Math.min(dayTo, 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; if (days.length === 0) return displaySlots;
const fromMs = days[effDayFrom - 1].dayMs; const fromMs = days[effDayFrom - 1].dayMs;
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS; const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
@@ -229,6 +242,40 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
}); });
}, [displaySlots, days, effDayFrom, effDayTo]); }, [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), а не все слоты — // Виртуализация таблицы: в DOM держим только видимое окно строк (+overscan), а не все слоты —
// иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef). // иначе на тысячах строк рендер встаёт. Скролл-контейнер — секция списка (listRef).
const rowVirtualizer = useVirtualizer({ const rowVirtualizer = useVirtualizer({
@@ -320,6 +367,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
setNotice(undefined); setNotice(undefined);
setError(undefined); setError(undefined);
setActiveSlotId(undefined); setActiveSlotId(undefined);
// Смена заявки — фильтры видимости в дефолт (satFilter раскроется через satSignature-эффект).
setStatusFilter(new Set<SlotStatusKey>(["free", "booked", "occupied"]));
setBranchFilter(new Set<RevolutionSign>(["ASC", "DESC"]));
if (selectedRequestId) void loadBooked(selectedRequestId); if (selectedRequestId) void loadBooked(selectedRequestId);
}, [selectedRequestId, loadBooked]); }, [selectedRequestId, loadBooked]);
@@ -367,8 +417,10 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
timeStop: toLocalIso(parsedRange.toMs), timeStop: toLocalIso(parsedRange.toMs),
revSign: revSign || undefined, revSign: revSign || undefined,
// cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать // cov=true (режим покрытия) ровно когда задана стратегия — это путь «Подобрать
// покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов. // покрытие». «Показать все слоты» идёт без стратегии и получает полный набор кандидатов,
// включая занятые бронью (all=true) — занятые показываем для обзора, бронировать нельзя.
cov: auto, cov: auto,
all: !auto,
coverageStrategy coverageStrategy
}, },
ctrl.signal ctrl.signal
@@ -391,9 +443,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
: "Для выбранного интервала слотов покрытия не найдено." : "Для выбранного интервала слотов покрытия не найдено."
); );
} }
if (result.length > 0) { // Карту НЕ перецентрируем: result[0] — первый по времени/витку, географически произвольный
focusOn({ lat: result[0].latitude, lon: result[0].longitude }); // угол, а зум до 4.5 спрятал бы остальной набор. Оставляем общий обзор заявки на месте;
} // прицельный фокус — по клику на строку таблицы (focusOn в onRowClick).
} catch (caught) { } catch (caught) {
// Отмена (повтор/смена заявки) — это не ошибка, молча выходим. // Отмена (повтор/смена заявки) — это не ошибка, молча выходим.
if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return; if (ctrl.signal.aborted || (caught instanceof DOMException && caught.name === "AbortError")) return;
@@ -456,7 +508,9 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем). // Выбранные кандидаты под бронь (без уже забронированных — их бронировать незачем).
const collectSelected = (): SlotBookingRequest[] => const collectSelected = (): SlotBookingRequest[] =>
slots 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 })); .map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
const book = () => { const book = () => {
@@ -546,25 +600,47 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
anchorIndexRef.current = index; anchorIndexRef.current = index;
}; };
// Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) слоты. // Чекбокс шапки — выбрать/снять все ВИДИМЫЕ (по окну суток) выбираемые слоты. BUSY (занятые
// бронью) бронировать нельзя — в массовый выбор не берём.
const selectableVisible = useMemo(() => filteredSlots.filter((slot) => !slotIsBusy(slot)), [filteredSlots]);
const allVisibleSelected = const allVisibleSelected =
filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot))); selectableVisible.length > 0 && selectableVisible.every((slot) => selectedKeys.has(slotKey(slot)));
const toggleAllVisible = () => { const toggleAllVisible = () => {
setSelectedKeys((current) => { setSelectedKeys((current) => {
const next = new Set(current); const next = new Set(current);
if (allVisibleSelected) for (const slot of filteredSlots) next.delete(slotKey(slot)); if (allVisibleSelected) for (const slot of selectableVisible) next.delete(slotKey(slot));
else for (const slot of filteredSlots) next.add(slotKey(slot)); else for (const slot of selectableVisible) next.add(slotKey(slot));
return next; return next;
}); });
anchorIndexRef.current = null; 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 = () => { const clearSelection = () => {
setSelectedKeys(new Set()); setSelectedKeys(new Set());
anchorIndexRef.current = null; 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( const visibleSelectedCount = useMemo(
() => filteredSlots.reduce((n, slot) => (selectedKeys.has(slotKey(slot)) ? n + 1 : n), 0), () => 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) { for (const slot of filteredSlots) {
const key = slotKey(slot); const key = slotKey(slot);
for (const ring of ringsFor(key, slot.contour)) { for (const ring of ringsFor(key, slot.contour)) {
@@ -776,8 +854,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
const virtualTotal = rowVirtualizer.getTotalSize(); const virtualTotal = rowVirtualizer.getTotalSize();
const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0; const padTop = virtualRows.length > 0 ? virtualRows[0].start : 0;
const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0; const padBottom = virtualRows.length > 0 ? virtualTotal - virtualRows[virtualRows.length - 1].end : 0;
// Слишком много полигонов в окне суток — подсказываем сузить (карту не блокируем).
const mapOverloaded = filteredSlots.length > MAP_HINT_THRESHOLD;
return ( return (
<main className="tgu-map-workspace"> <main className="tgu-map-workspace">
@@ -873,6 +949,20 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<section className="tgu-slots-list"> <section className="tgu-slots-list">
{hasRequest ? ( {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"> <div className="tgu-slots-selbar">
<span className="tgu-slots-selbar__count"> <span className="tgu-slots-selbar__count">
Выбрано: {selectedKeys.size} Выбрано: {selectedKeys.size}
@@ -906,7 +996,20 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
<div className="tgu-slots-scroll" ref={listRef}> <div className="tgu-slots-scroll" ref={listRef}>
{filteredSlots.length === 0 ? ( {filteredSlots.length === 0 ? (
<div className="tgu-slots-empty"> <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> </div>
) : ( ) : (
<table className="tgu-slots-table"> <table className="tgu-slots-table">
@@ -954,6 +1057,8 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
checked={selectedKeys.has(key)} checked={selectedKeys.has(key)}
onChange={() => {}} onChange={() => {}}
onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)} onClick={(event) => handleSlotCheck(vrow.index, event.shiftKey)}
disabled={slotIsBusy(slot)}
title={slotIsBusy(slot) ? "Слот недоступен — конфликт с бронью, бронировать нельзя" : undefined}
aria-label="Выбрать слот" aria-label="Выбрать слот"
/> />
</td> </td>
@@ -987,7 +1092,7 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
) : ( ) : (
<span <span
className={ className={
slot.state === "OCCUPIED" slot.state === "BUSY"
? "tgu-slots-badge tgu-slots-badge--occupied" ? "tgu-slots-badge tgu-slots-badge--occupied"
: "tgu-slots-badge" : "tgu-slots-badge"
} }
@@ -1113,11 +1218,6 @@ export function TguSlotsTab({ range }: TguSlotsTabProps) {
Сброс Сброс
</button> </button>
)} )}
{hasRequest && mapOverloaded && (
<div className="tgu-slots-map-hint">
Много слотов в окне ({filteredSlots.length}) сузьте сутки для удобной карты.
</div>
)}
{hasRequest && days.length > 0 && ( {hasRequest && days.length > 0 && (
<SlotDaySelector <SlotDaySelector
days={days} days={days}
@@ -1174,14 +1274,20 @@ function slotDurationSec(slot: SlotDTO): number {
return Math.max(0, Math.round((parseLocal(slot.tk) - parseLocal(slot.tn)) / 1000)); 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 { function slotStateLabel(state: string): string {
switch (state) { switch (state) {
case "AVAILABLE": case "AVAILABLE":
return "свободен"; return "свободен";
case "BOOKED": case "BOOKED":
return "бронь"; return "бронь";
case "OCCUPIED": case "BUSY":
return "занят"; return "недоступен";
default: default:
return state || "—"; 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),
)
}
@@ -10,8 +10,9 @@ export type RevolutionSign = "ASC" | "DESC";
*/ */
export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS"; export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
/** Статус слота из расчёта покрытия. */ /** Статус слота из расчёта покрытия (slots-service: SlotStatus). BUSY — слот занят чужой/этой
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string; * бронью на этом проходе КА: показываем для обзора, но забронировать нельзя. */
export type SlotStatus = "AVAILABLE" | "BOOKED" | "BUSY" | "PROCESSED" | string;
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */ /** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
export type SlotDTO = { export type SlotDTO = {
@@ -67,4 +68,6 @@ export type RequestSlotsParams = {
sun?: number; sun?: number;
/** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */ /** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */
coverageStrategy?: SlotCoverageStrategy; coverageStrategy?: SlotCoverageStrategy;
/** true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»). */
all?: boolean;
}; };
@@ -969,6 +969,74 @@
font-size: 0.82rem; font-size: 0.82rem;
} }
.tgu-slots-empty__action {
margin-top: 0.6rem;
}
/* Панель фильтров видимости (статус/КА/ветвь) — над строкой выбора. Сужает таблицу и карту. */
.tgu-slots-filterbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem 0.7rem;
padding: 0.4rem 0.55rem;
border-bottom: 1px solid var(--line);
background: var(--bg-panel);
}
.tgu-slots-filterbar__group {
display: flex;
align-items: center;
gap: 0.3rem;
}
.tgu-slots-filterbar__label {
color: var(--text-dim);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.tgu-slots-filterbar__reset {
margin-left: auto;
}
/* Чип фильтра — паттерн пилюли тулбара (.tgu-map-toolbar__toggles). Неактивный = скрыто
фильтром (приглушён). Статус-чипы в активном виде повторяют цвет бейджа статуса в таблице. */
.tgu-slots-chip {
min-height: 1.7rem;
border: 1px solid var(--line);
border-radius: 999px;
padding: 0.18rem 0.6rem;
background: #101214;
color: var(--text-muted);
font-size: 0.74rem;
font-variant-numeric: tabular-nums;
cursor: pointer;
opacity: 0.5;
}
.tgu-slots-chip:hover {
opacity: 0.8;
}
.tgu-slots-chip.is-active {
opacity: 1;
color: var(--text);
}
.tgu-slots-chip.is-booked.is-active {
border-color: rgba(104, 195, 189, 0.58);
background: rgba(104, 195, 189, 0.16);
color: var(--accent-strong);
}
.tgu-slots-chip.is-occupied.is-active {
border-color: rgba(214, 69, 69, 0.5);
background: rgba(214, 69, 69, 0.14);
color: #e98a8a;
}
/* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */ /* Имя выбранной заявки в тулбаре (на месте прежнего выпадающего списка). */
.tgu-slots-request { .tgu-slots-request {
align-items: center; align-items: center;
@@ -1422,23 +1490,6 @@
margin-top: 0.5rem; margin-top: 0.5rem;
} }
/* Подсказка о перегрузе карты слотами (карту не блокируем). */
.tgu-slots-map-hint {
position: absolute;
z-index: 5;
left: 50%;
bottom: 0.85rem;
transform: translateX(-50%);
max-width: 90%;
padding: 0.4rem 0.7rem;
border: 1px solid rgba(240, 173, 46, 0.42);
border-radius: 7px;
background: rgba(17, 19, 21, 0.88);
color: #ffe7b5;
font-size: 0.78rem;
backdrop-filter: blur(6px);
}
/* Баннер подтверждения крупной брони. */ /* Баннер подтверждения крупной брони. */
.tgu-slots-confirm { .tgu-slots-confirm {
position: absolute; position: absolute;