Files
dc-observatio/services/pcp-tgu-ui-service/src/features/tgu-slots/TguSlotsTab.tsx
T
Дмитрий Соловьев 6689351ef0 feat(tgu-ui): вкладка «Слоты» — расчёт покрытия и бронирование слотов заявки
- новая вкладка: заявки интервала на карте, выбор заявки кликом/инспектором,
  расчёт слотов покрытия, бронирование и снятие брони
- выбор заявки без выпадающего списка (не масштабируется на ~10к): все заявки
  на карте, панель справа с кнопкой «Выбрать», имя выбранной заявки в тулбаре,
  кнопка «Сброс», фильтр заявок по интервалу
- бронь отмечаем по ответу POST booking/request (by-request отдаёт слот в чужом
  cycle-фрейме — ключ не совпадал, бейдж «бронь» не появлялся)
- подсветка и автоскролл активной строки по клику на слот (ручной scrollBy)
- меню выбора при плотном наложении слотов (SlotPickMenu); контур заявки
  pickable:false, чтобы не «съедать» меню; панель не закрывается при выборе
- фильтр отображения по суткам (SlotDaySelector)
- gateway: маршрут slots (/api/pcp-slots/**)
- карточка полосы обзора (swath): КА и виток в инспекторе
2026-06-11 12:20:28 +03:00

802 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { fetchRequestsForMap } from "../../api/requestApi";
import {
bookRequestSlots,
cancelRequestBooking,
fetchBookedSlotsByRequest,
fetchRequestSlots
} from "../../api/slotsApi";
import type { RequestMapItem } from "../../model/requestTypes";
import type { RevolutionSign, SlotDTO } from "../../model/slotTypes";
import type { TimelineRange } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
import type { GeoPoint, MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import { MapObjectInspector } from "../tgu-map-2d/MapObjectInspector";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { SlotDaySelector, type SlotDay } from "./SlotDaySelector";
import { SlotPickMenu, type SlotPick } from "./SlotPickMenu";
const DAY_MS = 24 * 60 * 60 * 1000;
/** Начало локальных суток момента (мс) — для группировки слотов по суткам. */
function dayStartMs(ms: number): number {
const d = new Date(ms);
d.setHours(0, 0, 0, 0);
return d.getTime();
}
/** Метка суток ДД.ММ (локально). */
function dayLabel(ms: number): string {
const d = new Date(ms);
return `${String(d.getDate()).padStart(2, "0")}.${String(d.getMonth() + 1).padStart(2, "0")}`;
}
type TguSlotsTabProps = {
/** Диапазон вкладки «Планы» — начальный интервал отбора заявок и расчёта слотов. */
range: TimelineRange;
};
/** Ключ слота: КА + номер слота + цикл — уникален в пределах расчёта и совпадает с ключом брони. */
function slotKey(slot: { satelliteId: number; slotNumber: number; cycle: number }): string {
return `${slot.satelliteId}:${slot.slotNumber}:${slot.cycle}`;
}
export function TguSlotsTab({ range }: TguSlotsTabProps) {
// Заявки интервала (показываются на карте, выбираются кликом) и выбранная заявка.
const [requestItems, setRequestItems] = useState<RequestMapItem[]>([]);
const [requestsLoading, setRequestsLoading] = useState(false);
const [requestsLoaded, setRequestsLoaded] = useState(false);
const [requestsTotal, setRequestsTotal] = useState(0);
const [requestsTruncated, setRequestsTruncated] = useState(false);
const [requestsError, setRequestsError] = useState<string>();
const [selectedRequestId, setSelectedRequestId] = useState<string>("");
// Интервал отбора заявок — какие заявки показываем на карте (аналог фильтра вкладки «Карта»).
const [reqFromValue, setReqFromValue] = useState(() => toLocalInputValue(range.fromMs));
const [reqToValue, setReqToValue] = useState(() => toLocalInputValue(range.toMs));
// Интервал и фильтры расчёта слотов выбранной заявки.
const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs));
const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs));
const [revSign, setRevSign] = useState<"" | RevolutionSign>("");
const [withCoverage, setWithCoverage] = useState(false);
// Результаты: слоты-кандидаты и уже забронированные слоты заявки.
const [slots, setSlots] = useState<SlotDTO[]>([]);
const [bookedSlots, setBookedSlots] = useState<SlotDTO[]>([]);
const [selectedKeys, setSelectedKeys] = useState<ReadonlySet<string>>(() => new Set());
// Фильтр отображения по суткам: индексы (1-based) выбранного диапазона суток расчёта.
const [dayFrom, setDayFrom] = useState(1);
const [dayTo, setDayTo] = useState(1);
const [calculating, setCalculating] = useState(false);
const [booking, setBooking] = useState(false);
const [error, setError] = useState<string>();
const [notice, setNotice] = useState<string>();
// Подсветка строки/контура по клику на карте и центрирование на слоте.
const [activeSlotId, setActiveSlotId] = useState<string>();
const [focusPoint, setFocusPoint] = useState<GeoPoint>();
const focusTokenRef = useRef(0);
const [focusToken, setFocusToken] = useState(0);
// Контейнер списка слотов — по нему ищем активную строку (data-slot-id) и скроллим её в зону видимости.
const listRef = useRef<HTMLElement>(null);
// Инспектор заявки справа над картой: объекты под кликом и раскрытый (только до выбора заявки).
const [mapHits, setMapHits] = useState<Tgu2DMapSelection[]>([]);
const [activeHitId, setActiveHitId] = useState<string>();
// Меню выбора слота при плотном наложении (несколько слотов под точкой клика).
const [slotPicks, setSlotPicks] = useState<SlotPick[]>([]);
const hasRequest = !!selectedRequestId;
const selectedRequest = useMemo(
() => requestItems.find((item) => item.id === selectedRequestId),
[requestItems, selectedRequestId]
);
const parsedRange = useMemo(
() => ({ fromMs: parseLocal(fromValue), toMs: parseLocal(toValue) }),
[fromValue, toValue]
);
const rangeInvalid =
!Number.isFinite(parsedRange.fromMs) ||
!Number.isFinite(parsedRange.toMs) ||
parsedRange.fromMs >= parsedRange.toMs;
// Ключи забронированных слотов — для бейджа «бронь» в таблице и отдельного слоя на карте.
const bookedKeys = useMemo(() => new Set(bookedSlots.map(slotKey)), [bookedSlots]);
// Слот по ключу — чтобы из выбора на карте (id "slot:key"/"booked:key") восстановить полные данные.
const slotByKey = useMemo(() => {
const map = new Map<string, SlotDTO>();
for (const slot of slots) map.set(slotKey(slot), slot);
for (const slot of bookedSlots) if (!map.has(slotKey(slot))) map.set(slotKey(slot), slot);
return map;
}, [slots, bookedSlots]);
// Сутки, по которым раскладываются рассчитанные слоты (для фильтра отображения).
const days = useMemo<SlotDay[]>(() => {
const buckets = new Map<number, number>();
for (const slot of slots) {
const ms = parseLocal(slot.tn);
if (!Number.isFinite(ms)) continue;
const start = dayStartMs(ms);
buckets.set(start, (buckets.get(start) ?? 0) + 1);
}
return Array.from(buckets.entries())
.sort(([a], [b]) => a - b)
.map(([dayMs, count], idx) => ({ index: idx + 1, dayMs, count, label: dayLabel(dayMs) }));
}, [slots]);
// Новый расчёт меняет набор суток — раскрываем фильтр на весь диапазон.
useEffect(() => {
setDayFrom(1);
setDayTo(days.length || 1);
}, [days]);
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(() => {
if (days.length === 0) return slots;
const fromMs = days[effDayFrom - 1].dayMs;
const toMsExcl = days[effDayTo - 1].dayMs + DAY_MS;
return slots.filter((slot) => {
const ms = parseLocal(slot.tn);
return Number.isFinite(ms) && ms >= fromMs && ms < toMsExcl;
});
}, [slots, days, effDayFrom, effDayTo]);
// Загрузка заявок интервала (на карту); вызывается на входе, при смене диапазона и из фильтра.
const loadRequests = useCallback(async (fromInput: string, toInput: string) => {
const fromMs = parseLocal(fromInput);
const toMs = parseLocal(toInput);
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return;
setRequestsLoading(true);
setRequestsError(undefined);
try {
const result = await fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) });
setRequestItems(result.items);
setRequestsTotal(result.totalItems);
setRequestsTruncated(result.truncated);
setRequestsLoaded(true);
} catch (caught) {
setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки.");
} finally {
setRequestsLoading(false);
}
}, []);
// Заявки за диапазон вкладки — грузим на входе и при смене диапазона.
useEffect(() => {
const fromInput = toLocalInputValue(range.fromMs);
const toInput = toLocalInputValue(range.toMs);
setReqFromValue(fromInput);
setReqToValue(toInput);
void loadRequests(fromInput, toInput);
}, [range.fromMs, range.toMs, loadRequests]);
// Клик по слоту на карте подсвечивает строку — центрируем её в видимой части таблицы.
// Скроллим контейнер вручную (по rect-ам), а не через scrollIntoView: тот может выбрать другой
// скролл-предок или счесть строку «достаточно близкой» и не сдвинуться.
useEffect(() => {
if (!activeSlotId) return;
const container = listRef.current;
const row = container?.querySelector<HTMLElement>(`[data-slot-id="${activeSlotId}"]`);
if (!container || !row) return;
const cRect = container.getBoundingClientRect();
const rRect = row.getBoundingClientRect();
if (rRect.top >= cRect.top && rRect.bottom <= cRect.bottom) return; // строка уже целиком видна
const delta = rRect.top + rRect.height / 2 - (cRect.top + container.clientHeight / 2);
container.scrollBy({ top: delta, behavior: "smooth" });
}, [activeSlotId]);
const focusOn = useCallback((point?: GeoPoint) => {
if (!point) return;
setFocusPoint(point);
focusTokenRef.current += 1;
setFocusToken(focusTokenRef.current);
}, []);
// Загружает брони выбранной заявки (для бейджей и слоя карты).
const loadBooked = useCallback(async (requestId: string) => {
try {
const booked = await fetchBookedSlotsByRequest(requestId);
setBookedSlots(booked);
} catch (caught) {
setBookedSlots([]);
setError(caught instanceof Error ? caught.message : "Не удалось загрузить брони заявки.");
}
}, []);
// Смена заявки — сбрасываем результаты и подтягиваем уже существующие брони.
useEffect(() => {
setSlots([]);
setSelectedKeys(new Set());
setBookedSlots([]);
setNotice(undefined);
setError(undefined);
setActiveSlotId(undefined);
if (selectedRequestId) void loadBooked(selectedRequestId);
}, [selectedRequestId, loadBooked]);
const reqFromMs = parseLocal(reqFromValue);
const reqToMs = parseLocal(reqToValue);
const reqRangeInvalid =
!Number.isFinite(reqFromMs) || !Number.isFinite(reqToMs) || reqFromMs >= reqToMs;
const applyRequests = () => void loadRequests(reqFromValue, reqToValue);
const setQuickRange = (fromMs: number, toMs: number) => {
const fromInput = toLocalInputValue(fromMs);
const toInput = toLocalInputValue(toMs);
setReqFromValue(fromInput);
setReqToValue(toInput);
void loadRequests(fromInput, toInput);
};
const quickToday = () => {
const now = new Date();
const fromMs = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
setQuickRange(fromMs, fromMs + DAY_MS);
};
const quick7days = () => setQuickRange(Date.now(), Date.now() + 7 * DAY_MS);
const quick30days = () => setQuickRange(Date.now(), Date.now() + 30 * DAY_MS);
const calculate = async () => {
if (!selectedRequestId || rangeInvalid || calculating) return;
setCalculating(true);
setError(undefined);
setNotice(undefined);
try {
const result = await fetchRequestSlots({
requestId: selectedRequestId,
timeStart: toLocalIso(parsedRange.fromMs),
timeStop: toLocalIso(parsedRange.toMs),
revSign: revSign || undefined,
cov: withCoverage
});
setSlots(result);
setSelectedKeys(new Set());
setNotice(
result.length > 0
? `Найдено слотов: ${result.length}.`
: "Для выбранного интервала слотов покрытия не найдено."
);
if (result.length > 0) {
focusOn({ lat: result[0].latitude, lon: result[0].longitude });
}
} catch (caught) {
setSlots([]);
setError(caught instanceof Error ? caught.message : "Не удалось рассчитать слоты.");
} finally {
setCalculating(false);
}
};
const book = async () => {
if (!selectedRequestId || booking || selectedKeys.size === 0) return;
const toBook = slots
.filter((slot) => selectedKeys.has(slotKey(slot)))
.map((slot) => ({ satelliteId: slot.satelliteId, slotNum: slot.slotNumber, cycle: slot.cycle }));
if (toBook.length === 0) return;
setBooking(true);
setError(undefined);
setNotice(undefined);
try {
const booked = await bookRequestSlots(selectedRequestId, toBook);
// Источник истины — ответ POST: BookedSlotDTO несёт тот же cycle, что мы отправили (= cycle
// рассчитанного слота). А вот GET booking/by-request отдаёт базовый слот в ДРУГОМ цикле
// (slot.toDTO без сдвига на cycle брони), из-за чего ключ sat:slotNum:cycle не совпадает и
// бейдж «бронь» не появляется. Поэтому отмечаем забронированными ровно те рассчитанные слоты
// (с контуром), что вернул POST, не перечитывая by-request.
const bookedSet = new Set(booked.map((item) => `${item.satelliteId}:${item.slotNum}:${item.cycle}`));
const justBooked = slots.filter((slot) => bookedSet.has(slotKey(slot)));
setBookedSlots((prev) => {
const merged = new Map(prev.map((slot) => [slotKey(slot), slot]));
for (const slot of justBooked) merged.set(slotKey(slot), slot);
return Array.from(merged.values());
});
setSelectedKeys(new Set());
setNotice(`Забронировано слотов: ${booked.length}.`);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось забронировать слоты.");
} finally {
setBooking(false);
}
};
const cancelBooking = async () => {
if (!selectedRequestId || booking || bookedSlots.length === 0) return;
setBooking(true);
setError(undefined);
setNotice(undefined);
try {
const cancelled = await cancelRequestBooking(selectedRequestId);
// Снимаются все брони заявки — очищаем локально (by-request не перечитываем, см. book()).
setBookedSlots([]);
setNotice(`Снято броней: ${cancelled}.`);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось снять бронь.");
} finally {
setBooking(false);
}
};
const toggleSlot = (key: string) => {
setSelectedKeys((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
// «Выбрать все» работает по видимым (отфильтрованным) слотам.
const allSelected =
filteredSlots.length > 0 && filteredSlots.every((slot) => selectedKeys.has(slotKey(slot)));
const toggleAll = () => {
setSelectedKeys(allSelected ? new Set() : new Set(filteredSlots.map(slotKey)));
};
const selectRow = (slot: SlotDTO) => {
const id = `slot:${slotKey(slot)}`;
setActiveSlotId(id);
focusOn({ lat: slot.latitude, lon: slot.longitude });
};
// Выбор заявки из инспектора — закрываем инспектор, переходим в режим расчёта слотов.
const chooseRequest = useCallback((requestId: string) => {
setSelectedRequestId(requestId);
setMapHits([]);
setActiveHitId(undefined);
}, []);
// Сброс окна к состоянию «заявка не выбрана, слоты не показаны».
const resetSelection = useCallback(() => {
setSelectedRequestId("");
setMapHits([]);
setActiveHitId(undefined);
setActiveSlotId(undefined);
}, []);
// Сцена карты. До выбора заявки — все заявки интервала (для выбора кликом). После выбора —
// контур заявки (слой requests), слоты-кандидаты (swath) и брони (complexPlan).
const scene = useMemo<Tgu2DMapScene>(() => {
const polygons: MapPolygon[] = [];
let z = 0;
if (!hasRequest) {
for (const item of requestItems) {
if (!item.geometry) continue;
for (const ring of parseWktPolygon(item.geometry)) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${item.id}`,
name: item.name,
kind: "request",
layer: "requests",
points: ring,
requestId: item.id,
zIndex: z++
});
}
}
return { polygons, lines: [], markers: [] };
}
if (selectedRequest?.geometry) {
for (const ring of parseWktPolygon(selectedRequest.geometry)) {
if (ring.length < 3) continue;
polygons.push({
id: `request:${selectedRequest.id}`,
name: selectedRequest.name,
kind: "request",
layer: "requests",
points: ring,
requestId: selectedRequest.id,
// Контур заявки рисуем, но из выбора исключаем — иначе он «съедает» меню выбора слотов
// (как единственный requests-hit под точкой) и любой клик внутри выбирал бы заявку.
pickable: false,
zIndex: z++
});
}
}
for (const slot of filteredSlots) {
const key = slotKey(slot);
for (const ring of parseWktPolygon(slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `slot:${key}`,
name: `КА ${slot.satelliteId} · слот ${slot.slotNumber}`,
kind: bookedKeys.has(key) ? "complexPlan" : "swath",
layer: bookedKeys.has(key) ? "complexPlan" : "swath",
points: ring,
spacecraftId: String(slot.satelliteId),
revolution: slot.revolution,
zIndex: z++
});
}
}
// Брони, которых нет в текущем расчёте (напр. до нажатия «Рассчитать»), — показываем отдельно.
const slotKeysShown = new Set(slots.map(slotKey));
for (const slot of bookedSlots) {
const key = slotKey(slot);
if (slotKeysShown.has(key)) continue;
for (const ring of parseWktPolygon(slot.contour)) {
if (ring.length < 3) continue;
polygons.push({
id: `booked:${key}`,
name: `КА ${slot.satelliteId} · слот ${slot.slotNumber} (бронь)`,
kind: "complexPlan",
layer: "complexPlan",
points: ring,
spacecraftId: String(slot.satelliteId),
revolution: slot.revolution,
zIndex: z++
});
}
}
return { polygons, lines: [], markers: [] };
}, [hasRequest, requestItems, selectedRequest, slots, filteredSlots, bookedSlots, bookedKeys]);
const layers = useMemo<Tgu2DMapLayersState>(
() => ({
tracks: false,
swath: true,
planWorks: false,
complexPlan: true,
heatmap: false,
stations: false,
spacecraftMarkers: false,
requests: true
}),
[]
);
// Клик по карте. С выбранной заявкой — подсветка слота (синхрон со строкой). Без заявки —
// открываем инспектор заявки (одиночный клик) для выбора.
const handleObjectSelect = useCallback(
(selection?: Tgu2DMapSelection) => {
if (hasRequest) {
// Одиночное попадание — подсветку строки ведём только по клику на слот; клик по пустому
// месту снимает подсветку. Меню выбора (наложение) при этом закрываем.
setSlotPicks([]);
const id = selection?.id;
if (!selection) setActiveSlotId(undefined);
else if (id && (id.startsWith("slot:") || id.startsWith("booked:"))) setActiveSlotId(id);
return;
}
if (selection && selection.type === "polygon" && selection.layer === "requests") {
setMapHits([selection]);
setActiveHitId(selection.id);
} else {
setMapHits([]);
setActiveHitId(undefined);
}
},
[hasRequest]
);
// Наложение объектов под точкой. С выбранной заявкой — меню выбора слота (плотные наложения);
// без заявки — список заявок в инспекторе с раскрытием нужной.
const handleObjectsAtPoint = useCallback(
(selections: Tgu2DMapSelection[]) => {
if (hasRequest) {
const seen = new Set<string>();
const picks: SlotPick[] = [];
for (const sel of selections) {
if (sel.type !== "polygon") continue;
if (!sel.id.startsWith("slot:") && !sel.id.startsWith("booked:")) continue;
if (seen.has(sel.id)) continue;
const key = sel.id.slice(sel.id.indexOf(":") + 1);
const slot = slotByKey.get(key);
if (!slot) continue;
seen.add(sel.id);
picks.push({ id: sel.id, slot, booked: bookedKeys.has(key) });
}
setSlotPicks(picks);
return;
}
const requests = selections.filter((s) => s.type === "polygon" && s.layer === "requests");
const hits = requests.length > 0 ? requests : selections;
setMapHits(hits);
setActiveHitId(hits[0]?.id);
},
[hasRequest, slotByKey, bookedKeys]
);
// Выбор слота из меню наложения — подсвечиваем строку, но меню оставляем открытым
// (с пометкой активного пункта), чтобы можно было перебрать наложенные слоты; закрыть — крестиком.
const pickSlot = useCallback((id: string) => {
setActiveSlotId(id);
}, []);
const closeSlotPick = useCallback(() => setSlotPicks([]), []);
const toggleHit = useCallback((selection: Tgu2DMapSelection) => {
setActiveHitId((current) => (current === selection.id ? undefined : selection.id));
}, []);
const closeInspector = useCallback(() => {
setMapHits([]);
setActiveHitId(undefined);
}, []);
const canCalculate = hasRequest && !rangeInvalid && !calculating;
const overlayMessage = calculating
? "Расчёт слотов…"
: !hasRequest
? requestsLoading
? "Загрузка заявок…"
: undefined
: slots.length === 0 && bookedSlots.length === 0
? "Слоты не рассчитаны — нажмите «Рассчитать слоты»."
: undefined;
return (
<main className="tgu-map-workspace">
<div className="tgu-map-toolbar">
<div>
<div className="tgu-map-toolbar__title">Слоты</div>
<div className="tgu-map-toolbar__subtitle">Расчёт покрытия и бронирование слотов заявки</div>
</div>
<div className="tgu-field tgu-slots-request">
<span>Заявка</span>
<div className="tgu-slots-request__name" title={selectedRequest?.name}>
{selectedRequest ? selectedRequest.name : "—"}
</div>
</div>
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={fromValue} onChange={setFromValue} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={toValue} onChange={setToValue} />
</label>
<label className="tgu-field">
<span>Ветвь</span>
<select
className="tgu-select"
value={revSign}
onChange={(event) => setRevSign(event.target.value as "" | RevolutionSign)}
>
<option value="">обе</option>
<option value="ASC">восходящая</option>
<option value="DESC">нисходящая</option>
</select>
</label>
<label className="tgu-checkbox" title="Считать площадь покрытия (дольше)">
<input
type="checkbox"
checked={withCoverage}
onChange={(event) => setWithCoverage(event.target.checked)}
/>
<span>Покрытие</span>
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={calculate} disabled={!canCalculate}>
{calculating ? "Расчёт…" : "Рассчитать слоты"}
</button>
<button
className="tgu-button"
type="button"
onClick={book}
disabled={booking || selectedKeys.size === 0}
>
Забронировать{selectedKeys.size > 0 ? ` (${selectedKeys.size})` : ""}
</button>
<button
className="tgu-button"
type="button"
onClick={cancelBooking}
disabled={booking || bookedSlots.length === 0}
>
Снять бронь{bookedSlots.length > 0 ? ` (${bookedSlots.length})` : ""}
</button>
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{(slots.length > 0 || bookedSlots.length > 0) && (
<div className="tgu-map-toolbar__subtitle">
слотов {slots.length} · забронировано {bookedSlots.length}
</div>
)}
</div>
<div className="tgu-slots-body">
<section className="tgu-slots-list" ref={listRef}>
{hasRequest ? (
filteredSlots.length === 0 ? (
<div className="tgu-slots-empty">
{slots.length > 0 ? "На выбранные сутки слотов нет." : "Слоты не рассчитаны."}
</div>
) : (
<table className="tgu-slots-table">
<thead>
<tr>
<th>
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать все" />
</th>
<th>КА</th>
<th>Виток</th>
<th>Слот / цикл</th>
<th>Начало</th>
<th>Конец</th>
<th>Крен</th>
<th>Статус</th>
</tr>
</thead>
<tbody>
{filteredSlots.map((slot) => {
const key = slotKey(slot);
const id = `slot:${key}`;
const booked = bookedKeys.has(key);
const isActive = activeSlotId === id;
return (
<tr
key={key}
data-slot-id={id}
className={isActive ? "is-active" : undefined}
onClick={() => selectRow(slot)}
>
<td onClick={(event) => event.stopPropagation()}>
<input
type="checkbox"
checked={selectedKeys.has(key)}
onChange={() => toggleSlot(key)}
aria-label="Выбрать слот"
/>
</td>
<td>{slot.satelliteId}</td>
<td>
{slot.revolution} / {slot.revolutionSign === "DESC" ? "нисх" : "восх"}
</td>
<td>
{slot.slotNumber} / {slot.cycle}
</td>
<td>{formatLocalDateTime(parseLocal(slot.tn))}</td>
<td>{formatLocalDateTime(parseLocal(slot.tk))}</td>
<td>{slot.roll.toFixed(1)}°</td>
<td>
{booked ? (
<span className="tgu-slots-badge tgu-slots-badge--booked">бронь</span>
) : (
<span className="tgu-slots-badge">{slotStateLabel(slot.state)}</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
)
) : (
<div className="tgu-slots-requests-panel">
<div className="tgu-map-panel__section">
<div className="tgu-map-panel__label">Заявки на интервал</div>
<div className="tgu-map-requests-filter">
<label className="tgu-field">
<span>С</span>
<DateTime24Field value={reqFromValue} onChange={setReqFromValue} />
</label>
<label className="tgu-field">
<span>По</span>
<DateTime24Field value={reqToValue} onChange={setReqToValue} />
</label>
<div className="tgu-map-requests-filter__actions">
<button
className="tgu-button tgu-button--primary tgu-button--sm"
type="button"
onClick={applyRequests}
disabled={requestsLoading || reqRangeInvalid}
>
{requestsLoading ? "Загрузка..." : "Загрузить"}
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quickToday} disabled={requestsLoading}>
Сегодня
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick7days} disabled={requestsLoading}>
7 дней
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={quick30days} disabled={requestsLoading}>
30 дней
</button>
</div>
{requestsError && (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{requestsError}</div>
)}
{requestsLoaded && !requestsError && (
<div className="tgu-map-requests-filter__status">
{requestItems.length === 0
? "Заявок не найдено."
: `Загружено: ${requestItems.length} из ${requestsTotal}`}
{requestsTruncated && (
<span className="tgu-map-requests-filter__truncated"> (показаны первые 5000 уточните интервал)</span>
)}
</div>
)}
{requestItems.length > 0 && (
<div className="tgu-map-requests-legend">
<span><i className="req-optics" />OPTICS</span>
<span><i className="req-rsa" />RSA</span>
<span><i className="req-combined" />COMBINED</span>
</div>
)}
</div>
</div>
<div className="tgu-slots-empty">Кликните заявку на карте, чтобы выбрать.</div>
</div>
)}
</section>
<section className="tgu-map-stage">
{overlayMessage && <div className="tgu-map-overlay">{overlayMessage}</div>}
{hasRequest && (
<button type="button" className="tgu-button tgu-slots-reset" onClick={resetSelection}>
Сброс
</button>
)}
{hasRequest && days.length > 0 && (
<SlotDaySelector
days={days}
fromIndex={effDayFrom}
toIndex={effDayTo}
onFromChange={setDayFrom}
onToChange={setDayTo}
/>
)}
<Tgu2DMapView
scene={scene}
layers={layers}
redrawToken={hasRequest ? filteredSlots.length + bookedSlots.length : requestItems.length}
enableObjectChooser
onObjectsAtPoint={handleObjectsAtPoint}
onObjectSelect={handleObjectSelect}
selectedObjectId={hasRequest ? activeSlotId : activeHitId}
focusPoint={focusPoint}
focusToken={focusToken}
/>
{!hasRequest && mapHits.length > 0 && (
<aside className="tgu-slots-inspector">
<MapObjectInspector
hits={mapHits}
activeId={activeHitId}
onToggle={toggleHit}
onClose={closeInspector}
requestItems={requestItems}
onSelectRequest={chooseRequest}
/>
</aside>
)}
{hasRequest && slotPicks.length > 0 && (
<SlotPickMenu picks={slotPicks} activeId={activeSlotId} onPick={pickSlot} onClose={closeSlotPick} />
)}
</section>
</div>
<div className="tgu-map-errors">
{notice && <div className="tgu-alert">{notice}</div>}
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
</div>
</main>
);
}
function slotStateLabel(state: string): string {
switch (state) {
case "AVAILABLE":
return "свободен";
case "BOOKED":
return "бронь";
case "OCCUPIED":
return "занят";
default:
return state || "—";
}
}