6689351ef0
- новая вкладка: заявки интервала на карте, выбор заявки кликом/инспектором, расчёт слотов покрытия, бронирование и снятие брони - выбор заявки без выпадающего списка (не масштабируется на ~10к): все заявки на карте, панель справа с кнопкой «Выбрать», имя выбранной заявки в тулбаре, кнопка «Сброс», фильтр заявок по интервалу - бронь отмечаем по ответу POST booking/request (by-request отдаёт слот в чужом cycle-фрейме — ключ не совпадал, бейдж «бронь» не появлялся) - подсветка и автоскролл активной строки по клику на слот (ручной scrollBy) - меню выбора при плотном наложении слотов (SlotPickMenu); контур заявки pickable:false, чтобы не «съедать» меню; панель не закрывается при выборе - фильтр отображения по суткам (SlotDaySelector) - gateway: маршрут slots (/api/pcp-slots/**) - карточка полосы обзора (swath): КА и виток в инспекторе
631 lines
26 KiB
TypeScript
631 lines
26 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
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 { TguComplexPlanTab } from "../tgu-complex-plan/TguComplexPlanTab";
|
||
import { ConstellationTab } from "../constellation/ConstellationTab";
|
||
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
|
||
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
|
||
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
|
||
import { TguSlotsTab } from "../tgu-slots/TguSlotsTab";
|
||
import {
|
||
abandonDraft,
|
||
createDraft,
|
||
fetchDraftPlans,
|
||
fetchZrvWindows,
|
||
promoteDraft,
|
||
type ZrvWindowResponse
|
||
} from "../../api/tguPlanApi";
|
||
import { fetchPlanModes } from "../tgu-editor/editorApi";
|
||
import { CreateChainPlanDialog } from "./CreateChainPlanDialog";
|
||
import { TguPlanDetails, type PlanModeCounts } from "./TguPlanDetails";
|
||
import { TguPlanningLayout } from "./TguPlanningLayout";
|
||
import { TguSidebar } from "./TguSidebar";
|
||
import { TguTimeline, type ZrvWindow } from "./TguTimeline";
|
||
import { TguToolbar } from "./TguToolbar";
|
||
import { TguZrvPanel } from "./TguZrvPanel";
|
||
import {
|
||
buildPlatformsForInterval,
|
||
buildTimelineRows,
|
||
isOperationalPlatform,
|
||
mapPlans,
|
||
operationalSpacecraftIds,
|
||
pausedSpacecraftIds
|
||
} from "./tguTimelineMapper";
|
||
import { parseLocal, toLocalInputValue } from "../../utils/localTime";
|
||
|
||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||
const MINUTE_MS = 60 * 1000;
|
||
|
||
export function TguPlanningPage() {
|
||
const initialRange = useMemo(defaultRange, []);
|
||
const [fromValue, setFromValue] = useState(toLocalInputValue(initialRange.fromMs));
|
||
const [toValue, setToValue] = useState(toLocalInputValue(initialRange.toMs));
|
||
const [appliedRange, setAppliedRange] = useState<TimelineRange>(initialRange);
|
||
const [rawPlatforms, setRawPlatforms] = useState<TguPlatform[]>([]);
|
||
const [plans, setPlans] = useState<TguPlanUi[]>([]);
|
||
const [loading, setLoading] = useState(false);
|
||
const [pageError, setPageError] = useState<string>();
|
||
const [platformLoadFailed, setPlatformLoadFailed] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const [selectedSpacecraftId, setSelectedSpacecraftId] = useState<string>();
|
||
const [selectedPlanId, setSelectedPlanId] = useState<string>();
|
||
const [activeTab, setActiveTab] = useState<TguActiveTab>("timeline");
|
||
const [createChainOpen, setCreateChainOpen] = useState(false);
|
||
// Черновики (DRAFT) — план-кандидаты, скрытые из основного read-path; грузим отдельно.
|
||
const [drafts, setDrafts] = useState<TguPlanUi[]>([]);
|
||
const [draftActionInFlight, setDraftActionInFlight] = useState(false);
|
||
// Тумблеры видимости «непринятого будущего» на схеме.
|
||
const [showDrafts, setShowDrafts] = useState(false);
|
||
const [showProjection, setShowProjection] = useState(true);
|
||
// ЗРВ выбранного КА — подсветка доступных зон на таймлайне (на какие зоны можно опереться).
|
||
const [zrvWindows, setZrvWindows] = useState<ZrvWindowResponse[]>([]);
|
||
// Выбор ЗРВ на таймлайне: пересекающиеся в точке зоны + индекс активной (панель справа).
|
||
const [zrvSelection, setZrvSelection] = useState<{ windows: ZrvWindow[]; index: number }>();
|
||
// Вкладки рендерятся в режиме keep-alive: каждую монтируем при первом открытии
|
||
// и дальше держим смонтированной (скрытой через display:none), чтобы её
|
||
// внутреннее состояние — загруженные заявки, выбор, слои, скролл — не сбрасывалось
|
||
// при переключении между вкладками.
|
||
const [visitedTabs, setVisitedTabs] = useState<ReadonlySet<TguActiveTab>>(() => new Set(["timeline"]));
|
||
const [decisionInFlight, setDecisionInFlight] = useState(false);
|
||
const [decisionNotice, setDecisionNotice] = useState<string>();
|
||
const [decisionError, setDecisionError] = useState<string>();
|
||
|
||
const parsedInputRange = useMemo(
|
||
() => ({
|
||
fromMs: parseLocal(fromValue),
|
||
toMs: parseLocal(toValue)
|
||
}),
|
||
[fromValue, toValue]
|
||
);
|
||
const invalidRange =
|
||
!Number.isFinite(parsedInputRange.fromMs) ||
|
||
!Number.isFinite(parsedInputRange.toMs) ||
|
||
parsedInputRange.fromMs >= parsedInputRange.toMs;
|
||
|
||
const loadData = useCallback(
|
||
async (range: TimelineRange) => {
|
||
setLoading(true);
|
||
setPageError(undefined);
|
||
setDecisionError(undefined);
|
||
|
||
const historyDays = historyDaysForRange(range);
|
||
const [platformResult, planResult, draftResult] = await Promise.allSettled([
|
||
fetchPlatforms(),
|
||
fetchPlans(historyDays),
|
||
fetchDraftPlans()
|
||
]);
|
||
|
||
// На морде работаем только с эксплуатируемыми КА — остальные отсекаем сразу
|
||
// на входе, чтобы они не всплывали ни в списке, ни как fallback-строки по планам.
|
||
const operationalPlatforms =
|
||
platformResult.status === "fulfilled" ? platformResult.value.filter(isOperationalPlatform) : [];
|
||
const operationalIds = operationalSpacecraftIds(operationalPlatforms);
|
||
|
||
if (platformResult.status === "rejected") {
|
||
setPlatformLoadFailed(true);
|
||
} else {
|
||
setPlatformLoadFailed(false);
|
||
setRawPlatforms(operationalPlatforms);
|
||
}
|
||
|
||
if (planResult.status === "rejected") {
|
||
setPlans([]);
|
||
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
|
||
} else {
|
||
setPlans(mapPlans(planResult.value, operationalPlatforms).filter((plan) => operationalIds.has(plan.spacecraftId)));
|
||
}
|
||
|
||
// Черновики читаем из pcp-tgu-service отдельно; сбой непрозрачен — просто нет черновиков.
|
||
if (draftResult.status === "fulfilled") {
|
||
setDrafts(
|
||
mapPlans(draftResult.value as unknown as TguPlan[], operationalPlatforms)
|
||
.filter((plan) => operationalIds.has(plan.spacecraftId))
|
||
);
|
||
} else {
|
||
setDrafts([]);
|
||
}
|
||
|
||
setLoading(false);
|
||
},
|
||
[]
|
||
);
|
||
|
||
useEffect(() => {
|
||
void loadData(appliedRange);
|
||
}, [appliedRange, loadData]);
|
||
|
||
useEffect(() => {
|
||
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: parseLocal(zrv.startTime),
|
||
endMs: parseLocal(zrv.endTime),
|
||
kppId: zrv.kppId
|
||
}))
|
||
};
|
||
}, [selectedSpacecraftId, zrvWindows]);
|
||
|
||
// Сбрасываем выбранную ЗРВ при смене КА (зоны перечитываются) — панель не должна показывать чужие.
|
||
useEffect(() => {
|
||
setZrvSelection(undefined);
|
||
}, [selectedSpacecraftId]);
|
||
|
||
const selectedZrvId = useMemo(() => {
|
||
if (!zrvSelection) return undefined;
|
||
const zrv = zrvSelection.windows[zrvSelection.index];
|
||
return zrv ? `${zrv.kppId}|${zrv.startMs}` : undefined;
|
||
}, [zrvSelection]);
|
||
|
||
// display:contents для активной вкладки сохраняет grid-раскладку shell
|
||
// (внутренний <main> остаётся прямым grid-элементом), display:none скрывает остальные.
|
||
const tabStyle = (tab: TguActiveTab) =>
|
||
({ display: activeTab === tab ? "contents" : "none" }) as const;
|
||
|
||
// Все планы для выбора/поиска (включая черновики), независимо от тумблеров видимости.
|
||
const allPlans = useMemo(() => [...plans, ...drafts], [plans, drafts]);
|
||
// Планы для отрисовки на таймлайне с учётом тумблеров: проекция PLANNED и черновики.
|
||
const visiblePlans = useMemo(() => {
|
||
const base = showProjection ? plans : plans.filter((plan) => plan.status !== "PLANNED");
|
||
return showDrafts ? [...base, ...drafts] : base;
|
||
}, [plans, drafts, showProjection, showDrafts]);
|
||
|
||
// КА с цепочкой на паузе после REJECTED (см. pausedSpacecraftIds): считаем по persisted-планам,
|
||
// черновики/проекция паузу не снимают. Совпадает с серверной паузой воркера выдачи.
|
||
const paused = useMemo(() => pausedSpacecraftIds(plans), [plans]);
|
||
|
||
const platforms = useMemo(
|
||
() => buildPlatformsForInterval(rawPlatforms, allPlans, appliedRange),
|
||
[rawPlatforms, allPlans, appliedRange]
|
||
);
|
||
const rows = useMemo(
|
||
() => buildTimelineRows(visiblePlans, platforms, appliedRange, selectedSpacecraftId),
|
||
[visiblePlans, platforms, appliedRange, selectedSpacecraftId]
|
||
);
|
||
const selectedPlan = useMemo(() => {
|
||
const plan = allPlans.find((item) => item.planId === selectedPlanId);
|
||
if (!plan) return undefined;
|
||
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
|
||
return { ...plan, platform };
|
||
}, [allPlans, platforms, selectedPlanId]);
|
||
|
||
// Состав включений выбранного плана (счётчики съёмок/сбросов) для правой панели.
|
||
// planId == mission_id, поэтому грузим режимы плана тем же эндпоинтом, что и редактор.
|
||
// Зависим и от activeTab: при возврате на вкладку «Планы» перечитываем состав, иначе
|
||
// счётчик маршрутов остаётся прежним после правок в редакторе (selectedPlanId не менялся).
|
||
const [planModeCounts, setPlanModeCounts] = useState<PlanModeCounts>();
|
||
useEffect(() => {
|
||
if (!selectedPlanId || activeTab !== "timeline") {
|
||
setPlanModeCounts(undefined);
|
||
return;
|
||
}
|
||
let cancelled = false;
|
||
setPlanModeCounts(undefined);
|
||
void fetchPlanModes(selectedPlanId)
|
||
.then((modes) => {
|
||
if (cancelled) return;
|
||
setPlanModeCounts({
|
||
survey: modes.filter((mode) => mode.type === "SURVEY").length,
|
||
drop: modes.filter((mode) => mode.type === "DROP").length
|
||
});
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) setPlanModeCounts(undefined);
|
||
});
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [selectedPlanId, activeTab]);
|
||
|
||
const applyRange = () => {
|
||
setDecisionNotice(undefined);
|
||
if (invalidRange) {
|
||
setPageError("Некорректный интервал: from должен быть меньше to.");
|
||
return;
|
||
}
|
||
setPageError(undefined);
|
||
setAppliedRange(parsedInputRange);
|
||
};
|
||
|
||
const quickRange = (hours: number) => {
|
||
const fromMs = startOfCurrentDayMs();
|
||
const toMs = fromMs + hours * 60 * 60 * 1000;
|
||
setFromValue(toLocalInputValue(fromMs));
|
||
setToValue(toLocalInputValue(toMs));
|
||
setAppliedRange({ fromMs, toMs });
|
||
setPageError(undefined);
|
||
setDecisionNotice(undefined);
|
||
};
|
||
|
||
const handleDecision = async (planId: string, decision: TguPlanDecision, reason?: string) => {
|
||
setDecisionInFlight(true);
|
||
setDecisionError(undefined);
|
||
setDecisionNotice(undefined);
|
||
|
||
try {
|
||
await sendPlanDecision(planId, decision, reason);
|
||
setDecisionNotice(
|
||
decision === "REJECTED"
|
||
? "Решение REJECTED отправлено. План может вернуться в PLANNED для повторной выдачи."
|
||
: "Решение ACCEPTED отправлено."
|
||
);
|
||
await loadData(appliedRange);
|
||
} catch (error) {
|
||
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить решение.");
|
||
} finally {
|
||
setDecisionInFlight(false);
|
||
}
|
||
};
|
||
|
||
// ТЕСТ: подтвердить закладку выбранного плана (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 draft = drafts.find((item) => item.planId === planId);
|
||
setSelectedPlanId(planId);
|
||
if (draft) setSelectedSpacecraftId(draft.spacecraftId);
|
||
setDecisionNotice(undefined);
|
||
setActiveTab("editor");
|
||
};
|
||
|
||
// Отправить черновик вручную (promote): уходит в выдачу, рабочим станет после приёмки.
|
||
const handlePromoteDraft = async (planId: string) => {
|
||
setDraftActionInFlight(true);
|
||
setDecisionError(undefined);
|
||
setDecisionNotice(undefined);
|
||
try {
|
||
await promoteDraft(planId);
|
||
setDecisionNotice("Черновик отправлен на выдачу. Рабочим (якорем цепочки) он станет после приёмки.");
|
||
await loadData(appliedRange);
|
||
} catch (error) {
|
||
setDecisionError(error instanceof Error ? error.message : "Не удалось отправить черновик.");
|
||
} finally {
|
||
setDraftActionInFlight(false);
|
||
}
|
||
};
|
||
|
||
// Создать черновик-копию на основе существующего плана (replace, окно 1:1, все включения).
|
||
// Это путь «сделать исправленный план», а не повторная отправка того же. В редактор сами
|
||
// не уходим — оператор выберет черновик и перейдёт в «Редактор» вручную.
|
||
const handleCreateDraftFromPlan = async (planId: string) => {
|
||
// Один открытый черновик на слот (КА + окно): replace-черновик копирует окно исходного плана
|
||
// 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) {
|
||
setShowDrafts(true);
|
||
setSelectedSpacecraftId(existing.spacecraftId);
|
||
setSelectedPlanId(existing.planId);
|
||
setDecisionNotice("Черновик на основе этого плана уже создан — выбран на схеме.");
|
||
return;
|
||
}
|
||
setDraftActionInFlight(true);
|
||
setDecisionError(undefined);
|
||
setDecisionNotice(undefined);
|
||
try {
|
||
const { draftPlanId } = await createDraft(planId, { mode: "replace" });
|
||
await loadData(appliedRange);
|
||
const base = allPlans.find((item) => item.planId === planId);
|
||
setShowDrafts(true);
|
||
if (base) setSelectedSpacecraftId(base.spacecraftId);
|
||
setSelectedPlanId(draftPlanId);
|
||
setDecisionNotice("Черновик создан и выбран на схеме. Откройте «Редактор», чтобы поправить и отправить его.");
|
||
} catch (error) {
|
||
setDecisionError(error instanceof Error ? error.message : "Не удалось создать черновик на основе плана.");
|
||
} finally {
|
||
setDraftActionInFlight(false);
|
||
}
|
||
};
|
||
|
||
// Удалить черновик (abandon): убирает DRAFT и его клон-миссию.
|
||
const handleDeleteDraft = async (planId: string) => {
|
||
setDraftActionInFlight(true);
|
||
setDecisionError(undefined);
|
||
setDecisionNotice(undefined);
|
||
try {
|
||
await abandonDraft(planId);
|
||
setSelectedPlanId(undefined);
|
||
setDecisionNotice("Черновик удалён.");
|
||
await loadData(appliedRange);
|
||
} catch (error) {
|
||
setDecisionError(error instanceof Error ? error.message : "Не удалось удалить черновик.");
|
||
} finally {
|
||
setDraftActionInFlight(false);
|
||
}
|
||
};
|
||
|
||
// Снять/вернуть план с учёта: косметико-статистический флаг, не трогает статус и цепочку.
|
||
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={
|
||
<TguToolbar
|
||
fromValue={fromValue}
|
||
toValue={toValue}
|
||
loading={loading}
|
||
activeTab={activeTab}
|
||
error={pageError}
|
||
onTabChange={setActiveTab}
|
||
onFromChange={setFromValue}
|
||
onToChange={setToValue}
|
||
onApply={applyRange}
|
||
onQuickRange={quickRange}
|
||
/>
|
||
}
|
||
>
|
||
<div style={tabStyle("timeline")}>
|
||
<main className="tgu-workspace">
|
||
<TguSidebar
|
||
platforms={platforms}
|
||
selectedSpacecraftId={selectedSpacecraftId}
|
||
search={search}
|
||
platformLoadFailed={platformLoadFailed}
|
||
onSearchChange={setSearch}
|
||
onSelectSpacecraft={(spacecraftId) => {
|
||
setSelectedSpacecraftId(spacecraftId);
|
||
setSelectedPlanId(undefined);
|
||
setDecisionNotice(undefined);
|
||
}}
|
||
/>
|
||
<section className="tgu-center">
|
||
<div className="tgu-center__actions">
|
||
<label className="tgu-center__toggle">
|
||
<input type="checkbox" checked={showDrafts} onChange={(e) => setShowDrafts(e.target.checked)} />
|
||
Черновики
|
||
</label>
|
||
<label className="tgu-center__toggle">
|
||
<input type="checkbox" checked={showProjection} onChange={(e) => setShowProjection(e.target.checked)} />
|
||
Возможные будущие планы
|
||
</label>
|
||
<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}
|
||
selectedZrvId={selectedZrvId}
|
||
pausedSpacecraftIds={paused}
|
||
selectedPlanId={selectedPlanId}
|
||
onSelectPlan={(planId) => {
|
||
setZrvSelection(undefined);
|
||
setDecisionNotice(undefined);
|
||
setDecisionError(undefined);
|
||
setSelectedPlanId(planId);
|
||
const plan = allPlans.find((item) => item.planId === planId);
|
||
if (plan) {
|
||
setSelectedSpacecraftId(plan.spacecraftId);
|
||
}
|
||
}}
|
||
onSelectZrv={(windows, index) => {
|
||
setSelectedPlanId(undefined);
|
||
setDecisionNotice(undefined);
|
||
setDecisionError(undefined);
|
||
setZrvSelection({ windows, index });
|
||
}}
|
||
/>
|
||
</section>
|
||
{zrvSelection ? (
|
||
<TguZrvPanel
|
||
windows={zrvSelection.windows}
|
||
selectedIndex={zrvSelection.index}
|
||
onSelect={(index) => setZrvSelection((current) => (current ? { ...current, index } : current))}
|
||
onClose={() => setZrvSelection(undefined)}
|
||
/>
|
||
) : (
|
||
<TguPlanDetails
|
||
plan={selectedPlan}
|
||
modeCounts={planModeCounts}
|
||
chainPaused={selectedPlan ? paused.has(selectedPlan.spacecraftId) : false}
|
||
decisionInFlight={decisionInFlight}
|
||
notice={decisionNotice}
|
||
error={decisionError}
|
||
onDecision={handleDecision}
|
||
draftActionInFlight={draftActionInFlight}
|
||
onEditDraft={handleEditDraft}
|
||
onPromoteDraft={handlePromoteDraft}
|
||
onDeleteDraft={handleDeleteDraft}
|
||
onCreateDraftFromPlan={handleCreateDraftFromPlan}
|
||
onConfirmLayin={handleConfirmLayin}
|
||
layinConflict={layinConflict}
|
||
onDismissPlan={handleDismissPlan}
|
||
onRestorePlan={handleRestorePlan}
|
||
onClose={() => setSelectedPlanId(undefined)}
|
||
/>
|
||
)}
|
||
</main>
|
||
{createChainOpen && selectedSpacecraftId && (
|
||
<CreateChainPlanDialog
|
||
spacecraftId={selectedSpacecraftId}
|
||
plans={plans}
|
||
initialBasePlanId={selectedPlanId}
|
||
onClose={() => setCreateChainOpen(false)}
|
||
onCreated={() => {
|
||
setShowDrafts(true);
|
||
void loadData(appliedRange);
|
||
}}
|
||
onEdit={(draftPlanId) => {
|
||
setCreateChainOpen(false);
|
||
setSelectedPlanId(draftPlanId);
|
||
setDecisionNotice(undefined);
|
||
setActiveTab("editor");
|
||
}}
|
||
/>
|
||
)}
|
||
</div>
|
||
|
||
{visitedTabs.has("map") && (
|
||
<div style={tabStyle("map")}>
|
||
<Tgu2DMapTab
|
||
range={appliedRange}
|
||
invalidRange={invalidRange}
|
||
selectedPlan={selectedPlan}
|
||
platforms={platforms}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{visitedTabs.has("complex") && (
|
||
<div style={tabStyle("complex")}>
|
||
<TguComplexPlanTab
|
||
range={appliedRange}
|
||
platforms={platforms}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{visitedTabs.has("requests") && (
|
||
<div style={tabStyle("requests")}>
|
||
<TguRequestsTab
|
||
range={appliedRange}
|
||
invalidRange={invalidRange}
|
||
platforms={platforms}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{visitedTabs.has("slots") && (
|
||
<div style={tabStyle("slots")}>
|
||
<TguSlotsTab range={appliedRange} />
|
||
</div>
|
||
)}
|
||
|
||
{visitedTabs.has("constellation") && (
|
||
<div style={tabStyle("constellation")}>
|
||
<ConstellationTab />
|
||
</div>
|
||
)}
|
||
|
||
{visitedTabs.has("editor") && (
|
||
<div style={tabStyle("editor")}>
|
||
<TguEditorTab
|
||
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
|
||
selectedPlan={selectedPlan}
|
||
appliedRange={appliedRange}
|
||
platforms={platforms}
|
||
/>
|
||
</div>
|
||
)}
|
||
</TguPlanningLayout>
|
||
);
|
||
}
|
||
|
||
function defaultRange(): TimelineRange {
|
||
const now = Date.now();
|
||
return { fromMs: now - 30 * MINUTE_MS, toMs: now + 3 * DAY_MS };
|
||
}
|
||
|
||
function startOfCurrentDayMs(): number {
|
||
const now = new Date();
|
||
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
||
}
|
||
|
||
function historyDaysForRange(range: TimelineRange): number {
|
||
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
|
||
return Math.max(1, daysBack + 1);
|
||
}
|