Files
dc-observatio/services/pcp-tgu-ui-service/src/features/tgu-planning/TguPlanningPage.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

631 lines
26 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, useState } from "react";
import {
confirmPlanLayin,
dismissPlan,
fetchPlans,
fetchPlatforms,
restorePlan,
sendPlanDecision
} from "../../api/tguApi";
import type { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab";
import { ConstellationTab } from "../constellation/ConstellationTab";
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { TguSlotsTab } from "../tgu-slots/TguSlotsTab";
import {
abandonDraft,
createDraft,
fetchDraftPlans,
fetchZrvWindows,
promoteDraft,
type ZrvWindowResponse
} from "../../api/tguPlanApi";
import { fetchPlanModes } from "../tgu-editor/editorApi";
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
import { TguPlanDetails, type PlanModeCounts } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
import { TguTimeline, type ZrvWindow } from "./TguTimeline";
import { TguToolbar } from "./TguToolbar";
import { TguZrvPanel } from "./TguZrvPanel";
import {
buildPlatformsForInterval,
buildTimelineRows,
isOperationalPlatform,
mapPlans,
operationalSpacecraftIds,
pausedSpacecraftIds
} from "./tguTimelineMapper";
import { parseLocal, toLocalInputValue } from "../../utils/localTime";
const DAY_MS = 24 * 60 * 60 * 1000;
const MINUTE_MS = 60 * 1000;
export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []);
const [fromValue, setFromValue] = useState(toLocalInputValue(initialRange.fromMs));
const [toValue, setToValue] = useState(toLocalInputValue(initialRange.toMs));
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
const [plans, setPlans] = useState<TguPlanUi[]>([]);
const [loading, setLoading] = useState(false);
const [pageError, setPageError] = useState<string>();
const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
const [search, setSearch] = useState("");
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
const [selectedPlanId, setSelectedPlanId] = useState<string>();
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
const [createChainOpen, setCreateChainOpen] = useState(false);
// Черновики (DRAFT) — план-кандидаты, скрытые из основного read-path; грузим отдельно.
const [drafts, setDrafts] = useState<TguPlanUi[]>([]);
const [draftActionInFlight, setDraftActionInFlight] = useState(false);
// Тумблеры видимости «непринятого будущего» на схеме.
const [showDrafts, setShowDrafts] = useState(false);
const [showProjection, setShowProjection] = useState(true);
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
// Выбор ЗРВ на таймлайне: пересекающиеся в точке зоны + индекс активной (панель справа).
const [zrvSelection, setZrvSelection] = useState<{ windows: ZrvWindow[]; index: number }>();
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
// и дальше держим смонтированной (скрытой через display:none), чтобы её
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
// при переключении между вкладками.
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<TguActiveTab>>(() => new Set(["timeline"]));
const [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
const parsedInputRange = useMemo(
() => ({
fromMs: parseLocal(fromValue),
toMs: parseLocal(toValue)
}),
[fromValue, toValue]
);
const invalidRange =
!Number.isFinite(parsedInputRange.fromMs) ||
!Number.isFinite(parsedInputRange.toMs) ||
parsedInputRange.fromMs >= parsedInputRange.toMs;
const loadData = useCallback(
async (range: TimelineRange) => {
setLoading(true);
setPageError(undefined);
setDecisionError(undefined);
const historyDays = historyDaysForRange(range);
const [platformResult, planResult, draftResult] = await Promise.allSettled([
fetchPlatforms(),
fetchPlans(historyDays),
fetchDraftPlans()
]);
// На морде работаем только с эксплуатируемыми КА — остальные отсекаем сразу
// на входе, чтобы они не всплывали ни в списке, ни как fallback-строки по планам.
const operationalPlatforms =
platformResult.status === "fulfilled" ? platformResult.value.filter(isOperationalPlatform) : [];
const operationalIds = operationalSpacecraftIds(operationalPlatforms);
if (platformResult.status === "rejected") {
setPlatformLoadFailed(true);
} else {
setPlatformLoadFailed(false);
setRawPlatforms(operationalPlatforms);
}
if (planResult.status === "rejected") {
setPlans([]);
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
} else {
setPlans(mapPlans(planResult.value, operationalPlatforms).filter((plan) => operationalIds.has(plan.spacecraftId)));
}
// Черновики читаем из pcp-tgu-service отдельно; сбой непрозрачен — просто нет черновиков.
if (draftResult.status === "fulfilled") {
setDrafts(
mapPlans(draftResult.value as unknown as TguPlan[], operationalPlatforms)
.filter((plan) => operationalIds.has(plan.spacecraftId))
);
} else {
setDrafts([]);
}
setLoading(false);
},
[]
);
useEffect(() => {
void loadData(appliedRange);
}, [appliedRange, loadData]);
useEffect(() => {
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
}, [activeTab]);
// ЗРВ выбранного КА для подсветки на таймлайне. Сбой непрозрачен — просто нет подсветки.
useEffect(() => {
if (!selectedSpacecraftId) {
setZrvWindows([]);
return;
}
let cancelled = false;
void fetchZrvWindows(selectedSpacecraftId)
.then((windows) => {
if (!cancelled) setZrvWindows(windows);
})
.catch(() => {
if (!cancelled) setZrvWindows([]);
});
return () => {
cancelled = true;
};
}, [selectedSpacecraftId]);
const zrvBands = useMemo(() => {
if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined;
return {
spacecraftId: selectedSpacecraftId,
windows: zrvWindows.map((zrv) => ({
startMs: parseLocal(zrv.startTime),
endMs: parseLocal(zrv.endTime),
kppId: zrv.kppId
}))
};
}, [selectedSpacecraftId, zrvWindows]);
// Сбрасываем выбранную ЗРВ при смене КА (зоны перечитываются) — панель не должна показывать чужие.
useEffect(() => {
setZrvSelection(undefined);
}, [selectedSpacecraftId]);
const selectedZrvId = useMemo(() => {
if (!zrvSelection) return undefined;
const zrv = zrvSelection.windows[zrvSelection.index];
return zrv ? `${zrv.kppId}|${zrv.startMs}` : undefined;
}, [zrvSelection]);
// display:contents для активной вкладки сохраняет grid-раскладку shell
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
const tabStyle = (tab: TguActiveTab) =>
({ display: activeTab === tab ? "contents" : "none" }) as const;
// Все планы для выбора/поиска (включая черновики), независимо от тумблеров видимости.
const allPlans = useMemo(() => [...plans, ...drafts], [plans, drafts]);
// Планы для отрисовки на таймлайне с учётом тумблеров: проекция PLANNED и черновики.
const visiblePlans = useMemo(() => {
const base = showProjection ? plans : plans.filter((plan) => plan.status !== "PLANNED");
return showDrafts ? [...base, ...drafts] : base;
}, [plans, drafts, showProjection, showDrafts]);
// КА с цепочкой на паузе после REJECTED (см. pausedSpacecraftIds): считаем по persisted-планам,
// черновики/проекция паузу не снимают. Совпадает с серверной паузой воркера выдачи.
const paused = useMemo(() => pausedSpacecraftIds(plans), [plans]);
const platforms = useMemo(
() => buildPlatformsForInterval(rawPlatforms, allPlans, appliedRange),
[rawPlatforms, allPlans, appliedRange]
);
const rows = useMemo(
() => buildTimelineRows(visiblePlans, platforms, appliedRange, selectedSpacecraftId),
[visiblePlans, platforms, appliedRange, selectedSpacecraftId]
);
const selectedPlan = useMemo(() => {
const plan = allPlans.find((item) => item.planId === selectedPlanId);
if (!plan) return undefined;
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
return { ...plan, platform };
}, [allPlans, platforms, selectedPlanId]);
// Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели.
// planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор.
// Зависим и от activeTab: при возврате на вкладку «Планы» перечитываем состав, иначе
// счётчик маршрутов остаётся прежним после правок в редакторе (selectedPlanId не менялся).
const [planModeCounts, setPlanModeCounts] = useState<PlanModeCounts>();
useEffect(() => {
if (!selectedPlanId || activeTab !== "timeline") {
setPlanModeCounts(undefined);
return;
}
let cancelled = false;
setPlanModeCounts(undefined);
void fetchPlanModes(selectedPlanId)
.then((modes) => {
if (cancelled) return;
setPlanModeCounts({
survey: modes.filter((mode) => mode.type === "SURVEY").length,
drop: modes.filter((mode) => mode.type === "DROP").length
});
})
.catch(() => {
if (!cancelled) setPlanModeCounts(undefined);
});
return () => {
cancelled = true;
};
}, [selectedPlanId, activeTab]);
const applyRange = () => {
setDecisionNotice(undefined);
if (invalidRange) {
setPageError("Некорректный интервал: from должен быть меньше to.");
return;
}
setPageError(undefined);
setAppliedRange(parsedInputRange);
};
const quickRange = (hours: number) => {
const fromMs = startOfCurrentDayMs();
const toMs = fromMs + hours * 60 * 60 * 1000;
setFromValue(toLocalInputValue(fromMs));
setToValue(toLocalInputValue(toMs));
setAppliedRange({ fromMs, toMs });
setPageError(undefined);
setDecisionNotice(undefined);
};
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
setDecisionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await sendPlanDecision(planId, decision, reason);
setDecisionNotice(
decision === "REJECTED"
? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
: "Решение ACCEPTED отправлено."
);
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
} finally {
setDecisionInFlight(false);
}
};
// ТЕСТ: подтвердить закладку выбранного плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер.
const handleConfirmLayin = async (planId: string) => {
setDecisionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await confirmPlanLayin(planId);
setDecisionNotice("Закладка подтверждена (LAID_IN). План стал базой цепочки.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось подтвердить закладку.");
} finally {
setDecisionInFlight(false);
}
};
// Гард ±10 мин: нельзя подтвердить закладку, если по КА уже заложен (LAID_IN) план со стартом
// в пределах 10 минут. Дублирует бэкенд-гард, чтобы заранее погасить кнопку с подсказкой.
const layinConflict = useMemo(() => {
if (!selectedPlan || selectedPlan.status !== "AWAITING_LAYIN") return false;
const windowMs = 10 * 60_000;
return allPlans.some(
(item) =>
item.planId !== selectedPlan.planId &&
item.spacecraftId === selectedPlan.spacecraftId &&
item.status === "LAID_IN" &&
Math.abs(item.startMs - selectedPlan.startMs) < windowMs
);
}, [allPlans, selectedPlan]);
// Открыть черновик в редакторе: выбираем его план/КА и переключаемся на вкладку «Редактор».
const handleEditDraft = (planId: string) => {
const draft = drafts.find((item) => item.planId === planId);
setSelectedPlanId(planId);
if (draft) setSelectedSpacecraftId(draft.spacecraftId);
setDecisionNotice(undefined);
setActiveTab("editor");
};
// Отправить черновик вручную (promote): уходит в выдачу, рабочим станет после приёмки.
const handlePromoteDraft = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await promoteDraft(planId);
setDecisionNotice("Черновик отправлен на выдачу. Рабочим (якорем цепочки) он станет после приёмки.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить черновик.");
} finally {
setDraftActionInFlight(false);
}
};
// Создать черновик-копию на основе существующего плана (replace, окно 1:1, все включения).
// Это путь «сделать исправленный план», а не повторная отправка того же. В редактор сами
// не уходим — оператор выберет черновик и перейдёт в «Редактор» вручную.
const handleCreateDraftFromPlan = async (planId: string) => {
// Один открытый черновик на слот (КА + окно): replace-черновик копирует окно исходного плана
// 1:1, а его basePlanId теперь = база исходного (предшественник), а не сам исходный план —
// поэтому ищем уже созданный черновик по совпадению окна, а не по basePlanId.
const source = allPlans.find((item) => item.planId === planId);
const existing = source
? drafts.find(
(item) =>
item.spacecraftId === source.spacecraftId &&
item.startMs === source.startMs &&
item.endMs === source.endMs
)
: undefined;
if (existing) {
setShowDrafts(true);
setSelectedSpacecraftId(existing.spacecraftId);
setSelectedPlanId(existing.planId);
setDecisionNotice("Черновик на основе этого плана уже создан — выбран на схеме.");
return;
}
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
const { draftPlanId } = await createDraft(planId, { mode: "replace" });
await loadData(appliedRange);
const base = allPlans.find((item) => item.planId === planId);
setShowDrafts(true);
if (base) setSelectedSpacecraftId(base.spacecraftId);
setSelectedPlanId(draftPlanId);
setDecisionNotice("Черновик создан и выбран на схеме. Откройте «Редактор», чтобы поправить и отправить его.");
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана.");
} finally {
setDraftActionInFlight(false);
}
};
// Удалить черновик (abandon): убирает DRAFT и его клон-миссию.
const handleDeleteDraft = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await abandonDraft(planId);
setSelectedPlanId(undefined);
setDecisionNotice("Черновик удалён.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось удалить черновик.");
} finally {
setDraftActionInFlight(false);
}
};
// Снять/вернуть план с учёта: косметико-статистический флаг, не трогает статус и цепочку.
const handleDismissPlan = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await dismissPlan(planId);
setDecisionNotice("План снят с учёта — больше не считается проблемой КА.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось снять план с учёта.");
} finally {
setDraftActionInFlight(false);
}
};
const handleRestorePlan = async (planId: string) => {
setDraftActionInFlight(true);
setDecisionError(undefined);
setDecisionNotice(undefined);
try {
await restorePlan(planId);
setDecisionNotice("План возвращён на учёт.");
await loadData(appliedRange);
} catch (error) {
setDecisionError(error instanceof Error ? error.message : "Не удалось вернуть план на учёт.");
} finally {
setDraftActionInFlight(false);
}
};
return (
<TguPlanningLayout
toolbar={
<TguToolbar
fromValue={fromValue}
toValue={toValue}
loading={loading}
activeTab={activeTab}
error={pageError}
onTabChange={setActiveTab}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
onQuickRange={quickRange}
/>
}
>
<div style={tabStyle("timeline")}>
<main className="tgu-workspace">
<TguSidebar
platforms={platforms}
selectedSpacecraftId={selectedSpacecraftId}
search={search}
platformLoadFailed={platformLoadFailed}
onSearchChange={setSearch}
onSelectSpacecraft={(spacecraftId) => {
setSelectedSpacecraftId(spacecraftId);
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
}}
/>
<section className="tgu-center">
<div className="tgu-center__actions">
<label className="tgu-center__toggle">
<input type="checkbox" checked={showDrafts} onChange={(e) => setShowDrafts(e.target.checked)} />
Черновики
</label>
<label className="tgu-center__toggle">
<input type="checkbox" checked={showProjection} onChange={(e) => setShowProjection(e.target.checked)} />
Возможные будущие планы
</label>
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={!selectedSpacecraftId}
title={selectedSpacecraftId ? "Создать план новой цепочки" : "Сначала выберите КА"}
onClick={() => setCreateChainOpen(true)}
>
Создать план
</button>
</div>
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
zrvBands={zrvBands}
selectedZrvId={selectedZrvId}
pausedSpacecraftIds={paused}
selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => {
setZrvSelection(undefined);
setDecisionNotice(undefined);
setDecisionError(undefined);
setSelectedPlanId(planId);
const plan = allPlans.find((item) => item.planId === planId);
if (plan) {
setSelectedSpacecraftId(plan.spacecraftId);
}
}}
onSelectZrv={(windows, index) => {
setSelectedPlanId(undefined);
setDecisionNotice(undefined);
setDecisionError(undefined);
setZrvSelection({ windows, index });
}}
/>
</section>
{zrvSelection ? (
<TguZrvPanel
windows={zrvSelection.windows}
selectedIndex={zrvSelection.index}
onSelect={(index) => setZrvSelection((current) => (current ? { ...current, index } : current))}
onClose={() => setZrvSelection(undefined)}
/>
) : (
<TguPlanDetails
plan={selectedPlan}
modeCounts={planModeCounts}
chainPaused={selectedPlan ? paused.has(selectedPlan.spacecraftId) : false}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
draftActionInFlight={draftActionInFlight}
onEditDraft={handleEditDraft}
onPromoteDraft={handlePromoteDraft}
onDeleteDraft={handleDeleteDraft}
onCreateDraftFromPlan={handleCreateDraftFromPlan}
onConfirmLayin={handleConfirmLayin}
layinConflict={layinConflict}
onDismissPlan={handleDismissPlan}
onRestorePlan={handleRestorePlan}
onClose={() => setSelectedPlanId(undefined)}
/>
)}
</main>
{createChainOpen && selectedSpacecraftId && (
<CreateChainPlanDialog
spacecraftId={selectedSpacecraftId}
plans={plans}
initialBasePlanId={selectedPlanId}
onClose={() => setCreateChainOpen(false)}
onCreated={() => {
setShowDrafts(true);
void loadData(appliedRange);
}}
onEdit={(draftPlanId) => {
setCreateChainOpen(false);
setSelectedPlanId(draftPlanId);
setDecisionNotice(undefined);
setActiveTab("editor");
}}
/>
)}
</div>
{visitedTabs.has("map") && (
<div style={tabStyle("map")}>
<Tgu2DMapTab
range={appliedRange}
invalidRange={invalidRange}
selectedPlan={selectedPlan}
platforms={platforms}
/>
</div>
)}
{visitedTabs.has("complex") && (
<div style={tabStyle("complex")}>
<TguComplexPlanTab
range={appliedRange}
platforms={platforms}
/>
</div>
)}
{visitedTabs.has("requests") && (
<div style={tabStyle("requests")}>
<TguRequestsTab
range={appliedRange}
invalidRange={invalidRange}
platforms={platforms}
/>
</div>
)}
{visitedTabs.has("slots") && (
<div style={tabStyle("slots")}>
<TguSlotsTab range={appliedRange} />
</div>
)}
{visitedTabs.has("constellation") && (
<div style={tabStyle("constellation")}>
<ConstellationTab />
</div>
)}
{visitedTabs.has("editor") && (
<div style={tabStyle("editor")}>
<TguEditorTab
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
selectedPlan={selectedPlan}
appliedRange={appliedRange}
platforms={platforms}
/>
</div>
)}
</TguPlanningLayout>
);
}
function defaultRange(): TimelineRange {
const now = Date.now();
return { fromMs: now - 30 * MINUTE_MS, toMs: now + 3 * DAY_MS };
}
function startOfCurrentDayMs(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
function historyDaysForRange(range: TimelineRange): number {
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
return Math.max(1, daysBack + 1);
}