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