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:
@@ -297,8 +297,12 @@ P2 = DRAFT (base_plan_id=P0, окно=W)
|
|||||||
> - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением.
|
> - [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Проверить» = серверный `validate` при наличии черновика (иначе локальное превью видимости); «Применить» = `promoteDraft`, активна только при существующем DRAFT; 409/422 (база уехала / слот сместился / заморозка) приходят с бэка с пояснением.
|
||||||
> - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные.
|
> - Приёмка ядра: `tsc -b`, `vitest` (42) и `vite build` — зелёные.
|
||||||
>
|
>
|
||||||
|
> **fork-forward в UI — ✅ СДЕЛАНО:**
|
||||||
|
> - Селектор «Целевая ЗРВ» в [EditorToolbar](services/pcp-tgu-ops-ui/src/features/tgu-editor/EditorToolbar.tsx): «Текущий слот (правка)» (replace, по умолчанию) ⊕ будущие ЗРВ КА. Список — из проекции pcp-tgu-service ([fetchSpacecraftPlans](services/pcp-tgu-ops-ui/src/api/tguPlanApi.ts), `GET /api/plans/{spacecraftId}`), отфильтрованной до PLANNED строго в будущем, кроме базы ([editorForkForward.ts](services/pcp-tgu-ops-ui/src/features/tgu-editor/editorForkForward.ts) `futureZrvWindows` + 5 тестов). Это та же проекция, с которой бэк сверяет окно (`assertWindowMatchesProjection`).
|
||||||
|
> - [TguEditorTab](services/pcp-tgu-ops-ui/src/features/tgu-editor/TguEditorTab.tsx): выбор окна переводит обзор редактора на него; `ensureDraft` шлёт `toCreateDraftRequest(targetWindow)` — fork-forward (naive-окно + kpp) при выбранной ЗРВ, иначе replace. Селектор блокируется после создания DRAFT (режим зафиксирован). Окна — naive-UTC (без зоны), как ждёт `LocalDateTime` бэка.
|
||||||
|
> - Приёмка: `tsc -b`, `vitest` (46) и `vite build` — зелёные.
|
||||||
|
>
|
||||||
> **Вынесено в отдельные подзадачи (не входит в ядро):**
|
> **Вынесено в отдельные подзадачи (не входит в ядро):**
|
||||||
> - **fork-forward в UI**: выбор будущей ЗРВ (мини-панель/диалог) и `createDraft(mode=fork-forward, windowStart/Stop/kpp)` для `LAID_IN`-базы. Сейчас `ensureDraft` всегда шлёт `replace`; для заложенной базы бэк ответит 422 — сообщение показывается оператору.
|
|
||||||
> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote).
|
> - **Откат/версии**: список версий цепочки → «Восстановить» (= новый черновик из старой версии → promote).
|
||||||
> - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора.
|
> - **PATCH-правка контура в инспекторе**: API `updateSurveyRoute` готов, но UI-поля перестроения (окно/крен → новый контур) в `WorkInspector` пока не выведены — нужен апгрейд инспектора.
|
||||||
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`.
|
- `src/features/tgu-editor/editorApi.ts`: заменить заглушку saveEditedPlan на вызовы `draft`/`promote`; `src/api/missionPlanningApi.ts`: `clone`/`patch`/`delete`/`validate`.
|
||||||
|
|||||||
@@ -33,6 +33,36 @@ export type PromoteDraftResponse = {
|
|||||||
attemptId: string;
|
attemptId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** План КА из проекции pcp-tgu-service (persisted ∪ будущая проекция). Времена — ISO с `Z`. */
|
||||||
|
export type TguPlanResponse = {
|
||||||
|
planId: string;
|
||||||
|
spacecraftId: string;
|
||||||
|
startTime: string;
|
||||||
|
endTime: string;
|
||||||
|
kppId: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Цепочка планов одного КА из pcp-tgu-service (GET /api/plans/{spacecraftId}): persisted планы плюс
|
||||||
|
* будущая проекция (PLANNED). Используется fork-forward-пикером для выбора целевой ЗРВ — бэк сверяет
|
||||||
|
* выбранное окно ровно с этой же проекцией (assertWindowMatchesProjection).
|
||||||
|
*/
|
||||||
|
export async function fetchSpacecraftPlans(spacecraftId: string): Promise<TguPlanResponse[]> {
|
||||||
|
const url = `${BASE}/api/plans/${encodeURIComponent(spacecraftId)}`;
|
||||||
|
let response: Response;
|
||||||
|
try {
|
||||||
|
response = await fetch(url, { method: "GET", headers: { Accept: "application/json" } });
|
||||||
|
} catch {
|
||||||
|
throw new TguApiError("Не удалось подключиться к сервису планирования ТГУ.", "network");
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new TguApiError(extractErrorText(text) || "Не удалось получить планы КА.", "http", response.status);
|
||||||
|
}
|
||||||
|
return text.trim() ? (JSON.parse(text) as TguPlanResponse[]) : [];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Создаёт черновик плана (DRAFT) на основе базового плана
|
* Создаёт черновик плана (DRAFT) на основе базового плана
|
||||||
* (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает
|
* (pcp-tgu-service, POST /api/plans/{basePlanId}/draft). Триггерит клон миссии; возвращает
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
import type { TguPlanUi, TguPlatformUi } from "../../model/timelineTypes";
|
||||||
import { formatDuration } from "./editorPresentation";
|
import { formatDuration } from "./editorPresentation";
|
||||||
|
import type { ZrvWindow } from "./editorForkForward";
|
||||||
|
import { formatUtcDateTime } from "../../utils/utcTime";
|
||||||
|
|
||||||
type EditorToolbarProps = {
|
type EditorToolbarProps = {
|
||||||
selectedSpacecraftId?: string;
|
selectedSpacecraftId?: string;
|
||||||
@@ -13,6 +15,10 @@ type EditorToolbarProps = {
|
|||||||
dirty: boolean;
|
dirty: boolean;
|
||||||
applying: boolean;
|
applying: boolean;
|
||||||
canceling: boolean;
|
canceling: boolean;
|
||||||
|
futureWindows: ZrvWindow[];
|
||||||
|
targetWindowId?: string;
|
||||||
|
targetLocked: boolean;
|
||||||
|
onSelectTargetWindow: (planId: string | undefined) => void;
|
||||||
windowLabel: string;
|
windowLabel: string;
|
||||||
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
rightMode: "none" | "inspect" | "shoot" | "downlink" | "mapInfo";
|
||||||
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
onSetRightMode: (mode: "shoot" | "downlink") => void;
|
||||||
@@ -37,6 +43,10 @@ export function EditorToolbar({
|
|||||||
dirty,
|
dirty,
|
||||||
applying,
|
applying,
|
||||||
canceling,
|
canceling,
|
||||||
|
futureWindows,
|
||||||
|
targetWindowId,
|
||||||
|
targetLocked,
|
||||||
|
onSelectTargetWindow,
|
||||||
windowLabel,
|
windowLabel,
|
||||||
rightMode,
|
rightMode,
|
||||||
onSetRightMode,
|
onSetRightMode,
|
||||||
@@ -83,6 +93,25 @@ export function EditorToolbar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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-toolbar__spacer" />
|
||||||
|
|
||||||
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
<div className="tgu-editor-window-control" aria-label="Окно редактора">
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
validateMission,
|
validateMission,
|
||||||
type SurveyRoute
|
type SurveyRoute
|
||||||
} from "../../api/missionPlanningApi";
|
} 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 { parseUtc } from "../../utils/utcTime";
|
||||||
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
|
||||||
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
import { parseWktPolygon } from "../tgu-map-2d/geometry/wktParser";
|
||||||
@@ -121,6 +122,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
// Пока null — редактор показывает живой план read-only; после создания все правки и валидация
|
||||||
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
|
// адресуются в этот DRAFT, а «Применить» делает его promote в живую цепочку.
|
||||||
const [draftPlanId, setDraftPlanId] = useState<string>();
|
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 [passFrom, setPassFrom] = useState(1);
|
||||||
const [passTo, setPassTo] = useState(999);
|
const [passTo, setPassTo] = useState(999);
|
||||||
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
const [flightLineData, setFlightLineData] = useState<FlightLinePoint[]>([]);
|
||||||
@@ -223,6 +228,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
// «Применить» доступно, когда на сервере есть черновик (создаётся на первую сохранённую правку);
|
||||||
// локальные несохранённые твики без DRAFT применять нечего.
|
// локальные несохранённые твики без DRAFT применять нечего.
|
||||||
const dirty = Boolean(draftPlanId);
|
const dirty = Boolean(draftPlanId);
|
||||||
|
const targetWindow = useMemo(
|
||||||
|
() => futureWindows.find((window) => window.planId === targetWindowId),
|
||||||
|
[futureWindows, targetWindowId]
|
||||||
|
);
|
||||||
const addedCount = draft.works.filter((work) => work.isNew).length;
|
const addedCount = draft.works.filter((work) => work.isNew).length;
|
||||||
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
// Реальный состав плана берём из включений (plan modes), а не из черновика:
|
||||||
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
// черновик сидируется заглушкой, когда у плана нет встроенных works.
|
||||||
@@ -247,11 +256,33 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
setNotice(undefined);
|
setNotice(undefined);
|
||||||
setApplyError(undefined);
|
setApplyError(undefined);
|
||||||
setPlanModes([]);
|
setPlanModes([]);
|
||||||
|
setTargetWindowId(undefined);
|
||||||
setPassFrom(1);
|
setPassFrom(1);
|
||||||
setPassTo(999);
|
setPassTo(999);
|
||||||
setFlightLineData([]);
|
setFlightLineData([]);
|
||||||
}, [appliedRange, selectedPlan, selectedSpacecraftId]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
if (!noradId) return;
|
if (!noradId) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -453,15 +484,31 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
changeDraft((current) => updateWork(current, workId, patch));
|
changeDraft((current) => updateWork(current, workId, patch));
|
||||||
};
|
};
|
||||||
|
|
||||||
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT (replace)
|
// Гарантирует наличие черновика: при первой правке клонирует базовый план в DRAFT и возвращает его id.
|
||||||
// и возвращает его id. Дальше все правки/валидация/promote адресуются именно сюда.
|
// Без выбранной целевой ЗРВ — replace (тот же слот); с выбранной — fork-forward на следующую ЗРВ
|
||||||
|
// (для невозвратного плана). Дальше все правки/валидация/promote адресуются именно сюда.
|
||||||
const ensureDraft = useCallback(async (): Promise<string> => {
|
const ensureDraft = useCallback(async (): Promise<string> => {
|
||||||
if (draftPlanId) return draftPlanId;
|
if (draftPlanId) return draftPlanId;
|
||||||
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
if (!selectedPlan) throw new Error("План не выбран — нечего редактировать.");
|
||||||
const created = await createDraft(selectedPlan.planId, { mode: "replace" });
|
const created = await createDraft(selectedPlan.planId, toCreateDraftRequest(targetWindow));
|
||||||
setDraftPlanId(created.draftPlanId);
|
setDraftPlanId(created.draftPlanId);
|
||||||
return 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.
|
// Бэкенд-валидация черновика (Ф4): авторитетные конфликты по режимам → карта по workId.
|
||||||
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
|
// MVP-заглушка возвращает пусто; ошибки валидации не эскалируем в основной поток правок.
|
||||||
@@ -781,6 +828,10 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
|
|||||||
dirty={dirty}
|
dirty={dirty}
|
||||||
applying={applying}
|
applying={applying}
|
||||||
canceling={canceling}
|
canceling={canceling}
|
||||||
|
futureWindows={futureWindows}
|
||||||
|
targetWindowId={targetWindowId}
|
||||||
|
targetLocked={dirty}
|
||||||
|
onSelectTargetWindow={selectTargetWindow}
|
||||||
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
windowLabel={formatDuration(window.toMs - window.fromMs)}
|
||||||
rightMode={rightMode}
|
rightMode={rightMode}
|
||||||
onSetRightMode={(mode) => {
|
onSetRightMode={(mode) => {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
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"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -66,6 +66,36 @@
|
|||||||
min-width: 1rem;
|
min-width: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tgu-editor-target-zrv {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-editor-target-zrv__label {
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-select {
|
||||||
|
height: 1.95rem;
|
||||||
|
max-width: 14rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
background: #101214;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tgu-select:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.tgu-editor-type-dot {
|
.tgu-editor-type-dot {
|
||||||
width: 0.62rem;
|
width: 0.62rem;
|
||||||
height: 0.62rem;
|
height: 0.62rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user