diff --git a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts index e5acb13..b7dc1a9 100644 --- a/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/complexPlanApi.ts @@ -8,6 +8,13 @@ import { toLocalIso } from "../utils/localTime"; */ const BASE = "/api/pcp-complex"; +/** + * Максимальная длина интервала расчёта комплексного плана — 10 суток. Дольше считать запрещено + * (расчёт по всем КА на больший горизонт слишком тяжёлый и теряет смысл для оперативного плана). + */ +export const MAX_COMPLEX_PLAN_RANGE_DAYS = 10; +export const MAX_COMPLEX_PLAN_RANGE_MS = MAX_COMPLEX_PLAN_RANGE_DAYS * 24 * 60 * 60 * 1000; + /** Статус запуска расчёта комплексного плана (ComplexPlanRunStatus бэка). */ export type ComplexPlanRunStatus = "CREATED" | "RUNNING" | "COMPLETED" | "FAILED"; @@ -45,6 +52,35 @@ export type ComplexPlanRunResult = { coveredAreaPercent: number; }; +/** + * Краткая карточка прогона из истории расчётов (ComplexPlanRunSummaryDTO). + * Возвращается списком из GET /api/com-plan/runs, отсортированным по убыванию даты создания. + */ +export type ComplexPlanRunSummary = { + runId: number; + status: ComplexPlanRunStatus; + intervalStart: string; + intervalEnd: string; + satelliteIds: number[]; + requestedBy: string | null; + createdAt: string; + startedAt: string | null; + finishedAt: string | null; + durationMs: number | null; + errorMessage: string | null; + calculatedModesCount: number; + bookedModesCount: number; + complanModesCount: number; + mixedModesCount: number; + affectedSatellitesCount: number; + processedCellsCount: number; + bookedSlotLinksCount: number; + snapshotIds: number[]; + totalRequestsArea: number; + coveredArea: number; + coveredAreaPercent: number; +}; + /** Режим съёмки/сброса из результата расчёта (SatelliteModeResponseDTO). Времена — локальные. */ export type ComplexPlanMode = { id: number; @@ -101,6 +137,27 @@ export async function runComplexPlan(params: RunComplexPlanParams): Promise { + const url = `${BASE}/api/com-plan/runs`; + let response: Response; + try { + response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } }); + } catch { + throw new TguApiError("Не удалось подключиться к сервису комплексного плана.", "network"); + } + const text = await response.text(); + if (!response.ok) { + throw new TguApiError(extractErrorText(text) || "Не удалось загрузить историю расчётов.", "http", response.status); + } + if (!text.trim()) return []; + return JSON.parse(text) as ComplexPlanRunSummary[]; +} + /** * Режимы (съёмки/сбросы) рассчитанного run’а для отрисовки на карте * (pcp-complex-mission-service, GET /api/com-plan/runs/{runId}/modes). diff --git a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts index 87700f9..acd72bc 100644 --- a/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/missionPlanningApi.ts @@ -191,11 +191,13 @@ export async function calculateDrops(planId: string): Promise { } /** - * Полная очистка плана: удаляет все включения (съёмки, включая ручные, и сбросы) плана-черновика - * (pcp-mission-planing-service, DELETE /api/missions/{planId}/modes). Возвращает 204. + * Очистка плана-черновика (pcp-mission-planing-service, DELETE /api/missions/{planId}/modes). + * Сбросы удаляются всегда; `keepManual=true` сохраняет ручные маршруты съёмки (source=MANUAL), + * `keepManual=false` (по умолчанию) удаляет все съёмки, включая ручные. Возвращает 204. */ -export async function clearPlanModes(planId: string): Promise { - const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/modes`; +export async function clearPlanModes(planId: string, keepManual = false): Promise { + const query = keepManual ? "?keepManual=true" : ""; + const url = `${BASE}/api/missions/${encodeURIComponent(planId)}/modes${query}`; let response: Response; try { response = await fetch(url, { method: "DELETE", headers: { Accept: "application/json" } }); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx index fd0725e..095d1a0 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-complex-plan/TguComplexPlanTab.tsx @@ -1,17 +1,23 @@ -import { useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { fetchComplexPlanRunModes, + fetchComplexPlanRuns, runComplexPlan, + MAX_COMPLEX_PLAN_RANGE_DAYS, + MAX_COMPLEX_PLAN_RANGE_MS, type ComplexPlanMode, - type ComplexPlanRunResult + type ComplexPlanRunSummary } from "../../api/complexPlanApi"; +import { fetchRequestsForMap } from "../../api/requestApi"; +import type { RequestMapItem } from "../../model/requestTypes"; import type { TguPlatformUi, TimelineRange } from "../../model/timelineTypes"; import { DateTime24Field } from "../../components/timeFields"; import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser"; +import { buildTgu2DMapScene } from "../tgu-map-2d/Tgu2DMapLayers"; import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes"; import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; -import { parseLocal, toLocalInputValue } from "../../utils/localTime"; +import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; type TguComplexPlanTabProps = { range: TimelineRange; @@ -19,27 +25,26 @@ type TguComplexPlanTabProps = { platforms: TguPlatformUi[]; }; -// Включён только выделенный слой «Комплексный план» (свой цвет, своя отрисовка с фиксом -// антимеридиана) — контуры не путаются с полосой обзора/работами плана других вкладок. -const COMPLEX_PLAN_LAYERS: Tgu2DMapLayersState = { - tracks: false, - swath: false, - planWorks: false, - complexPlan: true, - stations: false, - spacecraftMarkers: false, - requests: false -}; - export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) { const [fromValue, setFromValue] = useState(() => toLocalInputValue(range.fromMs)); const [toValue, setToValue] = useState(() => toLocalInputValue(range.toMs)); const [running, setRunning] = useState(false); - const [result, setResult] = useState(); const [modes, setModes] = useState([]); const [error, setError] = useState(); const [notice, setNotice] = useState(); + // История прогонов и выбранный для просмотра прогон. + const [runs, setRuns] = useState([]); + const [runsError, setRunsError] = useState(); + const [selectedRunId, setSelectedRunId] = useState(); + const [loadingRun, setLoadingRun] = useState(false); + + // Наложение заявок поверх контуров комплексного плана. + const [showRequests, setShowRequests] = useState(false); + const [requestItems, setRequestItems] = useState([]); + const [requestsLoading, setRequestsLoading] = useState(false); + const [requestsError, setRequestsError] = useState(); + // NORAD-id всех эксплуатируемых КА «Планов» (spacecraftId == norad-id, см. tguTimelineMapper). const satelliteIds = useMemo( () => @@ -61,11 +66,106 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) !Number.isFinite(parsedRange.fromMs) || !Number.isFinite(parsedRange.toMs) || parsedRange.fromMs >= parsedRange.toMs; + const rangeTooLong = + !rangeInvalid && parsedRange.toMs - parsedRange.fromMs > MAX_COMPLEX_PLAN_RANGE_MS; - const scene = useMemo(() => ({ polygons: buildModePolygons(modes), lines: [], markers: [] }), [modes]); + const selectedRun = useMemo( + () => runs.find((run) => run.runId === selectedRunId), + [runs, selectedRunId] + ); + + // Интервал, по которому грузим заявки: показанный прогон (если выбран) либо поля «С/По». + const activeInterval = useMemo(() => { + if (selectedRun) { + return { fromMs: parseLocal(selectedRun.intervalStart), toMs: parseLocal(selectedRun.intervalEnd) }; + } + return parsedRange; + }, [selectedRun, parsedRange]); + + const modePolygons = useMemo(() => buildModePolygons(modes), [modes]); + const requestPolygons = useMemo( + () => (showRequests ? buildTgu2DMapScene({ range, platforms, requestItems }).polygons : []), + [showRequests, requestItems, range, platforms] + ); + + const scene = useMemo( + () => ({ polygons: [...modePolygons, ...requestPolygons], lines: [], markers: [] }), + [modePolygons, requestPolygons] + ); + + const layers = useMemo( + () => ({ + tracks: false, + swath: false, + planWorks: false, + complexPlan: true, + stations: false, + spacecraftMarkers: false, + requests: showRequests + }), + [showRequests] + ); + + const loadRuns = useCallback(async (): Promise => { + setRunsError(undefined); + try { + const items = await fetchComplexPlanRuns(); + setRuns(items); + return items; + } catch (caught) { + setRunsError(caught instanceof Error ? caught.message : "Не удалось загрузить историю расчётов."); + return []; + } + }, []); + + const loadRunModes = useCallback(async (runId: number) => { + setSelectedRunId(runId); + setLoadingRun(true); + setError(undefined); + try { + const runModes = await fetchComplexPlanRunModes(runId); + setModes(runModes); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось загрузить результат расчёта."); + } finally { + setLoadingRun(false); + } + }, []); + + // На входе во вкладку подтягиваем историю и сразу показываем последний построенный план. + useEffect(() => { + void (async () => { + const items = await loadRuns(); + const latest = items.find((run) => run.status === "COMPLETED" && run.calculatedModesCount > 0) ?? items[0]; + if (latest) void loadRunModes(latest.runId); + })(); + }, [loadRuns, loadRunModes]); + + // Заявки за активный интервал (по тумблеру) — для сопоставления контуров плана с заявками. + useEffect(() => { + if (!showRequests) return; + const { fromMs, toMs } = activeInterval; + if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || fromMs >= toMs) return; + let cancelled = false; + setRequestsLoading(true); + setRequestsError(undefined); + fetchRequestsForMap({ endFrom: toLocalIso(fromMs), beginTo: toLocalIso(toMs) }) + .then((result) => { + if (!cancelled) setRequestItems(result.items); + }) + .catch((caught) => { + if (!cancelled) setRequestsError(caught instanceof Error ? caught.message : "Не удалось загрузить заявки."); + }) + .finally(() => { + if (!cancelled) setRequestsLoading(false); + }); + return () => { + cancelled = true; + }; + }, [showRequests, activeInterval]); const runCalculation = async () => { - if (running || rangeInvalid || satelliteIds.length === 0) return; + if (running || rangeInvalid || rangeTooLong || satelliteIds.length === 0) return; setRunning(true); setError(undefined); setNotice(undefined); @@ -75,13 +175,11 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) toMs: parsedRange.toMs, satelliteIds }); - setResult(runResult); - const runModes = await fetchComplexPlanRunModes(runResult.runId); - setModes(runModes); - const contoursCount = runModes.filter((mode) => mode.contourWkt).length; + await loadRuns(); + await loadRunModes(runResult.runId); setNotice( runResult.calculatedModesCount > 0 - ? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}. На карте — ${contoursCount} контуров.` + ? `Расчёт завершён: режимов ${runResult.calculatedModesCount}, КА затронуто ${runResult.affectedSatellitesCount}, охват ${formatPercent(runResult.coveredAreaPercent)}.` : "Расчёт завершён, но включений не построено: на интервал нет покрытия заявками." ); } catch (caught) { @@ -91,15 +189,21 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) } }; - const canRun = !running && !rangeInvalid && satelliteIds.length > 0; - const overlayMessage = running - ? "Идёт расчёт…" + const canRun = !running && !rangeInvalid && !rangeTooLong && satelliteIds.length > 0; + const busy = running || loadingRun; + const overlayMessage = busy + ? running + ? "Идёт расчёт…" + : "Загрузка плана…" : rangeInvalid ? "Некорректный интервал: «С» должно быть раньше «По»." : modes.length === 0 - ? "Задайте интервал и нажмите «Рассчитать», чтобы построить комплексный план." + ? "Задайте интервал и нажмите «Рассчитать» либо выберите построенный план из истории." : undefined; + const displayRun = selectedRun; + const contoursCount = modePolygons.length; + return (
@@ -120,11 +224,38 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) + +
- {result && ( + {displayRun && (
- run #{result.runId} · режимов {result.calculatedModesCount} · охват {formatPercent(result.coveredAreaPercent)} - {result.snapshotIds.length > 0 ? ` · snapshot ${result.snapshotIds.join(", ")}` : ""} + run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} + {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров + {showRequests ? ` · заявок ${requestItems.length}` : ""}
)}
@@ -133,8 +264,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) {overlayMessage &&
{overlayMessage}
} undefined} /> @@ -145,6 +276,14 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps) Нет эксплуатируемых КА на вкладке «Планы» — расчёт недоступен.
)} + {rangeTooLong && ( +
+ Интервал расчёта не может превышать {MAX_COMPLEX_PLAN_RANGE_DAYS} суток — сократите диапазон «С/По». +
+ )} + {requestsLoading &&
Загрузка заявок…
} + {requestsError &&
{requestsError}
} + {runsError &&
{runsError}
} {notice &&
{notice}
} {error &&
{error}
} @@ -176,6 +315,13 @@ function buildModePolygons(modes: ComplexPlanMode[]): MapPolygon[] { return polygons; } +/** Подпись прогона в выпадающем списке истории: интервал, статус и счётчик режимов. */ +function formatRunOption(run: ComplexPlanRunSummary): string { + const interval = `${formatLocalDateTime(parseLocal(run.intervalStart))} — ${formatLocalDateTime(parseLocal(run.intervalEnd))}`; + const status = run.status === "COMPLETED" ? "" : ` · ${run.status}`; + return `#${run.runId} · ${interval} · режимов ${run.calculatedModesCount}${status}`; +} + function formatPercent(value: number | null | undefined): string { if (value == null || !Number.isFinite(value)) return "—"; return `${value.toFixed(1)}%`; diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx index fd19361..de9a1b2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx @@ -143,6 +143,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, // Идёт серверная операция над всем планом (расчёт съёмки+сброса или полная очистка) — // блокирует повторный запуск и кнопки правок, пока не перечитаем включения. const [planCalcRunning, setPlanCalcRunning] = useState(false); + // Открыт диалог выбора режима очистки плана (всё / оставить ручные съёмки). + const [clearDialogOpen, setClearDialogOpen] = useState(false); const [notice, setNotice] = useState(); const [applyError, setApplyError] = useState(); const [contextSpacecraftIds, setContextSpacecraftIds] = useState>(new Set()); @@ -877,27 +879,28 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, } }; - // Полная очистка плана: сносит ВСЕ включения (съёмки, включая ручные, и сбросы) в БД черновика - // и в локальном представлении. Единственный способ убрать ручные маршруты массово (помимо - // ручного удаления по одному). Подтверждение — действие необратимо. - const clearPlan = async () => { + // Очистка плана. Оператор выбирает в диалоге, что снести: всё (включая ручные маршруты) или + // только рассчитанное (ручные съёмки source=MANUAL остаются, как при пересчёте плана). Сбросы + // удаляются в любом случае. Меняем БД черновика и локальное представление. Необратимо. + const clearPlan = async (keepManual: boolean) => { if (!selectedPlan || planCalcRunning) return; - // window здесь затенён состоянием окна таймлайна — берём confirm из globalThis. - if (!globalThis.confirm("Очистить план полностью? Будут удалены все включения, включая построенные вручную. Действие необратимо.")) { - return; - } + setClearDialogOpen(false); setPlanCalcRunning(true); setApplyError(undefined); setNotice(undefined); try { const draftId = await ensureDraft(); - await clearPlanModes(draftId); + await clearPlanModes(draftId, keepManual); await refreshDraftModes(draftId); changeDraft((current) => ({ ...current, works: [] })); setSelectedWorkId(undefined); setEditingWorkId(undefined); setRightMode("none"); - setNotice("План полностью очищен."); + setNotice( + keepManual + ? "Рассчитанные включения удалены. Ручные маршруты съёмки сохранены." + : "План полностью очищен." + ); } catch (error) { setApplyError(error instanceof Error ? error.message : "Не удалось очистить план."); } finally { @@ -1160,7 +1163,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, }} onCheck={runCheck} onCalculatePlan={() => { void runPlanCalculation(); }} - onClearPlan={() => { void clearPlan(); }} + onClearPlan={() => setClearDialogOpen(true)} onZoom={zoom} /> @@ -1408,6 +1411,48 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange, )} + + {clearDialogOpen && ( +
setClearDialogOpen(false)} + > +
e.stopPropagation()}> +
+

Очистить план

+
+

+ Что удалить из плана? Сбросы удаляются в любом случае. Действие необратимо. +

+
+ + +
+
+ +
+
+
+ )}
); } diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css index bf9e6de..7f86e9f 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-editor.css @@ -1188,3 +1188,36 @@ border: 0; padding: 0; } + +/* Диалог выбора режима очистки плана (всё / оставить ручные съёмки). */ +.tgu-editor-clear-dialog { + width: min(26rem, calc(100vw - 2rem)); +} + +.tgu-editor-clear-dialog__text { + margin: 0; + font-size: 0.82rem; + color: var(--text-muted, #9fb0b3); +} + +.tgu-editor-clear-dialog__choices { + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.tgu-editor-clear-dialog__choices > .tgu-button { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.15rem; + width: 100%; + padding: 0.6rem 0.75rem; + text-align: left; +} + +.tgu-editor-clear-dialog__choices > .tgu-button > small { + font-weight: 400; + font-size: 0.72rem; + opacity: 0.85; +} diff --git a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css index 4eb9fb3..cd56a02 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -102,6 +102,12 @@ color: #eafffd; } +.tgu-button.is-active { + border-color: rgba(104, 195, 189, 0.58); + background: rgba(104, 195, 189, 0.16); + color: var(--accent-strong); +} + .tgu-button--accept { border-color: rgba(47, 158, 68, 0.65); background: rgba(47, 158, 68, 0.16);