feat(tgu-ui): слоты — панель фильтров, показ всех слотов и статус «недоступен»
- Панель фильтров видимости (статус/КА/ветвь) над строкой выбора: единый filteredSlots сужает и таблицу, и карту; пустое состояние с «Сбросить». - Убран потолок отрисовки карты — общую картину не режем, объём сужает оператор фильтрами. - Карту не перецентрируем после расчёта (был прыжок на произвольный первый слот). - «Показать все слоты» шлёт all=true и показывает занятые бронью (BUSY); бронь их не берёт (исключены из выбора/счётчика, чекбокс disabled). - Починен мёртвый маппинг статуса: бэкенд шлёт BUSY, фронт ждал OCCUPIED. - BUSY отображается как «недоступен» (конфликт с бронью), а не «занят» — чтобы не путать с самой бронью.
This commit is contained in:
@@ -36,6 +36,18 @@ describe("fetchRequestSlots", () => {
|
||||
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 () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
|
||||
const ctrl = new AbortController();
|
||||
|
||||
@@ -46,6 +46,7 @@ export async function fetchRequestSlots(
|
||||
if (params.cov != null) query.set('cov', String(params.cov))
|
||||
if (params.sun != null) query.set('sun', String(params.sun))
|
||||
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)))
|
||||
|
||||
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 { 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),
|
||||
)
|
||||
}
|
||||
@@ -10,8 +10,9 @@ export type RevolutionSign = "ASC" | "DESC";
|
||||
*/
|
||||
export type SlotCoverageStrategy = "GREEDY" | "CONTINUOUS";
|
||||
|
||||
/** Статус слота из расчёта покрытия. */
|
||||
export type SlotStatus = "AVAILABLE" | "BOOKED" | "OCCUPIED" | string;
|
||||
/** Статус слота из расчёта покрытия (slots-service: SlotStatus). BUSY — слот занят чужой/этой
|
||||
* бронью на этом проходе КА: показываем для обзора, но забронировать нельзя. */
|
||||
export type SlotStatus = "AVAILABLE" | "BOOKED" | "BUSY" | "PROCESSED" | string;
|
||||
|
||||
/** Слот покрытия заявки — кандидат на бронирование (slots-service: SlotDTO). */
|
||||
export type SlotDTO = {
|
||||
@@ -67,4 +68,6 @@ export type RequestSlotsParams = {
|
||||
sun?: number;
|
||||
/** Стратегия автоформирования покрытия; не задано — все слоты-кандидаты. */
|
||||
coverageStrategy?: SlotCoverageStrategy;
|
||||
/** true — вернуть и занятые бронью слоты (BUSY) для обзора («Показать все слоты»). */
|
||||
all?: boolean;
|
||||
};
|
||||
|
||||
@@ -969,6 +969,74 @@
|
||||
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 {
|
||||
align-items: center;
|
||||
@@ -1422,23 +1490,6 @@
|
||||
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 {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user