pcp: создание плана новой цепочки из вкладки «Планы» (база/без истории, окно из двух ЗРВ)
Оператор берёт за основу прошлый план (предыстория) или начинает цепочку с нуля,
набирает окно из двух реальных ЗРВ и создаёт план-кандидат; сразу показывается прогноз
будущей цепочки от него. Рабочим (якорем) план становится после ручной отправки (promote)
и приёмки — отдельного механизма якорения не вводим.
pcp-tgu-service:
- сырые ЗРВ: PlanCalculationService.rawWindows + PlanProjectionService.rawZrvWindows,
GET /api/plans/{spacecraftId}/zrv (исходные окна снапшота, не cooked-проекция);
- CreateChainPlanUseCase + POST /api/plans/new-chain: опциональная база (клон включений
в окно) либо пустая миссия без истории; прогноз цепочки через projectAfterWindow;
- ZrvWindowMatcher (старт+КПП одной зоны, конец — другой); assertDraftSlotStillValid
переведён на него — единая проверка для replace/fork-forward и окна из двух ЗРВ;
- тесты: CreateChainPlanUseCaseTest (8), ZrvWindowMatcherTest (7), правки promote-тестов.
pcp-mission-planing-service:
- POST /api/missions/blank — пустая миссия для сценария «без истории» (+ 2 теста).
pcp-tgu-ops-ui:
- API fetchZrvWindows/createChainPlan; диалог «Создать план» во вкладке «Планы»
(выбор базы, стартовой и конечной ЗРВ) с показом спрогнозированной цепочки и ручной
отправкой; подсветка ЗРВ выбранного КА на таймлайне;
- «Целевая ЗРВ» убрана из редактора (он остаётся под replace-правку); удалён ставший
мёртвым editorForkForward; createChainWindows + тесты.
This commit is contained in:
@@ -1,7 +1,5 @@
|
||||
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
||||
import { formatDuration } from "./editorPresentation";
|
||||
import type { ZrvWindow } from "./editorForkForward";
|
||||
import { formatUtcDateTime } from "../../utils/utcTime";
|
||||
|
||||
type EditorToolbarProps = {
|
||||
selectedSpacecraftId?: string;
|
||||
@@ -15,10 +13,6 @@ type EditorToolbarProps = {
|
||||
dirty: boolean;
|
||||
applying: boolean;
|
||||
canceling: boolean;
|
||||
futureWindows: ZrvWindow[];
|
||||
targetWindowId?: string;
|
||||
targetLocked: boolean;
|
||||
onSelectTargetWindow: (planId: string | undefined) => void;
|
||||
windowLabel: string;
|
||||
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
||||
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
||||
@@ -43,10 +37,6 @@ export function EditorToolbar({
|
||||
dirty,
|
||||
applying,
|
||||
canceling,
|
||||
futureWindows,
|
||||
targetWindowId,
|
||||
targetLocked,
|
||||
onSelectTargetWindow,
|
||||
windowLabel,
|
||||
rightMode,
|
||||
onSetRightMode,
|
||||
@@ -93,25 +83,6 @@ export function EditorToolbar({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{(futureWindows.length > 0 || targetWindowId) && (
|
||||
<label className="tgu-editor-target-zrv" title="Целевая ЗРВ для перевыдачи невозвратного плана">
|
||||
<span className="tgu-editor-target-zrv__label">Целевая ЗРВ</span>
|
||||
<select
|
||||
className="tgu-select"
|
||||
value={targetWindowId ?? ""}
|
||||
disabled={targetLocked}
|
||||
onChange={(event) => onSelectTargetWindow(event.target.value || undefined)}
|
||||
>
|
||||
<option value="">Текущий слот (правка)</option>
|
||||
{futureWindows.map((window) => (
|
||||
<option key={window.planId} value={window.planId}>
|
||||
{formatUtcDateTime(window.startMs)} · {window.kppId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<div className="tgu-editor-toolbar__spacer" />
|
||||
|
||||
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
||||
|
||||
@@ -13,8 +13,7 @@ import {
|
||||
validateMission,
|
||||
type SurveyRoute
|
||||
} from "../../api/missionPlanningApi";
|
||||
import { abandonDraft, createDraft, fetchSpacecraftPlans, promoteDraft } from "../../api/tguPlanApi";
|
||||
import { futureZrvWindows, toCreateDraftRequest, type ZrvWindow } from "./editorForkForward";
|
||||
import { abandonDraft, createDraft, promoteDraft } from "../../api/tguPlanApi";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||
@@ -122,10 +121,6 @@ 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[]>([]);
|
||||
@@ -228,10 +223,6 @@ 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.
|
||||
@@ -256,33 +247,11 @@ 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;
|
||||
@@ -484,31 +453,16 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
||||
changeDraft((current) => updateWork(current, workId, patch));
|
||||
};
|
||||
|
||||
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT и возвращает его id.
|
||||
// Без выбранной целевой ЗРВ — replace (тот же слот); с выбранной — fork-forward на следующую ЗРВ
|
||||
// (для невозвратного плана). Дальше все правки/валидация/promote адресуются именно сюда.
|
||||
// Гарантирует наличие черновика: при первой правке клонирует текущий план в DRAFT (replace, тот же
|
||||
// слот) и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда. Создание
|
||||
// плана на другую ЗРВ / новой цепочки вынесено во вкладку «Планы».
|
||||
const ensureDraft = useCallback(async (): Promise<string> => {
|
||||
if (draftPlanId) return draftPlanId;
|
||||
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
||||
const created = await createDraft(selectedPlan.planId, toCreateDraftRequest(targetWindow));
|
||||
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
||||
setDraftPlanId(created.draftPlanId);
|
||||
return created.draftPlanId;
|
||||
}, [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]
|
||||
);
|
||||
}, [draftPlanId, selectedPlan]);
|
||||
|
||||
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
|
||||
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
|
||||
@@ -828,10 +782,6 @@ 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) => {
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { futureZrvWindows, toCreateDraftRequest } from "./editorForkForward";
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
import type { TguPlanResponse } from "../../api/tguPlanApi";
|
||||
|
||||
const NOW = parseUtc("2026-06-03T00:00:00Z");
|
||||
|
||||
function plan(over: Partial<TguPlanResponse>): TguPlanResponse {
|
||||
return {
|
||||
planId: "p",
|
||||
spacecraftId: "sc-1",
|
||||
startTime: "2026-06-03T05:00:00Z",
|
||||
endTime: "2026-06-03T05:10:00Z",
|
||||
kppId: "KPP-1",
|
||||
status: "PLANNED",
|
||||
...over
|
||||
};
|
||||
}
|
||||
|
||||
describe("futureZrvWindows", () => {
|
||||
it("оставляет только будущие PLANNED-окна, кроме базового, по возрастанию начала", () => {
|
||||
const plans = [
|
||||
plan({ planId: "base", startTime: "2026-06-03T05:00:00Z" }),
|
||||
plan({ planId: "future-late", startTime: "2026-06-03T09:00:00Z", endTime: "2026-06-03T09:10:00Z" }),
|
||||
plan({ planId: "future-early", startTime: "2026-06-03T07:00:00Z", endTime: "2026-06-03T07:10:00Z" }),
|
||||
plan({ planId: "past", startTime: "2026-06-02T23:00:00Z", endTime: "2026-06-02T23:10:00Z" }),
|
||||
plan({ planId: "issued", status: "ISSUING", startTime: "2026-06-03T08:00:00Z" })
|
||||
];
|
||||
|
||||
const windows = futureZrvWindows(plans, "base", NOW);
|
||||
|
||||
expect(windows.map((w) => w.planId)).toEqual(["future-early", "future-late"]);
|
||||
});
|
||||
|
||||
it("приводит окно к naive-UTC без зоны для CreateDraftRequest", () => {
|
||||
const windows = futureZrvWindows(
|
||||
[plan({ planId: "future", startTime: "2026-06-03T07:00:00Z", endTime: "2026-06-03T07:10:00Z" })],
|
||||
"base",
|
||||
NOW
|
||||
);
|
||||
|
||||
expect(windows).toHaveLength(1);
|
||||
expect(windows[0].windowStart).toBe("2026-06-03T07:00:00");
|
||||
expect(windows[0].windowStop).toBe("2026-06-03T07:10:00");
|
||||
expect(windows[0].kppId).toBe("KPP-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("toCreateDraftRequest", () => {
|
||||
it("без окна — replace в том же слоте", () => {
|
||||
expect(toCreateDraftRequest(undefined)).toEqual({ mode: "replace" });
|
||||
});
|
||||
|
||||
it("с окном — fork-forward с naive-границами и КПП", () => {
|
||||
const [window] = futureZrvWindows(
|
||||
[plan({ planId: "future", startTime: "2026-06-03T07:00:00Z", endTime: "2026-06-03T07:10:00Z" })],
|
||||
"base",
|
||||
NOW
|
||||
);
|
||||
|
||||
expect(toCreateDraftRequest(window)).toEqual({
|
||||
mode: "fork-forward",
|
||||
windowStart: "2026-06-03T07:00:00",
|
||||
windowStop: "2026-06-03T07:10:00",
|
||||
windowKppId: "KPP-1"
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,59 +0,0 @@
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
import type { CreateDraftRequest, TguPlanResponse } from "../../api/tguPlanApi";
|
||||
|
||||
/**
|
||||
* Окно-кандидат будущей ЗРВ для fork-forward: план из проекции (статус PLANNED), лежащий строго
|
||||
* в будущем и не являющийся базовым. Из такого окна оператор перевыдаёт невозвратный план на
|
||||
* следующую ЗРВ; бэк сверяет выбор с той же проекцией (assertWindowMatchesProjection).
|
||||
*/
|
||||
export type ZrvWindow = {
|
||||
/** Детерминированный planId проекции (для value в селекте). */
|
||||
planId: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
/** Naive UTC (без зоны) для CreateDraftRequest — бэк парсит LocalDateTime. */
|
||||
windowStart: string;
|
||||
windowStop: string;
|
||||
kppId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Будущие ЗРВ-кандидаты из проекции КА: только PLANNED, строго позже `nowMs`, кроме базового плана,
|
||||
* по возрастанию начала. Naive-окна (windowStart/Stop без зоны) совпадают со значениями проекции,
|
||||
* с которыми бэк сверяет выбор.
|
||||
*/
|
||||
export function futureZrvWindows(plans: TguPlanResponse[], basePlanId: string, nowMs: number): ZrvWindow[] {
|
||||
return plans
|
||||
.filter((plan) => plan.status === "PLANNED" && plan.planId !== basePlanId)
|
||||
.map((plan) => {
|
||||
const startMs = parseUtc(plan.startTime);
|
||||
const endMs = parseUtc(plan.endTime);
|
||||
return {
|
||||
planId: plan.planId,
|
||||
startMs,
|
||||
endMs,
|
||||
windowStart: toNaiveUtc(startMs),
|
||||
windowStop: toNaiveUtc(endMs),
|
||||
kppId: plan.kppId
|
||||
};
|
||||
})
|
||||
.filter((window) => window.startMs > nowMs)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
}
|
||||
|
||||
/** Тело запроса createDraft из выбора целевой ЗРВ: окно → fork-forward, иначе тот же слот (replace). */
|
||||
export function toCreateDraftRequest(window: ZrvWindow | undefined): CreateDraftRequest {
|
||||
if (!window) {
|
||||
return { mode: "replace" };
|
||||
}
|
||||
return {
|
||||
mode: "fork-forward",
|
||||
windowStart: window.windowStart,
|
||||
windowStop: window.windowStop,
|
||||
windowKppId: window.kppId
|
||||
};
|
||||
}
|
||||
|
||||
function toNaiveUtc(ms: number): string {
|
||||
return new Date(ms).toISOString().slice(0, 19);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
createChainPlan,
|
||||
fetchZrvWindows,
|
||||
promoteDraft,
|
||||
type CreateChainPlanResponse
|
||||
} from "../../api/tguPlanApi";
|
||||
import type { TguPlanUi } from "../../model/timelineTypes";
|
||||
import { TguApiError } from "../../model/tguTypes";
|
||||
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
|
||||
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
|
||||
|
||||
type CreateChainPlanDialogProps = {
|
||||
spacecraftId: string;
|
||||
/** Планы для выбора базы-предыстории (берём persisted этого КА, кроме черновиков/проекции). */
|
||||
plans: TguPlanUi[];
|
||||
onClose: () => void;
|
||||
/** Вызвать после ручной отправки созданного плана (для перезагрузки таймлайна). */
|
||||
onSent: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Диалог «Создать план» (вкладка «Планы»): оператор берёт за основу прошлый план (или начинает
|
||||
* цепочку без истории), набирает окно из двух реальных ЗРВ и создаёт план-кандидат. Сразу
|
||||
* показывается прогноз будущей цепочки от него; кандидат становится рабочим после ручной отправки
|
||||
* («Отправить вручную» = promote) и последующей приёмки.
|
||||
*/
|
||||
export function CreateChainPlanDialog({ spacecraftId, plans, onClose, onSent }: CreateChainPlanDialogProps) {
|
||||
const [zrvOptions, setZrvOptions] = useState<ZrvOption[]>([]);
|
||||
const [zrvLoading, setZrvLoading] = useState(true);
|
||||
const [zrvError, setZrvError] = useState<string>();
|
||||
|
||||
const [basePlanId, setBasePlanId] = useState<string>(""); // "" — без истории
|
||||
const [startId, setStartId] = useState<string>("");
|
||||
const [endId, setEndId] = useState<string>("");
|
||||
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const [result, setResult] = useState<CreateChainPlanResponse>();
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// Базы-предыстории: persisted планы этого КА (без проекции PLANNED и без черновиков).
|
||||
const baseOptions = useMemo(
|
||||
() =>
|
||||
plans
|
||||
.filter((plan) => plan.spacecraftId === spacecraftId && plan.status !== "PLANNED" && plan.status !== "DRAFT")
|
||||
.sort((a, b) => b.startMs - a.startMs),
|
||||
[plans, spacecraftId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setZrvLoading(true);
|
||||
setZrvError(undefined);
|
||||
void fetchZrvWindows(spacecraftId)
|
||||
.then((windows) => {
|
||||
if (!cancelled) setZrvOptions(futureZrvOptions(windows, Date.now()));
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) setZrvError(err instanceof TguApiError ? err.message : "Не удалось получить ЗРВ КА.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setZrvLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [spacecraftId]);
|
||||
|
||||
const start = zrvOptions.find((option) => option.id === startId);
|
||||
const end = zrvOptions.find((option) => option.id === endId);
|
||||
const canCreate = Boolean(start && end) && !creating;
|
||||
|
||||
const create = async () => {
|
||||
if (!start || !end) return;
|
||||
setCreating(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
const request = buildChainPlanRequest(spacecraftId, basePlanId || null, start, end);
|
||||
setResult(await createChainPlan(request));
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof TguApiError ? err.message : err instanceof Error ? err.message : "Не удалось создать план."
|
||||
);
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
if (!result) return;
|
||||
setSending(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
await promoteDraft(result.draftPlanId);
|
||||
onSent();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof TguApiError ? err.message : "Не удалось отправить план.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tgu-chain-dialog__backdrop" role="dialog" aria-modal="true" aria-label="Создать план">
|
||||
<div className="tgu-chain-dialog">
|
||||
<div className="tgu-chain-dialog__header">
|
||||
<h2>Создать план</h2>
|
||||
<button className="tgu-icon-button" type="button" onClick={onClose} aria-label="Закрыть">
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!result ? (
|
||||
<>
|
||||
<label className="tgu-chain-dialog__field">
|
||||
<span>Базовое состояние (предыстория)</span>
|
||||
<select className="tgu-select" value={basePlanId} onChange={(e) => setBasePlanId(e.target.value)}>
|
||||
<option value="">Без истории (новая цепочка с нуля)</option>
|
||||
{baseOptions.map((plan) => (
|
||||
<option key={plan.planId} value={plan.planId}>
|
||||
{formatUtcDateTime(plan.startMs)} · {plan.kppId} · {plan.status}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
{zrvError && <div className="tgu-alert tgu-alert--error">{zrvError}</div>}
|
||||
{zrvLoading ? (
|
||||
<div className="tgu-chain-dialog__hint">Загрузка ЗРВ…</div>
|
||||
) : zrvOptions.length === 0 ? (
|
||||
<div className="tgu-chain-dialog__hint">Нет будущих ЗРВ для этого КА.</div>
|
||||
) : (
|
||||
<>
|
||||
<label className="tgu-chain-dialog__field">
|
||||
<span>Стартовая ЗРВ (начало окна)</span>
|
||||
<select className="tgu-select" value={startId} onChange={(e) => setStartId(e.target.value)}>
|
||||
<option value="">— выберите —</option>
|
||||
{zrvOptions.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{formatUtcDateTime(option.startMs)} · {option.kppId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="tgu-chain-dialog__field">
|
||||
<span>Конечная ЗРВ (конец окна)</span>
|
||||
<select className="tgu-select" value={endId} onChange={(e) => setEndId(e.target.value)}>
|
||||
<option value="">— выберите —</option>
|
||||
{zrvOptions
|
||||
.filter((option) => !start || option.endMs > start.startMs)
|
||||
.map((option) => (
|
||||
<option key={option.id} value={option.id}>
|
||||
{formatUtcDateTime(option.endMs)} · {option.kppId}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
|
||||
|
||||
<div className="tgu-chain-dialog__actions">
|
||||
<button className="tgu-button" type="button" onClick={onClose}>
|
||||
Отмена
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={create} disabled={!canCreate}>
|
||||
{creating ? "Создание…" : "Создать"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="tgu-alert tgu-alert--success">
|
||||
План-кандидат создан (черновик {result.draftPlanId}). Прогноз будущей цепочки от него:
|
||||
</div>
|
||||
<ul className="tgu-chain-dialog__chain">
|
||||
{result.projectedChain.length === 0 ? (
|
||||
<li className="tgu-chain-dialog__hint">Цепочка после этого плана пуста (нет дальнейших ЗРВ).</li>
|
||||
) : (
|
||||
result.projectedChain.map((plan) => (
|
||||
<li key={plan.planId}>
|
||||
{formatUtcDateTime(parseUtc(plan.startTime))} – {formatUtcDateTime(parseUtc(plan.endTime))} ·{" "}
|
||||
{plan.kppId}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
|
||||
{error && <div className="tgu-alert tgu-alert--error">{error}</div>}
|
||||
|
||||
<div className="tgu-chain-dialog__actions">
|
||||
<button className="tgu-button" type="button" onClick={onClose}>
|
||||
Закрыть
|
||||
</button>
|
||||
<button className="tgu-button tgu-button--primary" type="button" onClick={send} disabled={sending}>
|
||||
{sending ? "Отправка…" : "Отправить вручную"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="tgu-chain-dialog__hint">
|
||||
После отправки план уходит в выдачу; рабочим (якорем цепочки) он станет после приёмки.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelin
|
||||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||||
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
||||
import { fetchZrvWindows, type ZrvWindowResponse } from "../../api/tguPlanApi";
|
||||
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
|
||||
import { TguPlanDetails } from "./TguPlanDetails";
|
||||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||||
import { TguSidebar } from "./TguSidebar";
|
||||
@@ -36,6 +38,9 @@ export function TguPlanningPage() {
|
||||
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
||||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||||
const [createChainOpen, setCreateChainOpen] = useState(false);
|
||||
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
|
||||
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
|
||||
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
||||
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
||||
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
||||
@@ -102,6 +107,33 @@ export function TguPlanningPage() {
|
||||
setVisitedTabs((prev) => (prev.has(activeTab) ? prev : new Set(prev).add(activeTab)));
|
||||
}, [activeTab]);
|
||||
|
||||
// ЗРВ выбранного КА для подсветки на таймлайне. Сбой непрозрачен — просто нет подсветки.
|
||||
useEffect(() => {
|
||||
if (!selectedSpacecraftId) {
|
||||
setZrvWindows([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void fetchZrvWindows(selectedSpacecraftId)
|
||||
.then((windows) => {
|
||||
if (!cancelled) setZrvWindows(windows);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setZrvWindows([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [selectedSpacecraftId]);
|
||||
|
||||
const zrvBands = useMemo(() => {
|
||||
if (!selectedSpacecraftId || zrvWindows.length === 0) return undefined;
|
||||
return {
|
||||
spacecraftId: selectedSpacecraftId,
|
||||
windows: zrvWindows.map((zrv) => ({ startMs: parseUtc(zrv.startTime), endMs: parseUtc(zrv.endTime) }))
|
||||
};
|
||||
}, [selectedSpacecraftId, zrvWindows]);
|
||||
|
||||
// display:contents для активной вкладки сохраняет grid-раскладку shell
|
||||
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
||||
const tabStyle = (tab: TguActiveTab) =>
|
||||
@@ -194,10 +226,22 @@ export function TguPlanningPage() {
|
||||
}}
|
||||
/>
|
||||
<section className="tgu-center">
|
||||
<div className="tgu-center__actions">
|
||||
<button
|
||||
className="tgu-button tgu-button--primary"
|
||||
type="button"
|
||||
disabled={!selectedSpacecraftId}
|
||||
title={selectedSpacecraftId ? "Создать план новой цепочки" : "Сначала выберите КА"}
|
||||
onClick={() => setCreateChainOpen(true)}
|
||||
>
|
||||
Создать план
|
||||
</button>
|
||||
</div>
|
||||
<TguTimeline
|
||||
rows={rows}
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
zrvBands={zrvBands}
|
||||
selectedPlanId={selectedPlanId}
|
||||
onSelectPlan={(planId) => {
|
||||
setSelectedPlanId(planId);
|
||||
@@ -217,6 +261,14 @@ export function TguPlanningPage() {
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
</main>
|
||||
{createChainOpen && selectedSpacecraftId && (
|
||||
<CreateChainPlanDialog
|
||||
spacecraftId={selectedSpacecraftId}
|
||||
plans={plans}
|
||||
onClose={() => setCreateChainOpen(false)}
|
||||
onSent={() => void loadData(appliedRange)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visitedTabs.has("map") && (
|
||||
|
||||
@@ -15,15 +15,22 @@ import { platformLabel } from "./tguTimelineMapper";
|
||||
import { statusStyle } from "./tguStatus";
|
||||
import { formatUtcDate, formatUtcDateTime, formatUtcTime, parseUtc } from "../../utils/utcTime";
|
||||
|
||||
type ZrvBands = {
|
||||
spacecraftId: string;
|
||||
windows: { startMs: number; endMs: number }[];
|
||||
};
|
||||
|
||||
type TguTimelineProps = {
|
||||
rows: TimelineRow[];
|
||||
range: TimelineRange;
|
||||
selectedPlanId?: string;
|
||||
invalidRange?: boolean;
|
||||
/** ЗРВ выбранного КА — подсветка доступных зон на его строке. */
|
||||
zrvBands?: ZrvBands;
|
||||
onSelectPlan: (planId: string) => void;
|
||||
};
|
||||
|
||||
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelectPlan }: TguTimelineProps) {
|
||||
export function TguTimeline({ rows, range, selectedPlanId, invalidRange, zrvBands, onSelectPlan }: TguTimelineProps) {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(1200);
|
||||
const timelineWidth = Math.max(720, containerWidth - TIMELINE_LABEL_WIDTH - 28);
|
||||
@@ -124,6 +131,26 @@ export function TguTimeline({ rows, range, selectedPlanId, invalidRange, onSelec
|
||||
<text x="16" y={y + 42} className="tgu-timeline__row-meta">
|
||||
NORAD {row.platform?.noradId || row.spacecraftId}
|
||||
</text>
|
||||
{zrvBands?.spacecraftId === row.spacecraftId &&
|
||||
zrvBands.windows.map((zrv, zrvIndex) => {
|
||||
const from = Math.max(zrv.startMs, range.fromMs);
|
||||
const to = Math.min(zrv.endMs, range.toMs);
|
||||
if (to <= from) return null;
|
||||
const bx = TIMELINE_LABEL_WIDTH + timeToX(from, range, timelineWidth);
|
||||
const bw = Math.max(2, timeToX(to, range, timelineWidth) - timeToX(from, range, timelineWidth));
|
||||
return (
|
||||
<rect
|
||||
key={`zrv-${zrvIndex}`}
|
||||
className="tgu-timeline__zrv"
|
||||
x={bx}
|
||||
y={y + 6}
|
||||
width={bw}
|
||||
height={TIMELINE_ROW_HEIGHT - 12}
|
||||
>
|
||||
<title>ЗРВ {formatUtcDateTime(zrv.startMs)} – {formatUtcDateTime(zrv.endMs)}</title>
|
||||
</rect>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildChainPlanRequest, futureZrvOptions, type ZrvOption } from "./createChainWindows";
|
||||
import type { ZrvWindowResponse } from "../../api/tguPlanApi";
|
||||
|
||||
const NOW = Date.UTC(2026, 5, 3, 12, 0, 0);
|
||||
|
||||
function zrv(startIso: string, endIso: string, kppId = "KPP-1"): ZrvWindowResponse {
|
||||
return { startTime: startIso, endTime: endIso, kppId };
|
||||
}
|
||||
|
||||
describe("futureZrvOptions", () => {
|
||||
it("keeps only future zones and sorts by start", () => {
|
||||
const windows = [
|
||||
zrv("2026-06-03T15:00:00Z", "2026-06-03T15:10:00Z"),
|
||||
zrv("2026-06-03T11:00:00Z", "2026-06-03T11:10:00Z"), // в прошлом → отбросить
|
||||
zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")
|
||||
];
|
||||
const options = futureZrvOptions(windows, NOW);
|
||||
expect(options.map((o) => o.startMs)).toEqual([
|
||||
Date.UTC(2026, 5, 3, 13, 0, 0),
|
||||
Date.UTC(2026, 5, 3, 15, 0, 0)
|
||||
]);
|
||||
});
|
||||
|
||||
it("produces naive UTC (no zone) start/stop strings", () => {
|
||||
const [option] = futureZrvOptions([zrv("2026-06-03T13:00:00Z", "2026-06-03T13:10:00Z")], NOW);
|
||||
expect(option.start).toBe("2026-06-03T13:00:00");
|
||||
expect(option.stop).toBe("2026-06-03T13:10:00");
|
||||
expect(option.kppId).toBe("KPP-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildChainPlanRequest", () => {
|
||||
const start: ZrvOption = {
|
||||
id: "a", startMs: Date.UTC(2026, 5, 3, 13, 0, 0), endMs: Date.UTC(2026, 5, 3, 13, 10, 0),
|
||||
start: "2026-06-03T13:00:00", stop: "2026-06-03T13:10:00", kppId: "KPP-1"
|
||||
};
|
||||
const end: ZrvOption = {
|
||||
id: "b", startMs: Date.UTC(2026, 5, 3, 15, 0, 0), endMs: Date.UTC(2026, 5, 3, 15, 10, 0),
|
||||
start: "2026-06-03T15:00:00", stop: "2026-06-03T15:10:00", kppId: "KPP-2"
|
||||
};
|
||||
|
||||
it("spans from start zone begin to end zone finish and keeps the start kpp", () => {
|
||||
const req = buildChainPlanRequest("25544", "base-1", start, end);
|
||||
expect(req).toEqual({
|
||||
spacecraftId: "25544",
|
||||
basePlanId: "base-1",
|
||||
windowStart: "2026-06-03T13:00:00",
|
||||
windowStop: "2026-06-03T15:10:00",
|
||||
windowKppId: "KPP-1"
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a single-zone window (start == end zone)", () => {
|
||||
const req = buildChainPlanRequest("25544", null, start, start);
|
||||
expect(req.windowStart).toBe("2026-06-03T13:00:00");
|
||||
expect(req.windowStop).toBe("2026-06-03T13:10:00");
|
||||
expect(req.basePlanId).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when the end zone finishes before the start begins", () => {
|
||||
expect(() => buildChainPlanRequest("25544", null, end, start)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { parseUtc } from "../../utils/utcTime";
|
||||
import type { CreateChainPlanRequest, ZrvWindowResponse } from "../../api/tguPlanApi";
|
||||
|
||||
/**
|
||||
* Опция выбора ЗРВ в диалоге «Создать план». Из стартовой зоны берутся начало окна и КПП, из
|
||||
* конечной — конец окна. `start`/`stop` — naive UTC (ISO без зоны), как ждёт `LocalDateTime` бэка;
|
||||
* `startMs`/`endMs` — для сортировки/сравнения и подсветки на таймлайне.
|
||||
*/
|
||||
export type ZrvOption = {
|
||||
/** Детерминированный id зоны (для value в select и key). */
|
||||
id: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
/** Naive UTC начала зоны. */
|
||||
start: string;
|
||||
/** Naive UTC конца зоны. */
|
||||
stop: string;
|
||||
kppId: string;
|
||||
};
|
||||
|
||||
/** Будущие ЗРВ-зоны (строго позже nowMs), по возрастанию начала. */
|
||||
export function futureZrvOptions(windows: ZrvWindowResponse[], nowMs: number): ZrvOption[] {
|
||||
return windows
|
||||
.map((zrv) => {
|
||||
const startMs = parseUtc(zrv.startTime);
|
||||
const endMs = parseUtc(zrv.endTime);
|
||||
return {
|
||||
id: `${zrv.kppId}|${zrv.startTime}|${zrv.endTime}`,
|
||||
startMs,
|
||||
endMs,
|
||||
start: toNaiveUtc(startMs),
|
||||
stop: toNaiveUtc(endMs),
|
||||
kppId: zrv.kppId
|
||||
};
|
||||
})
|
||||
.filter((option) => option.startMs > nowMs)
|
||||
.sort((a, b) => a.startMs - b.startMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Собирает тело запроса создания плана новой цепочки из выбора оператора. Окно — от начала стартовой
|
||||
* зоны до конца конечной; `basePlanId` — предыстория (или null для цепочки без истории). Бросает,
|
||||
* если конец окна не позже начала (конечная зона раньше стартовой).
|
||||
*/
|
||||
export function buildChainPlanRequest(
|
||||
spacecraftId: string,
|
||||
basePlanId: string | null,
|
||||
start: ZrvOption,
|
||||
end: ZrvOption
|
||||
): CreateChainPlanRequest {
|
||||
if (end.endMs <= start.startMs) {
|
||||
throw new Error("Конец окна должен быть позже начала: выберите конечную ЗРВ не раньше стартовой.");
|
||||
}
|
||||
return {
|
||||
spacecraftId,
|
||||
basePlanId,
|
||||
windowStart: start.start,
|
||||
windowStop: end.stop,
|
||||
windowKppId: start.kppId
|
||||
};
|
||||
}
|
||||
|
||||
function toNaiveUtc(ms: number): string {
|
||||
return new Date(ms).toISOString().slice(0, 19);
|
||||
}
|
||||
Reference in New Issue
Block a user