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(initialRange); const [rawPlatforms, setRawPlatforms] = useState([]); const [plans, setPlans] = useState([]); const [loading, setLoading] = useState(false); const [pageError, setPageError] = useState(); const [platformLoadFailed, setPlatformLoadFailed] = useState(false); const [search, setSearch] = useState(""); const [selectedSpacecraftId, setSelectedSpacecraftId] = useState(); const [selectedPlanId, setSelectedPlanId] = useState(); const [activeTab, setActiveTab] = useState("timeline"); const [createChainOpen, setCreateChainOpen] = useState(false); // Черновики (DRAFT) — план-кандидаты, скрытые из основного read-path; грузим отдельно. const [drafts, setDrafts] = useState([]); const [draftActionInFlight, setDraftActionInFlight] = useState(false); // Тумблеры видимости «непринятого будущего» на схеме. const [showDrafts, setShowDrafts] = useState(false); const [showProjection, setShowProjection] = useState(true); // ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться). const [zrvWindows, setZrvWindows] = useState([]); // Выбор ЗРВ на таймлайне: пересекающиеся в точке зоны + индекс активной (панель справа). const [zrvSelection, setZrvSelection] = useState<{ windows: ZrvWindow[]; index: number }>(); // Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии // и дальше держим смонтированной (скрытой через display:none), чтобы её // внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось // при переключении между вкладками. const [visitedTabs, setVisitedTabs] = useState>(() => new Set(["timeline"])); const [decisionInFlight, setDecisionInFlight] = useState(false); const [decisionNotice, setDecisionNotice] = useState(); const [decisionError, setDecisionError] = useState(); 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 // (внутренний
остаётся прямым 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(); 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 ( } >
{ setSelectedSpacecraftId(spacecraftId); setSelectedPlanId(undefined); setDecisionNotice(undefined); }} />
{ 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 }); }} />
{zrvSelection ? ( setZrvSelection((current) => (current ? { ...current, index } : current))} onClose={() => setZrvSelection(undefined)} /> ) : ( setSelectedPlanId(undefined)} /> )}
{createChainOpen && selectedSpacecraftId && ( setCreateChainOpen(false)} onCreated={() => { setShowDrafts(true); void loadData(appliedRange); }} onEdit={(draftPlanId) => { setCreateChainOpen(false); setSelectedPlanId(draftPlanId); setDecisionNotice(undefined); setActiveTab("editor"); }} /> )}
{visitedTabs.has("map") && (
)} {visitedTabs.has("complex") && (
)} {visitedTabs.has("requests") && (
)} {visitedTabs.has("slots") && (
)} {visitedTabs.has("constellation") && (
)} {visitedTabs.has("editor") && (
)}
); } 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); }