diff --git a/services/pcp-tgu-ops-ui/src/api/tguApi.ts b/services/pcp-tgu-ops-ui/src/api/tguApi.ts index 05c406e..2f9cb82 100644 --- a/services/pcp-tgu-ops-ui/src/api/tguApi.ts +++ b/services/pcp-tgu-ops-ui/src/api/tguApi.ts @@ -67,6 +67,17 @@ export async function sendPlanDecision( ); } +/** + * ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ. + * decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»). + */ +export async function confirmPlanLayin(planId: string): Promise { + await fetchJson( + `/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`, + { method: "POST" } + ); +} + async function fetchJson(url: string, init?: RequestInit, decisionRequest = false): Promise { let response: Response; try { 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 9761f41..4f9bdd8 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 @@ -34,6 +34,10 @@ type TguPlanDetailsProps = { onDeleteDraft?: (planId: string) => void; /** Создать черновик-копию на основе этого плана (replace, окно 1:1, все включения). */ onCreateDraftFromPlan?: (planId: string) => void; + /** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */ + onConfirmLayin?: (planId: string) => void; + /** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */ + layinConflict?: boolean; onClose: () => void; }; @@ -50,6 +54,8 @@ export function TguPlanDetails({ onPromoteDraft, onDeleteDraft, onCreateDraftFromPlan, + onConfirmLayin, + layinConflict, onClose }: TguPlanDetailsProps) { if (!plan) { @@ -89,6 +95,11 @@ export function TguPlanDetails({ onDeleteDraft?.(plan.planId); }; + const confirmLayin = () => { + if (!window.confirm("Подтвердить закладку плана? Он станет базой цепочки (LAID_IN). Вы уверены?")) return; + onConfirmLayin?.(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 71d41af..915a622 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,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi"; +import { confirmPlanLayin, fetchPlans, fetchPlatforms, 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"; @@ -276,6 +276,36 @@ export function TguPlanningPage() { } }; + // ТЕСТ: подтвердить закладку выбранного плана (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); @@ -305,8 +335,18 @@ export function TguPlanningPage() { // Это путь «сделать исправленный план», а не повторная отправка того же. В редактор сами // не уходим — оператор выберет черновик и перейдёт в «Редактор» вручную. const handleCreateDraftFromPlan = async (planId: string) => { - // Один открытый черновик на базу: если он уже есть — выбираем его, второй не плодим. - const existing = drafts.find((item) => item.basePlanId === planId); + // Один открытый черновик на слот (КА + окно): 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); @@ -447,6 +487,8 @@ export function TguPlanningPage() { onPromoteDraft={handlePromoteDraft} onDeleteDraft={handleDeleteDraft} onCreateDraftFromPlan={handleCreateDraftFromPlan} + onConfirmLayin={handleConfirmLayin} + layinConflict={layinConflict} onClose={() => 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 8e3cc43..1946917 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 @@ -15,6 +15,21 @@ import { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } fro export type ZrvWindow = { startMs: number; endMs: number; kppId: string }; +// Терминально-«мёртвые» статусы: план выбыл из живой цепочки (вытеснен закладкой, отклонён, +// не принят, сбой закладки, неоднозначный старт). Применяется ТОЛЬКО к «следующему» (обратная +// ссылка basePlanId → выбранный, она неоднозначна): мёртвый план — это, как правило, коррекция, +// проигравшая гонку закладки и оставшаяся со ссылкой base_plan_id на победителя (особенно старые +// строки до наследования предшественника), и без фильтра она ложно показывалась бы «следующей». +// На «предыдущий» НЕ распространяется: это явная база самого выбранного плана (forward-ссылка из +// его поля basePlanId) — её показываем всегда, даже если база REJECTED/SUPERSEDED. +const DEAD_LINK_STATUSES: ReadonlySet = new Set([ + "SUPERSEDED", + "REJECTED", + "LAYIN_FAILED", + "NOT_ACCEPTED", + "START_AMBIGUOUS" +]); + type ZrvBands = { spacecraftId: string; windows: ZrvWindow[]; @@ -207,19 +222,26 @@ export function TguTimeline({ const selected = selectedPlanId === segment.plan.planId; // Сосед выбранного по цепочке: предыдущий (прошлое) — planId == basePlanId выбранного; // следующий (будущее) — basePlanId == выбранный. Подсветка по реальной связи, не по времени. + // prev — явная база выбранного плана (forward-ссылка), показываем всегда, даже мёртвую. + // next — обратная ссылка; мёртвый план (вытесненный/отклонённый конкурент того же слота) + // живым продолжением не подсвечиваем. + const liveLink = !DEAD_LINK_STATUSES.has(segment.plan.status); const neighbor = selected ? undefined : selectedBasePlanId && segment.plan.planId === selectedBasePlanId ? "prev" - : selectedPlanId && segment.plan.basePlanId === selectedPlanId + : selectedPlanId && liveLink && segment.plan.basePlanId === selectedPlanId ? "next" : undefined; const labelVisible = segment.width > 92; + // При активном выборе все «непричастные» планы гасим до еле заметных, а текущий / + // прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана. + const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined; return ( onSelectPlan(segment.plan.planId)} > diff --git a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts index c9a4b31..c71c2f9 100644 --- a/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts +++ b/services/pcp-tgu-ops-ui/src/features/tgu-planning/tguStatus.ts @@ -3,8 +3,8 @@ import type { TimelineStatusStyle } from "../../model/timelineTypes"; // Порядок — по жизненному циклу плана (см. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md). // ACCEPTED из enum бэка здесь намеренно отсутствует: приёмка переводит план сразу в AWAITING_LAYIN. -// LAID_IN показан, хотя пока не возникает — нужен обработчик события подтверждения закладки -// (AWAITING_LAYIN → LAID_IN), см. TODO в HandlePlanDecisionUseCase и док жизненного цикла. +// LAID_IN возникает по событию подтверждения закладки (AWAITING_LAYIN → LAID_IN) и делает план базой +// цепочки; конкуренты на то же окно при этом уходят в SUPERSEDED. export const STATUS_STYLES: TimelineStatusStyle[] = [ { status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft", @@ -45,6 +45,10 @@ export const STATUS_STYLES: TimelineStatusStyle[] = [ { status: "LAYIN_FAILED", label: "LAYIN_FAILED", color: "#a61e4d", className: "status-layin-failed", description: "План не заложен, идёт срочная перевыдача на ближайшую ЗРВ." + }, + { + status: "SUPERSEDED", label: "SUPERSEDED", color: "#6b7280", className: "status-superseded", + description: "Вытеснён: по тому же окну заложен другой план (коррекция). Терминальный." } ]; 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 aafc25c..6a47bd8 100644 --- a/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css +++ b/services/pcp-tgu-ops-ui/src/styles/tgu-planning.css @@ -180,6 +180,10 @@ background-color: #a61e4d; } +.status-superseded { + background-color: #6b7280; +} + .status-waiting { background-color: #f0ad2e; } @@ -433,6 +437,13 @@ .tgu-timeline-plan { cursor: pointer; + transition: opacity 0.12s ease; +} + +/* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются + яркие рамки текущего/прошлого/следующего звена цепочки. */ +.tgu-timeline-plan.is-dimmed { + opacity: 0.16; } .tgu-timeline-plan__bar { @@ -445,26 +456,27 @@ stroke-dasharray: 7 5; } +/* Яркая рамка ТЕКУЩЕГО (выбранного) плана — blue-600. */ .tgu-timeline-plan__selection { - stroke: #efffff; + stroke: #2563eb; stroke-linecap: round; stroke-width: 19; - opacity: 0.85; + opacity: 1; } -/* Гало вокруг соседнего по цепочке плана при выборе: прошлое — тёплый янтарь, будущее — бирюза. */ +/* Яркая рамка соседнего по цепочке плана: прошлый — slate-400, будущий — amber-500. */ .tgu-timeline-plan__neighbor { stroke-linecap: round; stroke-width: 19; - opacity: 0.7; + opacity: 1; } .tgu-timeline-plan__neighbor--prev { - stroke: #e0a23c; + stroke: #94a3b8; } .tgu-timeline-plan__neighbor--next { - stroke: #46b3c4; + stroke: #f59e0b; } .tgu-timeline-plan__label { @@ -575,6 +587,29 @@ flex-wrap: wrap; } +/* Тестовый блок — отделён сверху, с предупреждающей подписью «ТЕСТОВЫЙ ФУНКЦИОНАЛ». */ +.tgu-details__test-block { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-top: 0.9rem; + padding-top: 0.7rem; + border-top: 1px dashed var(--line); +} + +.tgu-details__test-label { + font-size: 0.66rem; + font-weight: 700; + letter-spacing: 0.08em; + color: #d97706; +} + +.tgu-details__test-hint { + font-size: 0.74rem; + line-height: 1.3; + color: var(--muted, #79828d); +} + .tgu-alert { border: 1px solid var(--line); border-radius: 7px;