pcp-tgu интерфейс
This commit is contained in:
@@ -0,0 +1,217 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { fetchPlans, fetchPlatforms, sendPlanDecision } from "../../api/tguApi";
|
||||
import type { TguPlanDecision, TguPlatform } from "../../model/tguTypes";
|
||||
import type { TguPlanUi, TimelineRange } from "../../model/timelineTypes";
|
||||
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 [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 refresh = () => {
|
||||
if (invalidRange) {
|
||||
setPageError("Некорректный интервал: from должен быть меньше to.");
|
||||
return;
|
||||
}
|
||||
void loadData(appliedRange);
|
||||
};
|
||||
|
||||
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}
|
||||
error={pageError}
|
||||
onFromChange={setFromValue}
|
||||
onToChange={setToValue}
|
||||
onApply={applyRange}
|
||||
onQuickRange={quickRange}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
}
|
||||
sidebar={
|
||||
<TguSidebar
|
||||
platforms={platforms}
|
||||
selectedSpacecraftId={selectedSpacecraftId}
|
||||
search={search}
|
||||
platformLoadFailed={platformLoadFailed}
|
||||
onSearchChange={setSearch}
|
||||
onSelectSpacecraft={(spacecraftId) => {
|
||||
setSelectedSpacecraftId(spacecraftId);
|
||||
setSelectedPlanId(undefined);
|
||||
setDecisionNotice(undefined);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
timeline={
|
||||
<TguTimeline
|
||||
rows={rows}
|
||||
range={appliedRange}
|
||||
invalidRange={invalidRange}
|
||||
selectedPlanId={selectedPlanId}
|
||||
onSelectPlan={setSelectedPlanId}
|
||||
/>
|
||||
}
|
||||
details={
|
||||
<TguPlanDetails
|
||||
plan={selectedPlan}
|
||||
decisionInFlight={decisionInFlight}
|
||||
notice={decisionNotice}
|
||||
error={decisionError}
|
||||
onDecision={handleDecision}
|
||||
onClose={() => setSelectedPlanId(undefined)}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user