diff --git a/services/pcp-tgu-ops-ui/src/api/tguApi.ts b/services/pcp-tgu-ops-ui/src/api/tguApi.ts index 2f9cb82..61384b8 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguApi.ts @@ -67,6 +67,25 @@ export async function sendPlanDecision( ); } +/** + * «Снять с учёта» терминально-негативный план: он перестаёт считаться проблемой и красить + * индикатор КА. Статус и цепочку не трогает; обратимо ([restorePlan]). 422 — статус не снимаемый. + */ +export async function dismissPlan(planId: string): Promise { + await fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`, + { method: "POST" } + ); +} + +/** «Вернуть на учёт» ранее снятый план. */ +export async function restorePlan(planId: string): Promise { + await fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`, + { method: "POST" } + ); +} + /** * ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ. * decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»). diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx index 4f9bdd8..f46afa2 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanDetails.tsx @@ -11,6 +11,9 @@ import { formatLocalDateTime, parseLocal } from "../../utils/localTime"; // config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes. const DECISION_GRACE_MINUTES = 30; +/** Терминально-негативные статусы, которые можно «снять с учёта» (синхронно с бэком DismissPlanUseCase). */ +const DISMISSIBLE_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS", "LAYIN_FAILED", "NOT_ACCEPTED", "SUPERSEDED"]); + /** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */ export type PlanModeCounts = { survey: number; @@ -38,6 +41,10 @@ type TguPlanDetailsProps = { onConfirmLayin?: (planId: string) => void; /** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */ layinConflict?: boolean; + /** «Снять с учёта» терминально-негативный план (чтобы не красил индикатор КА). */ + onDismissPlan?: (planId: string) => void; + /** «Вернуть на учёт» ранее снятый план. */ + onRestorePlan?: (planId: string) => void; onClose: () => void; }; @@ -56,6 +63,8 @@ export function TguPlanDetails({ onCreateDraftFromPlan, onConfirmLayin, layinConflict, + onDismissPlan, + onRestorePlan, onClose }: TguPlanDetailsProps) { if (!plan) { @@ -100,6 +109,13 @@ export function TguPlanDetails({ onConfirmLayin?.(plan.planId); }; + const dismissed = Boolean(plan.dismissedAt); + const canDismiss = DISMISSIBLE_STATUSES.has(plan.status); + const dismiss = () => { + if (!window.confirm("Снять план с учёта? Он перестанет считаться проблемой и не будет красить КА. Статус и цепочку это не меняет; можно вернуть на учёт.")) return; + onDismissPlan?.(plan.planId); + }; + return ( ); } diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx index aa69261..f00b9c1 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx @@ -1,5 +1,12 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { confirmPlanLayin, fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi"; +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 { TguEditorTab } from "../tgu-editor/TguEditorTab"; @@ -389,6 +396,37 @@ export function TguPlanningPage() { } }; + // Снять/вернуть план с учёта: косметико-статистический флаг, не трогает статус и цепочку. + 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 ( setSelectedPlanId(undefined)} /> )} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx index 1946917..ef44fbf 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguTimeline.tsx @@ -237,15 +237,20 @@ export function TguTimeline({ // При активном выборе все «непричастные» планы гасим до еле заметных, а текущий / // прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана. const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined; + // Снят с учёта: гасим план на таймлайне (но не сильнее, чем выбор-затемнение). + const dismissed = Boolean(segment.plan.dismissedAt); return ( onSelectPlan(segment.plan.planId)} > {segment.plan.planId} · {segment.plan.status} + {dismissed ? " · снят с учёта" : ""} {neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "} {formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)} diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts index eb5f1bd..f48f9ed 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes"; import type { TguPlanUi } from "../../model/timelineTypes"; -import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper"; +import { + isOperationalPlatform, + operationalSpacecraftIds, + pausedSpacecraftIds, + unresolvedProblemSpacecraftIds +} from "./tguTimelineMapper"; import { toLocalIso } from "../../utils/localTime"; function platform(overrides: Partial): TguPlatform { @@ -78,3 +83,44 @@ describe("pausedSpacecraftIds", () => { expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set()); }); }); + +describe("unresolvedProblemSpacecraftIds (красный индикатор КА)", () => { + const t0 = Date.UTC(2026, 5, 4, 0, 0, 0); + const hour = 3_600_000; + + function dismissed(plan: TguPlanUi): TguPlanUi { + return { ...plan, dismissedAt: toLocalIso(t0) }; + } + + it("красит КА, если есть нерешённый REJECTED", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "REJECTED", t0)])).toEqual(new Set(["100"])); + }); + + it("красит КА на START_AMBIGUOUS", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "START_AMBIGUOUS", t0)])).toEqual(new Set(["100"])); + }); + + it("НЕ красит, если проблемный план снят с учёта", () => { + expect(unresolvedProblemSpacecraftIds([dismissed(uiPlan("100", "REJECTED", t0))])).toEqual(new Set()); + }); + + it("НЕ красит, если цепочка продвинулась за отказ (исправленный план в полёте)", () => { + const ids = unresolvedProblemSpacecraftIds([ + uiPlan("100", "REJECTED", t0), + uiPlan("100", "WAITING_DECISION", t0 + hour) + ]); + expect(ids.has("100")).toBe(false); + }); + + it("красит, если продвинувший план раньше отказа (отказ всё ещё актуален)", () => { + const ids = unresolvedProblemSpacecraftIds([ + uiPlan("100", "REJECTED", t0 + 5 * hour), + uiPlan("100", "AWAITING_LAYIN", t0) + ]); + expect(ids).toEqual(new Set(["100"])); + }); + + it("успешные/живые статусы сами по себе не красят", () => { + expect(unresolvedProblemSpacecraftIds([uiPlan("100", "LAID_IN", t0)])).toEqual(new Set()); + }); +}); diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts index c3881ed..52bf02d 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguTimelineMapper.ts @@ -47,28 +47,28 @@ export function buildPlatformsForInterval( ): TguPlatformUi[] { const intervalPlans = filterPlansByRange(plans, range); const platformByNorad = buildPlatformByNorad(platforms); - const planStats = new Map(); + // Красный индикатор КА = есть НЕразрешённый проблемный план (см. unresolvedProblemSpacecraftIds): + // отказ, который оператор ещё не обработал (цепочка не продвинулась) и не снял с учёта. Прежняя + // логика «есть хоть один REJECTED» красила КА вечно — даже после исправления или снятия с учёта. + const problemSpacecraft = unresolvedProblemSpacecraftIds(intervalPlans); + const planCount = new Map(); for (const plan of intervalPlans) { - const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false }; - stats.count += 1; - stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status); - planStats.set(plan.spacecraftId, stats); + planCount.set(plan.spacecraftId, (planCount.get(plan.spacecraftId) ?? 0) + 1); } - const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => { - const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false }; - return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus); - }); + const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => + toPlatformUi(spacecraftId, platform, planCount.get(spacecraftId) ?? 0, problemSpacecraft.has(spacecraftId)) + ); const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId)); - for (const [spacecraftId, stats] of planStats.entries()) { + for (const [spacecraftId, count] of planCount.entries()) { if (!knownSpacecraft.has(spacecraftId)) { platformItems.push({ spacecraftId, noradId: spacecraftId, - planCount: stats.count, - hasProblemStatus: stats.hasProblemStatus + planCount: count, + hasProblemStatus: problemSpacecraft.has(spacecraftId) }); } } @@ -95,6 +95,32 @@ const CHAIN_ADVANCED_STATUSES = new Set([ * отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) — * оператор должен вручную создать исправленный план. */ +/** + * КА с НЕразрешённой проблемой — для красного индикатора. Проблемный план (REJECTED/START_AMBIGUOUS) + * считается разрешённым и НЕ красит КА, если он либо снят с учёта (`dismissedAt`), либо цепочка уже + * продвинулась за него (есть [CHAIN_ADVANCED_STATUSES]-план со стартом не раньше). Так исправленные + * и снятые с учёта отказы гаснут сами, а реальные нерешённые — остаются красными. + */ +export function unresolvedProblemSpacecraftIds(plans: TguPlanUi[]): Set { + const latestAdvancedStart = new Map(); + for (const plan of plans) { + if (CHAIN_ADVANCED_STATUSES.has(plan.status)) { + const prev = latestAdvancedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY; + if (plan.startMs > prev) latestAdvancedStart.set(plan.spacecraftId, plan.startMs); + } + } + + const unresolved = new Set(); + for (const plan of plans) { + if (!PROBLEM_STATUSES.has(plan.status)) continue; + if (plan.dismissedAt) continue; // снят с учёта оператором + const advancedStart = latestAdvancedStart.get(plan.spacecraftId); + if (advancedStart !== undefined && advancedStart >= plan.startMs) continue; // цепочка ушла дальше + unresolved.add(plan.spacecraftId); + } + return unresolved; +} + export function pausedSpacecraftIds(plans: TguPlanUi[]): Set { const latestRejectedStart = new Map(); for (const plan of plans) { diff --git a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts index 871a3df..48a5363 100644 --- a/services/pcp-tgu-ops-ui/src/model/tguTypes.ts +++ b/services/pcp-tgu-ops-ui/src/model/tguTypes.ts @@ -23,6 +23,11 @@ export type TguPlan = { basePlanId?: string | null; /** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */ projected?: boolean; + /** + * Время «снятия с учёта» (ISO без зоны) терминально-негативного плана. `null`/отсутствует — план + * на учёте. Снятый план гасится на таймлайне и не считается проблемой в индикаторе КА. + */ + dismissedAt?: string | null; }; export type TguPlatform = { 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 6a47bd8..4eb9fb3 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -440,6 +440,15 @@ transition: opacity 0.12s ease; } +/* Снят с учёта: приглушаем план и делаем штриховку — виден, но явно «второстепенный». + Объявлен ВЫШЕ is-dimmed, чтобы при активном выборе сильное выбор-затемнение перебивало это. */ +.tgu-timeline-plan.is-dismissed-plan { + opacity: 0.4; +} +.tgu-timeline-plan.is-dismissed-plan .tgu-timeline-plan__bar { + stroke-dasharray: 3 4; +} + /* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются яркие рамки текущего/прошлого/следующего звена цепочки. */ .tgu-timeline-plan.is-dimmed { @@ -556,6 +565,18 @@ font-weight: 800; } +.tgu-details__pills { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; +} + +/* Бейдж «снят с учёта» рядом со статусом: нейтральный серый, чтобы не путать со статусом. */ +.tgu-status-pill--dismissed { + background: #475569; + color: #e2e8f0; +} + .tgu-details-grid { display: grid; gap: 0.72rem;