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> {
let response: Response;
try {
@@ -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 (
<aside className="tgu-details">
<div className="tgu-details__header">
@@ -195,6 +206,30 @@ export function TguPlanDetails({
</button>
</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>
);
}
@@ -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)}
/>
)}
@@ -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<string> = 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 (
<g
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)}
>
<title>
@@ -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: "Вытеснён: по тому же окну заложен другой план (коррекция). Терминальный."
}
];
@@ -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;