pcp-tgu-ops-ui: fork-forward в редакторе — выбор будущей ЗРВ для перевыдачи невозвратного плана

Селектор «Целевая ЗРВ» в EditorToolbar: текущий слот (replace, по умолчанию)
либо будущая ЗРВ из проекции pcp-tgu-service (fetchSpacecraftPlans →
GET /api/plans/{spacecraftId}, фильтр futureZrvWindows: PLANNED строго в
будущем, кроме базы — та же проекция, что валидирует бэк). Выбор окна
переводит обзор редактора и заставляет ensureDraft слать mode=fork-forward
(naive-UTC окно + kpp); селектор блокируется после создания DRAFT.
This commit is contained in:
Дмитрий Соловьев
2026-06-03 14:12:21 +03:00
parent 2a841fc7b3
commit 057452f19d
7 changed files with 277 additions and 6 deletions
@@ -13,7 +13,8 @@ import {
validateMission,
type SurveyRoute
} from "../../api/missionPlanningApi";
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
import { abandonDraft, createDraft, fetchSpacecraftPlans, promoteDraft } from "../../api/tguPlanApi";
import { futureZrvWindows, toCreateDraftRequest, type ZrvWindow } from "./editorForkForward";
import { parseUtc } from "../../utils/utcTime";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
@@ -121,6 +122,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
const [draftPlanId, setDraftPlanId] = useState<string>();
// Будущие ЗРВ КА (проекция PLANNED) и выбранная цель fork-forward. Пусто/undefined — правка
// текущего слота (replace); выбор окна заставляет ensureDraft создать черновик на следующей ЗРВ.
const [futureWindows, setFutureWindows] = useState<ZrvWindow[]>([]);
const [targetWindowId, setTargetWindowId] = useState<string>();
const [passFrom, setPassFrom] = useState(1);
const [passTo, setPassTo] = useState(999);
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
@@ -223,6 +228,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
// локальные несохранённые твики без DRAFT применять нечего.
const dirty = Boolean(draftPlanId);
const targetWindow = useMemo(
() => futureWindows.find((window) => window.planId === targetWindowId),
[futureWindows, targetWindowId]
);
const addedCount = draft.works.filter((work) => work.isNew).length;
// Реальный состав плана берём из включений (plan modes), а не из черновика:
// черновик сидируется заглушкой, когда у плана нет встроенных works.
@@ -247,11 +256,33 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setNotice(undefined);
setApplyError(undefined);
setPlanModes([]);
setTargetWindowId(undefined);
setPassFrom(1);
setPassTo(999);
setFlightLineData([]);
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
// Будущие ЗРВ-кандидаты для fork-forward: проекция КА из pcp-tgu-service (та же, с которой бэк
// сверяет выбранное окно). Грузим при выборе плана; невозвратный план оператор перевыдаёт на одну
// из этих ЗРВ. Сбой непрозрачен — список просто остаётся пустым (доступен только replace).
useEffect(() => {
if (!selectedPlan || !selectedSpacecraftId) {
setFutureWindows([]);
return;
}
let cancelled = false;
void fetchSpacecraftPlans(selectedSpacecraftId)
.then((plans) => {
if (!cancelled) setFutureWindows(futureZrvWindows(plans, selectedPlan.planId, Date.now()));
})
.catch(() => {
if (!cancelled) setFutureWindows([]);
});
return () => {
cancelled = true;
};
}, [selectedPlan, selectedSpacecraftId]);
useEffect(() => {
if (!noradId) return;
let cancelled = false;
@@ -453,15 +484,31 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
changeDraft((current) => updateWork(current, workId, patch));
};
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT (replace)
// и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда.
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT и возвращает его id.
// Без выбранной целевой ЗРВ — replace (тот же слот); с выбранной — fork-forward на следующую ЗРВ
// (для невозвратного плана). Дальше все правки/валидация/promote адресуются именно сюда.
const ensureDraft = useCallback(async (): Promise<string> => {
if (draftPlanId) return draftPlanId;
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
const created = await createDraft(selectedPlan.planId, toCreateDraftRequest(targetWindow));
setDraftPlanId(created.draftPlanId);
return created.draftPlanId;
}, [draftPlanId, selectedPlan]);
}, [draftPlanId, selectedPlan, targetWindow]);
// Выбор целевой ЗРВ для fork-forward (до создания черновика): фиксирует окно и переводит
// обзор редактора на него, чтобы оператор строил включения там, где будет жить новый план.
const selectTargetWindow = useCallback(
(planId: string | undefined) => {
setTargetWindowId(planId);
const window = planId ? futureWindows.find((item) => item.planId === planId) : undefined;
if (window) {
setWindow({ fromMs: window.startMs - 3 * HOUR_MS, toMs: window.endMs + 3 * HOUR_MS });
} else if (selectedPlan) {
setWindow(defaultEditorWindow(selectedPlan, appliedRange));
}
},
[futureWindows, selectedPlan, appliedRange]
);
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
@@ -781,6 +828,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
dirty={dirty}
applying={applying}
canceling={canceling}
futureWindows={futureWindows}
targetWindowId={targetWindowId}
targetLocked={dirty}
onSelectTargetWindow={selectTargetWindow}
windowLabel={formatDuration(window.toMs - window.fromMs)}
rightMode={rightMode}
onSetRightMode={(mode) => {