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");
|
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user