Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-planning/TguPlanningPage.tsx
T
Дмитрий Соловьев 0128c5569c 2D-карта (станции, заявки) и вкладка «Заявки»
Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте,
создание заявки рисованием полигона на карте с формой параметров
(Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest,
RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView
добавлен изолированный режим рисования полигона.

2D-карта: панели информации о станции и о заявках, слой станций
(stationsApi), поддержка выбора объектов через спатиальный индекс.
Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя,
по умолчанию включены работы плана, станции и заявки.
2026-06-01 22:17:50 +03:00

242 lines
8.5 KiB
TypeScript

import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
import type { TguActiveTab, TguPlanUi, TimelineRange } from "../../model/timelineTypes";
import { TguEditorTab } from "../tgu-editor/TguEditorTab";
import { Tgu2DMapTab } from "../tgu-map-2d/Tgu2DMapTab";
import { TguRequestsTab } from "../tgu-requests/TguRequestsTab";
import { TguPlanDetails } from "./TguPlanDetails";
import { TguPlanningLayout } from "./TguPlanningLayout";
import { TguSidebar } from "./TguSidebar";
import { TguTimeline } from "./TguTimeline";
import { TguToolbar } from "./TguToolbar";
import { buildPlatformsForInterval, buildTimelineRows, mapPlans } from "./tguTimelineMapper";
const DAY_MS = 24 * 60 * 60 * 1000;
export function TguPlanningPage() {
const initialRange = useMemo(defaultRange, []);
const [fromValue, setFromValue] = useState(formatDateTimeLocal(initialRange.fromMs));
const [toValue, setToValue] = useState(formatDateTimeLocal(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 [decisionInFlight, setDecisionInFlight] = useState(false);
const [decisionNotice, setDecisionNotice] = useState<string>();
const [decisionError, setDecisionError] = useState<string>();
const parsedInputRange = useMemo(
() => ({
fromMs: new Date(fromValue).getTime(),
toMs: new Date(toValue).getTime()
}),
[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] = await Promise.allSettled([
fetchPlatforms(),
fetchPlans(historyDays)
]);
if (platformResult.status === "rejected") {
setPlatformLoadFailed(true);
} else {
setPlatformLoadFailed(false);
setRawPlatforms(platformResult.value);
}
if (planResult.status === "rejected") {
setPlans([]);
setPageError(planResult.reason instanceof Error ? planResult.reason.message : "Не удалось загрузить планы.");
} else {
const platforms = platformResult.status === "fulfilled" ? platformResult.value : [];
setPlans(mapPlans(planResult.value, platforms));
}
setLoading(false);
},
[]
);
useEffect(() => {
void loadData(appliedRange);
}, [appliedRange, loadData]);
const platforms = useMemo(
() => buildPlatformsForInterval(rawPlatforms, plans, appliedRange),
[rawPlatforms, plans, appliedRange]
);
const rows = useMemo(
() => buildTimelineRows(plans, platforms, appliedRange, selectedSpacecraftId),
[plans, platforms, appliedRange, selectedSpacecraftId]
);
const selectedPlan = useMemo(() => {
const plan = plans.find((item) => item.planId === selectedPlanId);
if (!plan) return undefined;
const platform = platforms.find((item) => item.spacecraftId === plan.spacecraftId);
return { ...plan, platform };
}, [plans, platforms, selectedPlanId]);
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(formatDateTimeLocal(fromMs));
setToValue(formatDateTimeLocal(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);
}
};
return (
<TguPlanningLayout
toolbar={
<TguToolbar
fromValue={fromValue}
toValue={toValue}
loading={loading}
activeTab={activeTab}
error={pageError}
onTabChange={setActiveTab}
onFromChange={setFromValue}
onToChange={setToValue}
onApply={applyRange}
onQuickRange={quickRange}
/>
}
>
{activeTab === "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">
<TguTimeline
rows={rows}
range={appliedRange}
invalidRange={invalidRange}
selectedPlanId={selectedPlanId}
onSelectPlan={(planId) => {
setSelectedPlanId(planId);
const plan = plans.find((item) => item.planId === planId);
if (plan) {
setSelectedSpacecraftId(plan.spacecraftId);
}
}}
/>
</section>
<TguPlanDetails
plan={selectedPlan}
decisionInFlight={decisionInFlight}
notice={decisionNotice}
error={decisionError}
onDecision={handleDecision}
onClose={() => setSelectedPlanId(undefined)}
/>
</main>
) : activeTab === "map" ? (
<Tgu2DMapTab
range={appliedRange}
invalidRange={invalidRange}
selectedPlan={selectedPlan}
platforms={platforms}
/>
) : activeTab === "requests" ? (
<TguRequestsTab
range={appliedRange}
invalidRange={invalidRange}
platforms={platforms}
/>
) : (
<TguEditorTab
selectedSpacecraftId={selectedPlan?.spacecraftId ?? selectedSpacecraftId}
selectedPlan={selectedPlan}
appliedRange={appliedRange}
platforms={platforms}
/>
)}
</TguPlanningLayout>
);
}
function defaultRange(): TimelineRange {
const fromMs = startOfCurrentDayMs();
return { fromMs, toMs: fromMs + DAY_MS };
}
function startOfCurrentDayMs(): number {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
}
function formatDateTimeLocal(ms: number): string {
const date = new Date(ms);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function historyDaysForRange(range: TimelineRange): number {
const daysBack = Math.ceil(Math.max(0, Date.now() - range.fromMs) / DAY_MS);
return Math.max(1, daysBack + 1);
}