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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user