pcp-tgu-ops-ui: тест-кнопка закладки, статус SUPERSEDED, подсветка цепочки

- Тест-кнопка «Подтвердить закладку» в деталях плана для AWAITING_LAYIN
  (под подписью «ТЕСТОВЫЙ ФУНКЦИОНАЛ», с подтверждением). Гасится, если по
  КА уже заложен план со стартом в пределах ±10 мин (дублирует бэкенд-гард).
- Статус SUPERSEDED: стиль/подпись/легенда.
- Поиск уже созданного черновика — по слоту (КА + окно исходного плана),
  т.к. basePlanId черновика теперь = предшественник, а не исходный план.
- Таймлайн: при выборе плана непричастные планы гаснут, а текущий/прошлый/
  следующий обводятся яркими рамками (current #2563EB, prev #94A3B8,
  next #F59E0B). Асимметрия: prev (явная база выбранного) показывается
  всегда, даже REJECTED/SUPERSEDED; next (обратная ссылка) не подсвечивает
  мёртвые статусы — иначе вытесненная коррекция ложно числилась бы
  «следующей» у заложенного плана.
This commit is contained in:
Дмитрий Соловьев
2026-06-04 16:13:13 +03:00
parent 87905311b1
commit faadc63028
6 changed files with 162 additions and 13 deletions
+11
View File
@@ -67,6 +67,17 @@ export async function sendPlanDecision(
); );
} }
/**
* ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ.
* decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»).
*/
export async function confirmPlanLayin(planId: string): Promise<void> {
await fetchJson<void>(
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/confirm-layin`,
{ method: "POST" }
);
}
async function fetchJson<T>(url: string, init?: RequestInit, decisionRequest = false): Promise<T> { async function fetchJson<T>(url: string, init?: RequestInit, decisionRequest = false): Promise<T> {
let response: Response; let response: Response;
try { try {
@@ -34,6 +34,10 @@ type TguPlanDetailsProps = {
onDeleteDraft?: (planId: string) => void; onDeleteDraft?: (planId: string) => void;
/** Создать черновик-копию на основе этого плана (replace, окно 1:1, все включения). */ /** Создать черновик-копию на основе этого плана (replace, окно 1:1, все включения). */
onCreateDraftFromPlan?: (planId: string) => void; onCreateDraftFromPlan?: (planId: string) => void;
/** ТЕСТ: подтвердить закладку плана (AWAITING_LAYIN → LAID_IN). */
onConfirmLayin?: (planId: string) => void;
/** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */
layinConflict?: boolean;
onClose: () => void; onClose: () => void;
}; };
@@ -50,6 +54,8 @@ export function TguPlanDetails({
onPromoteDraft, onPromoteDraft,
onDeleteDraft, onDeleteDraft,
onCreateDraftFromPlan, onCreateDraftFromPlan,
onConfirmLayin,
layinConflict,
onClose onClose
}: TguPlanDetailsProps) { }: TguPlanDetailsProps) {
if (!plan) { if (!plan) {
@@ -89,6 +95,11 @@ export function TguPlanDetails({
onDeleteDraft?.(plan.planId); onDeleteDraft?.(plan.planId);
}; };
const confirmLayin = () => {
if (!window.confirm("Подтвердить закладку плана? Он станет базой цепочки (LAID_IN). Вы уверены?")) return;
onConfirmLayin?.(plan.planId);
};
return ( return (
<aside className="tgu-details"> <aside className="tgu-details">
<div className="tgu-details__header"> <div className="tgu-details__header">
@@ -195,6 +206,30 @@ export function TguPlanDetails({
</button> </button>
</div> </div>
)} )}
{plan.status === "AWAITING_LAYIN" && onConfirmLayin && (
<div className="tgu-details__test-block">
<div className="tgu-details__test-label">ТЕСТОВЫЙ ФУНКЦИОНАЛ</div>
<button
className="tgu-button tgu-button--accept"
type="button"
disabled={decisionInFlight || layinConflict}
title={
layinConflict
? "По КА уже заложен план со стартом в пределах ±10 минут — закладка недоступна"
: "Сымитировать событие «план заложен» (AWAITING_LAYIN → LAID_IN)"
}
onClick={confirmLayin}
>
Подтвердить закладку
</button>
{layinConflict && (
<div className="tgu-details__test-hint">
По КА уже есть заложенный план со стартом в пределах ±10 минут подтвердить нельзя.
</div>
)}
</div>
)}
</aside> </aside>
); );
} }
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useState } from "react"; 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 { TguPlan, TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes"; import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguEditorTab } from "../tgu-editor/TguEditorTab"; 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 handleEditDraft = (planId: string) => {
const draft = drafts.find((item) => item.planId === planId); const draft = drafts.find((item) => item.planId === planId);
@@ -305,8 +335,18 @@ export function TguPlanningPage() {
// Это путь «сделать исправленный план», а не повторная отправка того же. В редактор сами // Это путь «сделать исправленный план», а не повторная отправка того же. В редактор сами
// не уходим — оператор выберет черновик и перейдёт в «Редактор» вручную. // не уходим — оператор выберет черновик и перейдёт в «Редактор» вручную.
const handleCreateDraftFromPlan = async (planId: string) => { const handleCreateDraftFromPlan = async (planId: string) => {
// Один открытый черновик на базу: если он уже есть — выбираем его, второй не плодим. // Один открытый черновик на слот (КА + окно): replace-черновик копирует окно исходного плана
const existing = drafts.find((item) => item.basePlanId === planId); // 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) { if (existing) {
setShowDrafts(true); setShowDrafts(true);
setSelectedSpacecraftId(existing.spacecraftId); setSelectedSpacecraftId(existing.spacecraftId);
@@ -447,6 +487,8 @@ export function TguPlanningPage() {
onPromoteDraft={handlePromoteDraft} onPromoteDraft={handlePromoteDraft}
onDeleteDraft={handleDeleteDraft} onDeleteDraft={handleDeleteDraft}
onCreateDraftFromPlan={handleCreateDraftFromPlan} onCreateDraftFromPlan={handleCreateDraftFromPlan}
onConfirmLayin={handleConfirmLayin}
layinConflict={layinConflict}
onClose={() => setSelectedPlanId(undefined)} onClose={() => setSelectedPlanId(undefined)}
/> />
)} )}
@@ -15,6 +15,21 @@ import { formatLocalDate, formatLocalDateTime, formatLocalTime, parseLocal } fro
export type ZrvWindow = { startMs: number; endMs: number; kppId: string }; export type ZrvWindow = { startMs: number; endMs: number; kppId: string };
// Терминально-«мёртвые» статусы: план выбыл из живой цепочки (вытеснен закладкой, отклонён,
// не принят, сбой закладки, неоднозначный старт). Применяется ТОЛЬКО к «следующему» (обратная
// ссылка basePlanId → выбранный, она неоднозначна): мёртвый план — это, как правило, коррекция,
// проигравшая гонку закладки и оставшаяся со ссылкой base_plan_id на победителя (особенно старые
// строки до наследования предшественника), и без фильтра она ложно показывалась бы «следующей».
// На «предыдущий» НЕ распространяется: это явная база самого выбранного плана (forward-ссылка из
// его поля basePlanId) — её показываем всегда, даже если база REJECTED/SUPERSEDED.
const DEAD_LINK_STATUSES: ReadonlySet<string> = new Set([
"SUPERSEDED",
"REJECTED",
"LAYIN_FAILED",
"NOT_ACCEPTED",
"START_AMBIGUOUS"
]);
type ZrvBands = { type ZrvBands = {
spacecraftId: string; spacecraftId: string;
windows: ZrvWindow[]; windows: ZrvWindow[];
@@ -207,19 +222,26 @@ export function TguTimeline({
const selected = selectedPlanId === segment.plan.planId; const selected = selectedPlanId === segment.plan.planId;
// Сосед выбранного по цепочке: предыдущий (прошлое) — planId == basePlanId выбранного; // Сосед выбранного по цепочке: предыдущий (прошлое) — planId == basePlanId выбранного;
// следующий (будущее) — basePlanId == выбранный. Подсветка по реальной связи, не по времени. // следующий (будущее) — basePlanId == выбранный. Подсветка по реальной связи, не по времени.
// prev — явная база выбранного плана (forward-ссылка), показываем всегда, даже мёртвую.
// next — обратная ссылка; мёртвый план (вытесненный/отклонённый конкурент того же слота)
// живым продолжением не подсвечиваем.
const liveLink = !DEAD_LINK_STATUSES.has(segment.plan.status);
const neighbor = selected const neighbor = selected
? undefined ? undefined
: selectedBasePlanId && segment.plan.planId === selectedBasePlanId : selectedBasePlanId && segment.plan.planId === selectedBasePlanId
? "prev" ? "prev"
: selectedPlanId && segment.plan.basePlanId === selectedPlanId : selectedPlanId && liveLink && segment.plan.basePlanId === selectedPlanId
? "next" ? "next"
: undefined; : undefined;
const labelVisible = segment.width > 92; const labelVisible = segment.width > 92;
// При активном выборе все «непричастные» планы гасим до еле заметных, а текущий /
// прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана.
const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined;
return ( return (
<g <g
key={segment.plan.planId} key={segment.plan.planId}
className={`tgu-timeline-plan ${selected ? "is-selected" : ""}`} className={`tgu-timeline-plan ${selected ? "is-selected" : ""} ${dimmed ? "is-dimmed" : ""}`}
onClick={() => onSelectPlan(segment.plan.planId)} onClick={() => onSelectPlan(segment.plan.planId)}
> >
<title> <title>
@@ -3,8 +3,8 @@ import type { TimelineStatusStyle } from "../../model/timelineTypes";
// Порядок — по жизненному циклу плана (см. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md). // Порядок — по жизненному циклу плана (см. docs/architect/PCP_TGU_PLAN_STATUS_LIFECYCLE.md).
// ACCEPTED из enum бэка здесь намеренно отсутствует: приёмка переводит план сразу в AWAITING_LAYIN. // ACCEPTED из enum бэка здесь намеренно отсутствует: приёмка переводит план сразу в AWAITING_LAYIN.
// LAID_IN показан, хотя пока не возникает — нужен обработчик события подтверждения закладки // LAID_IN возникает по событию подтверждения закладки (AWAITING_LAYIN → LAID_IN) и делает план базой
// (AWAITING_LAYIN → LAID_IN), см. TODO в HandlePlanDecisionUseCase и док жизненного цикла. // цепочки; конкуренты на то же окно при этом уходят в SUPERSEDED.
export const STATUS_STYLES: TimelineStatusStyle[] = [ export const STATUS_STYLES: TimelineStatusStyle[] = [
{ {
status: "DRAFT", label: "ЧЕРНОВИК", color: "#a98bdb", className: "status-draft", 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", status: "LAYIN_FAILED", label: "LAYIN_FAILED", color: "#a61e4d", className: "status-layin-failed",
description: "План не заложен, идёт срочная перевыдача на ближайшую ЗРВ." description: "План не заложен, идёт срочная перевыдача на ближайшую ЗРВ."
},
{
status: "SUPERSEDED", label: "SUPERSEDED", color: "#6b7280", className: "status-superseded",
description: "Вытеснён: по тому же окну заложен другой план (коррекция). Терминальный."
} }
]; ];
@@ -180,6 +180,10 @@
background-color: #a61e4d; background-color: #a61e4d;
} }
.status-superseded {
background-color: #6b7280;
}
.status-waiting { .status-waiting {
background-color: #f0ad2e; background-color: #f0ad2e;
} }
@@ -433,6 +437,13 @@
.tgu-timeline-plan { .tgu-timeline-plan {
cursor: pointer; cursor: pointer;
transition: opacity 0.12s ease;
}
/* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются
яркие рамки текущего/прошлого/следующего звена цепочки. */
.tgu-timeline-plan.is-dimmed {
opacity: 0.16;
} }
.tgu-timeline-plan__bar { .tgu-timeline-plan__bar {
@@ -445,26 +456,27 @@
stroke-dasharray: 7 5; stroke-dasharray: 7 5;
} }
/* Яркая рамка ТЕКУЩЕГО (выбранного) плана — blue-600. */
.tgu-timeline-plan__selection { .tgu-timeline-plan__selection {
stroke: #efffff; stroke: #2563eb;
stroke-linecap: round; stroke-linecap: round;
stroke-width: 19; stroke-width: 19;
opacity: 0.85; opacity: 1;
} }
/* Гало вокруг соседнего по цепочке плана при выборе: прошлое — тёплый янтарь, будущеебирюза. */ /* Яркая рамка соседнего по цепочке плана: прошлый — slate-400, будущийamber-500. */
.tgu-timeline-plan__neighbor { .tgu-timeline-plan__neighbor {
stroke-linecap: round; stroke-linecap: round;
stroke-width: 19; stroke-width: 19;
opacity: 0.7; opacity: 1;
} }
.tgu-timeline-plan__neighbor--prev { .tgu-timeline-plan__neighbor--prev {
stroke: #e0a23c; stroke: #94a3b8;
} }
.tgu-timeline-plan__neighbor--next { .tgu-timeline-plan__neighbor--next {
stroke: #46b3c4; stroke: #f59e0b;
} }
.tgu-timeline-plan__label { .tgu-timeline-plan__label {
@@ -575,6 +587,29 @@
flex-wrap: wrap; 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 { .tgu-alert {
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 7px; border-radius: 7px;