pcp-tgu-ops-ui: снятие плана с учёта + индикатор КА = нерешённый отказ
Индикатор «проблема КА» теперь означает НЕразрешённый отказ, а не «есть хоть один REJECTED»: unresolvedProblemSpacecraftIds не красит КА, если проблемный план снят с учёта (dismissedAt) либо цепочка уже продвинулась за него (advanced-план со стартом не раньше). Исправленные и снятые отказы гаснут сами. - кнопка «Снять с учёта» / «Вернуть на учёт» в деталях плана для терминально-негативных статусов + бейдж «снят с учёта»; - dismissPlan/restorePlan (через BFF), поле dismissedAt в модели; - снятый план приглушён и штрихуется на таймлайне; - тесты на unresolvedProblemSpacecraftIds.
This commit is contained in:
@@ -67,6 +67,25 @@ export async function sendPlanDecision(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* «Снять с учёта» терминально-негативный план: он перестаёт считаться проблемой и красить
|
||||
* индикатор КА. Статус и цепочку не трогает; обратимо ([restorePlan]). 422 — статус не снимаемый.
|
||||
*/
|
||||
export async function dismissPlan(planId: string): Promise<void> {
|
||||
await fetchJson<void>(
|
||||
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/dismiss`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
/** «Вернуть на учёт» ранее снятый план. */
|
||||
export async function restorePlan(planId: string): Promise<void> {
|
||||
await fetchJson<void>(
|
||||
`/api/tgu-planning/plans/${encodeURIComponent(planId)}/restore`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ТЕСТ: подтверждает закладку плана (AWAITING_LAYIN → LAID_IN) через тест-контроллер ТГУ.
|
||||
* decisionRequest=false — пробрасываем реальный текст ошибки бэка (напр. гард «закладка ±10 мин»).
|
||||
|
||||
@@ -11,6 +11,9 @@ import { formatLocalDateTime, parseLocal } from "../../utils/localTime";
|
||||
// config-repo/pcp-tgu-service.yaml → tgu.planning.waiting-decision-grace-minutes.
|
||||
const DECISION_GRACE_MINUTES = 30;
|
||||
|
||||
/** Терминально-негативные статусы, которые можно «снять с учёта» (синхронно с бэком DismissPlanUseCase). */
|
||||
const DISMISSIBLE_STATUSES = new Set(["REJECTED", "START_AMBIGUOUS", "LAYIN_FAILED", "NOT_ACCEPTED", "SUPERSEDED"]);
|
||||
|
||||
/** Состав включений плана: сколько съёмок и сбросов. undefined — ещё грузится / неизвестно. */
|
||||
export type PlanModeCounts = {
|
||||
survey: number;
|
||||
@@ -38,6 +41,10 @@ type TguPlanDetailsProps = {
|
||||
onConfirmLayin?: (planId: string) => void;
|
||||
/** Нельзя подтвердить закладку: по КА уже заложен план со стартом в пределах ±10 минут. */
|
||||
layinConflict?: boolean;
|
||||
/** «Снять с учёта» терминально-негативный план (чтобы не красил индикатор КА). */
|
||||
onDismissPlan?: (planId: string) => void;
|
||||
/** «Вернуть на учёт» ранее снятый план. */
|
||||
onRestorePlan?: (planId: string) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
@@ -56,6 +63,8 @@ export function TguPlanDetails({
|
||||
onCreateDraftFromPlan,
|
||||
onConfirmLayin,
|
||||
layinConflict,
|
||||
onDismissPlan,
|
||||
onRestorePlan,
|
||||
onClose
|
||||
}: TguPlanDetailsProps) {
|
||||
if (!plan) {
|
||||
@@ -100,6 +109,13 @@ export function TguPlanDetails({
|
||||
onConfirmLayin?.(plan.planId);
|
||||
};
|
||||
|
||||
const dismissed = Boolean(plan.dismissedAt);
|
||||
const canDismiss = DISMISSIBLE_STATUSES.has(plan.status);
|
||||
const dismiss = () => {
|
||||
if (!window.confirm("Снять план с учёта? Он перестанет считаться проблемой и не будет красить КА. Статус и цепочку это не меняет; можно вернуть на учёт.")) return;
|
||||
onDismissPlan?.(plan.planId);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="tgu-details">
|
||||
<div className="tgu-details__header">
|
||||
@@ -112,7 +128,10 @@ export function TguPlanDetails({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
|
||||
<div className="tgu-details__pills">
|
||||
<div className={`tgu-status-pill ${style.className}`}>{style.label}</div>
|
||||
{dismissed && <div className="tgu-status-pill tgu-status-pill--dismissed">снят с учёта</div>}
|
||||
</div>
|
||||
|
||||
{notice && <div className="tgu-alert tgu-alert--success">{notice}</div>}
|
||||
{error && <div className="tgu-alert tgu-alert--danger">{error}</div>}
|
||||
@@ -230,6 +249,36 @@ export function TguPlanDetails({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canDismiss && (
|
||||
dismissed
|
||||
? onRestorePlan && (
|
||||
<div className="tgu-details__actions">
|
||||
<button
|
||||
className="tgu-button"
|
||||
type="button"
|
||||
disabled={draftActionInFlight}
|
||||
title="Вернуть план на учёт — снова будет считаться проблемой КА"
|
||||
onClick={() => onRestorePlan(plan.planId)}
|
||||
>
|
||||
Вернуть на учёт
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
: onDismissPlan && (
|
||||
<div className="tgu-details__actions">
|
||||
<button
|
||||
className="tgu-button"
|
||||
type="button"
|
||||
disabled={draftActionInFlight}
|
||||
title="Снять отменённый/неудавшийся план с учёта, чтобы он не считался проблемой и не красил КА"
|
||||
onClick={dismiss}
|
||||
>
|
||||
Снять с учёта
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { confirmPlanLayin, fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
|
||||
import {
|
||||
confirmPlanLayin,
|
||||
dismissPlan,
|
||||
fetchPlans,
|
||||
fetchPlatforms,
|
||||
restorePlan,
|
||||
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";
|
||||
@@ -389,6 +396,37 @@ export function TguPlanningPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// Снять/вернуть план с учёта: косметико-статистический флаг, не трогает статус и цепочку.
|
||||
const handleDismissPlan = async (planId: string) => {
|
||||
setDraftActionInFlight(true);
|
||||
setDecisionError(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
try {
|
||||
await dismissPlan(planId);
|
||||
setDecisionNotice("План снят с учёта — больше не считается проблемой КА.");
|
||||
await loadData(appliedRange);
|
||||
} catch (error) {
|
||||
setDecisionError(error instanceof Error ? error.message : "Не удалось снять план с учёта.");
|
||||
} finally {
|
||||
setDraftActionInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestorePlan = async (planId: string) => {
|
||||
setDraftActionInFlight(true);
|
||||
setDecisionError(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
try {
|
||||
await restorePlan(planId);
|
||||
setDecisionNotice("План возвращён на учёт.");
|
||||
await loadData(appliedRange);
|
||||
} catch (error) {
|
||||
setDecisionError(error instanceof Error ? error.message : "Не удалось вернуть план на учёт.");
|
||||
} finally {
|
||||
setDraftActionInFlight(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TguPlanningLayout
|
||||
toolbar={
|
||||
@@ -489,6 +527,8 @@ export function TguPlanningPage() {
|
||||
onCreateDraftFromPlan={handleCreateDraftFromPlan}
|
||||
onConfirmLayin={handleConfirmLayin}
|
||||
layinConflict={layinConflict}
|
||||
onDismissPlan={handleDismissPlan}
|
||||
onRestorePlan={handleRestorePlan}
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -237,15 +237,20 @@ export function TguTimeline({
|
||||
// При активном выборе все «непричастные» планы гасим до еле заметных, а текущий /
|
||||
// прошлый / следующий обводим яркой рамкой — так видно звенья цепочки выбранного плана.
|
||||
const dimmed = selectedPlanId !== undefined && !selected && neighbor === undefined;
|
||||
// Снят с учёта: гасим план на таймлайне (но не сильнее, чем выбор-затемнение).
|
||||
const dismissed = Boolean(segment.plan.dismissedAt);
|
||||
|
||||
return (
|
||||
<g
|
||||
key={segment.plan.planId}
|
||||
className={`tgu-timeline-plan ${selected ? "is-selected" : ""} ${dimmed ? "is-dimmed" : ""}`}
|
||||
className={`tgu-timeline-plan ${selected ? "is-selected" : ""} ${dimmed ? "is-dimmed" : ""} ${
|
||||
dismissed ? "is-dismissed-plan" : ""
|
||||
}`}
|
||||
onClick={() => onSelectPlan(segment.plan.planId)}
|
||||
>
|
||||
<title>
|
||||
{segment.plan.planId} · {segment.plan.status}
|
||||
{dismissed ? " · снят с учёта" : ""}
|
||||
{neighbor === "prev" ? " · предыдущий в цепочке" : neighbor === "next" ? " · следующий в цепочке" : ""} ·{" "}
|
||||
{formatDateTime(segment.plan.startTime)} - {formatDateTime(segment.plan.endTime)}
|
||||
</title>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TguPlatform, TguPlanStatus } from "../../model/tguTypes";
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import { isOperationalPlatform, operationalSpacecraftIds, pausedSpacecraftIds } from "./tguTimelineMapper";
|
||||
import {
|
||||
isOperationalPlatform,
|
||||
operationalSpacecraftIds,
|
||||
pausedSpacecraftIds,
|
||||
unresolvedProblemSpacecraftIds
|
||||
} from "./tguTimelineMapper";
|
||||
import { toLocalIso } from "../../utils/localTime";
|
||||
|
||||
function platform(overrides: Partial<TguPlatform>): TguPlatform {
|
||||
@@ -78,3 +83,44 @@ describe("pausedSpacecraftIds", () => {
|
||||
expect(pausedSpacecraftIds([uiPlan("100", "WAITING_DECISION", t0)])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
describe("unresolvedProblemSpacecraftIds (красный индикатор КА)", () => {
|
||||
const t0 = Date.UTC(2026, 5, 4, 0, 0, 0);
|
||||
const hour = 3_600_000;
|
||||
|
||||
function dismissed(plan: TguPlanUi): TguPlanUi {
|
||||
return { ...plan, dismissedAt: toLocalIso(t0) };
|
||||
}
|
||||
|
||||
it("красит КА, если есть нерешённый REJECTED", () => {
|
||||
expect(unresolvedProblemSpacecraftIds([uiPlan("100", "REJECTED", t0)])).toEqual(new Set(["100"]));
|
||||
});
|
||||
|
||||
it("красит КА на START_AMBIGUOUS", () => {
|
||||
expect(unresolvedProblemSpacecraftIds([uiPlan("100", "START_AMBIGUOUS", t0)])).toEqual(new Set(["100"]));
|
||||
});
|
||||
|
||||
it("НЕ красит, если проблемный план снят с учёта", () => {
|
||||
expect(unresolvedProblemSpacecraftIds([dismissed(uiPlan("100", "REJECTED", t0))])).toEqual(new Set());
|
||||
});
|
||||
|
||||
it("НЕ красит, если цепочка продвинулась за отказ (исправленный план в полёте)", () => {
|
||||
const ids = unresolvedProblemSpacecraftIds([
|
||||
uiPlan("100", "REJECTED", t0),
|
||||
uiPlan("100", "WAITING_DECISION", t0 + hour)
|
||||
]);
|
||||
expect(ids.has("100")).toBe(false);
|
||||
});
|
||||
|
||||
it("красит, если продвинувший план раньше отказа (отказ всё ещё актуален)", () => {
|
||||
const ids = unresolvedProblemSpacecraftIds([
|
||||
uiPlan("100", "REJECTED", t0 + 5 * hour),
|
||||
uiPlan("100", "AWAITING_LAYIN", t0)
|
||||
]);
|
||||
expect(ids).toEqual(new Set(["100"]));
|
||||
});
|
||||
|
||||
it("успешные/живые статусы сами по себе не красят", () => {
|
||||
expect(unresolvedProblemSpacecraftIds([uiPlan("100", "LAID_IN", t0)])).toEqual(new Set());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,28 +47,28 @@ export function buildPlatformsForInterval(
|
||||
): TguPlatformUi[] {
|
||||
const intervalPlans = filterPlansByRange(plans, range);
|
||||
const platformByNorad = buildPlatformByNorad(platforms);
|
||||
const planStats = new Map<string, { count: number; hasProblemStatus: boolean }>();
|
||||
|
||||
// Красный индикатор КА = есть НЕразрешённый проблемный план (см. unresolvedProblemSpacecraftIds):
|
||||
// отказ, который оператор ещё не обработал (цепочка не продвинулась) и не снял с учёта. Прежняя
|
||||
// логика «есть хоть один REJECTED» красила КА вечно — даже после исправления или снятия с учёта.
|
||||
const problemSpacecraft = unresolvedProblemSpacecraftIds(intervalPlans);
|
||||
const planCount = new Map<string, number>();
|
||||
for (const plan of intervalPlans) {
|
||||
const stats = planStats.get(plan.spacecraftId) ?? { count: 0, hasProblemStatus: false };
|
||||
stats.count += 1;
|
||||
stats.hasProblemStatus = stats.hasProblemStatus || PROBLEM_STATUSES.has(plan.status);
|
||||
planStats.set(plan.spacecraftId, stats);
|
||||
planCount.set(plan.spacecraftId, (planCount.get(plan.spacecraftId) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) => {
|
||||
const stats = planStats.get(spacecraftId) ?? { count: 0, hasProblemStatus: false };
|
||||
return toPlatformUi(spacecraftId, platform, stats.count, stats.hasProblemStatus);
|
||||
});
|
||||
const platformItems = Array.from(platformByNorad.entries()).map(([spacecraftId, platform]) =>
|
||||
toPlatformUi(spacecraftId, platform, planCount.get(spacecraftId) ?? 0, problemSpacecraft.has(spacecraftId))
|
||||
);
|
||||
|
||||
const knownSpacecraft = new Set(platformItems.map((platform) => platform.spacecraftId));
|
||||
for (const [spacecraftId, stats] of planStats.entries()) {
|
||||
for (const [spacecraftId, count] of planCount.entries()) {
|
||||
if (!knownSpacecraft.has(spacecraftId)) {
|
||||
platformItems.push({
|
||||
spacecraftId,
|
||||
noradId: spacecraftId,
|
||||
planCount: stats.count,
|
||||
hasProblemStatus: stats.hasProblemStatus
|
||||
planCount: count,
|
||||
hasProblemStatus: problemSpacecraft.has(spacecraftId)
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,32 @@ const CHAIN_ADVANCED_STATUSES = new Set([
|
||||
* отклонённого окна. Совпадает с серверной паузой ([IssueDuePlanWorker] не выдаёт окно с REJECTED) —
|
||||
* оператор должен вручную создать исправленный план.
|
||||
*/
|
||||
/**
|
||||
* КА с НЕразрешённой проблемой — для красного индикатора. Проблемный план (REJECTED/START_AMBIGUOUS)
|
||||
* считается разрешённым и НЕ красит КА, если он либо снят с учёта (`dismissedAt`), либо цепочка уже
|
||||
* продвинулась за него (есть [CHAIN_ADVANCED_STATUSES]-план со стартом не раньше). Так исправленные
|
||||
* и снятые с учёта отказы гаснут сами, а реальные нерешённые — остаются красными.
|
||||
*/
|
||||
export function unresolvedProblemSpacecraftIds(plans: TguPlanUi[]): Set<string> {
|
||||
const latestAdvancedStart = new Map<string, number>();
|
||||
for (const plan of plans) {
|
||||
if (CHAIN_ADVANCED_STATUSES.has(plan.status)) {
|
||||
const prev = latestAdvancedStart.get(plan.spacecraftId) ?? Number.NEGATIVE_INFINITY;
|
||||
if (plan.startMs > prev) latestAdvancedStart.set(plan.spacecraftId, plan.startMs);
|
||||
}
|
||||
}
|
||||
|
||||
const unresolved = new Set<string>();
|
||||
for (const plan of plans) {
|
||||
if (!PROBLEM_STATUSES.has(plan.status)) continue;
|
||||
if (plan.dismissedAt) continue; // снят с учёта оператором
|
||||
const advancedStart = latestAdvancedStart.get(plan.spacecraftId);
|
||||
if (advancedStart !== undefined && advancedStart >= plan.startMs) continue; // цепочка ушла дальше
|
||||
unresolved.add(plan.spacecraftId);
|
||||
}
|
||||
return unresolved;
|
||||
}
|
||||
|
||||
export function pausedSpacecraftIds(plans: TguPlanUi[]): Set<string> {
|
||||
const latestRejectedStart = new Map<string, number>();
|
||||
for (const plan of plans) {
|
||||
|
||||
@@ -23,6 +23,11 @@ export type TguPlan = {
|
||||
basePlanId?: string | null;
|
||||
/** `true` — вычисленная проекция будущего (не persisted); цепочку продолжает, но стартом не является. */
|
||||
projected?: boolean;
|
||||
/**
|
||||
* Время «снятия с учёта» (ISO без зоны) терминально-негативного плана. `null`/отсутствует — план
|
||||
* на учёте. Снятый план гасится на таймлайне и не считается проблемой в индикаторе КА.
|
||||
*/
|
||||
dismissedAt?: string | null;
|
||||
};
|
||||
|
||||
export type TguPlatform = {
|
||||
|
||||
@@ -440,6 +440,15 @@
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
/* Снят с учёта: приглушаем план и делаем штриховку — виден, но явно «второстепенный».
|
||||
Объявлен ВЫШЕ is-dimmed, чтобы при активном выборе сильное выбор-затемнение перебивало это. */
|
||||
.tgu-timeline-plan.is-dismissed-plan {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.tgu-timeline-plan.is-dismissed-plan .tgu-timeline-plan__bar {
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
|
||||
/* При активном выборе непричастные планы гасим до еле заметных — на их фоне читаются
|
||||
яркие рамки текущего/прошлого/следующего звена цепочки. */
|
||||
.tgu-timeline-plan.is-dimmed {
|
||||
@@ -556,6 +565,18 @@
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tgu-details__pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
/* Бейдж «снят с учёта» рядом со статусом: нейтральный серый, чтобы не путать со статусом. */
|
||||
.tgu-status-pill--dismissed {
|
||||
background: #475569;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.tgu-details-grid {
|
||||
display: grid;
|
||||
gap: 0.72rem;
|
||||
|
||||
Reference in New Issue
Block a user