0128c5569c
Новая вкладка «Заявки»: показ заявок за интервал на 2D-карте, создание заявки рисованием полигона на карте с формой параметров (Оптика/РСА) и удаление выбранной заявки (createRequest/deleteRequest, RequestCreatePanel/RequestDetailsPanel, polygonToWkt). В Tgu2DMapView добавлен изолированный режим рисования полигона. 2D-карта: панели информации о станции и о заявках, слой станций (stationsApi), поддержка выбора объектов через спатиальный индекс. Список слоёв упрощён — трасса/полоса/маркеры КА убраны из переключателя, по умолчанию включены работы плана, станции и заявки.
242 lines
8.5 KiB
TypeScript
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);
|
|
}
|