Files
dc-observatio/services/pcp-tgu-ops-ui/src/features/tgu-requests/RequestCreatePanel.tsx
T
Дмитрий Соловьев 294b17757d pcp: время — сквозной naive LocalDateTime как локальное настенное время (без UTC)
Отказ от остаточной UTC-семантики: naive LocalDateTime трактуется как
локальное время единой зоны всех сервисов.

Backend (границы внешних систем):
- VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase: ZoneOffset.UTC →
  ZoneId.systemDefault() для проекции внешних Instant/ISO-с-Z в naive.
- тесты границ перевёрнуты на systemDefault (под Europe/Moscow ждут +3).
- чистка UTC-формулировок в DTO-комментариях, мёртвых ссылок на
  docs/standards/DATETIME_UTC.md.

Frontend (pcp-tgu-ops-ui):
- utils/utcTime.ts → utils/localTime.ts: parseLocal/formatLocal*/toLocalIso/
  toLocalInputValue с локальной семантикой (без дописывания Z и timeZone:UTC).
- замена getUTC*/Date.UTC/toISOString-сериализации на локальные аналоги.
- убраны пользовательские подписи «UTC» в редакторе/таймлайне.
- удалён utcTime.guard.test.ts (форсил обратный UTC-инвариант); тест-фикстуры
  сделаны зоно-независимыми. tsc + vitest зелёные под TZ=UTC и TZ=Europe/Moscow.

Включает также прочие незакоммиченные правки рабочего дерева.
2026-06-04 09:50:39 +03:00

358 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from "react";
import type {
OpticsParams,
OpticsResultType,
Polarisation,
RsaParams,
RsaResultType
} from "../../model/requestTypes";
import { parseLocal } from "../../utils/localTime";
import { DateTime24Field } from "../../components/timeFields";
export type RequestFormSubmit = {
name: string;
importance: number;
beginDateTime: string;
endDateTime: string;
kpp: number[];
highPriorityTransmit: boolean;
optics?: OpticsParams;
rsa?: RsaParams;
};
type Props = {
vertexCount: number;
drawing: boolean;
defaultBegin: string;
defaultEnd: string;
submitting: boolean;
error?: string;
onUndoPoint: () => void;
onClear: () => void;
onFinishDrawing: () => void;
onRestartDrawing: () => void;
onSubmit: (form: RequestFormSubmit) => void;
onCancel: () => void;
};
const OPTICS_RESULT_TYPES: { value: OpticsResultType; label: string }[] = [
{ value: "PANCHROMATIC", label: "Панхроматическая" },
{ value: "MULTISPECTRAL", label: "Мультиспектральная" },
{ value: "PANSHARPENING", label: "Паншарпенинг" }
];
const RSA_RESULT_TYPES: { value: RsaResultType; label: string }[] = [
{ value: "SPOTLIGHT", label: "Spotlight" },
{ value: "STRIPMAP", label: "Stripmap" },
{ value: "SCANSAR", label: "ScanSAR" }
];
const POLARISATIONS: { value: Polarisation; label: string }[] = [
{ value: "HH", label: "HH" },
{ value: "VV", label: "VV" }
];
export function RequestCreatePanel({
vertexCount,
drawing,
defaultBegin,
defaultEnd,
submitting,
error,
onUndoPoint,
onClear,
onFinishDrawing,
onRestartDrawing,
onSubmit,
onCancel
}: Props) {
const [name, setName] = useState("");
const [importance, setImportance] = useState("1");
const [begin, setBegin] = useState(defaultBegin);
const [end, setEnd] = useState(defaultEnd);
const [kpp, setKpp] = useState("");
const [highPriority, setHighPriority] = useState(false);
const [opticsEnabled, setOpticsEnabled] = useState(true);
const [opticsResultType, setOpticsResultType] = useState<OpticsResultType>("PANCHROMATIC");
const [opticsResolution, setOpticsResolution] = useState("1");
const [sunAngleMin, setSunAngleMin] = useState("10");
const [sunAngleMax, setSunAngleMax] = useState("90");
const [clouds, setClouds] = useState("100");
const [rsaEnabled, setRsaEnabled] = useState(false);
const [rsaResultType, setRsaResultType] = useState<RsaResultType>("STRIPMAP");
const [interferometry, setInterferometry] = useState(false);
const [polarisation, setPolarisation] = useState<Polarisation>("HH");
const [rsaResolution, setRsaResolution] = useState("1");
const importanceNum = Number(importance);
const beginMs = parseLocal(begin);
const endMs = parseLocal(end);
const kppList = parseKpp(kpp);
const kppValid = kppList !== null;
const geometryReady = vertexCount >= 3 && !drawing;
const validationError = buildValidationError({
geometryReady,
drawing,
vertexCount,
name,
importance,
importanceNum,
beginMs,
endMs,
kppValid,
opticsEnabled,
opticsResolution,
rsaEnabled,
rsaResolution
});
const handleSubmit = () => {
if (validationError || !kppList) return;
const optics: OpticsParams | undefined = opticsEnabled
? {
resultType: opticsResultType,
resolution: Number(opticsResolution),
sunAngleMin: Number(sunAngleMin),
sunAngleMax: Number(sunAngleMax),
clouds: Number(clouds)
}
: undefined;
const rsa: RsaParams | undefined = rsaEnabled
? {
resultType: rsaResultType,
interferometry,
polarisation,
resolution: Number(rsaResolution)
}
: undefined;
onSubmit({
name: name.trim(),
importance: importanceNum,
beginDateTime: begin,
endDateTime: end,
kpp: kppList,
highPriorityTransmit: highPriority,
optics,
rsa
});
};
return (
<aside className="tgu-map-requests-panel tgu-requests-form">
<div className="tgu-panel-heading">
<span>Новая заявка</span>
<button className="tgu-link-button" type="button" onClick={onCancel}>
Отмена
</button>
</div>
<div className="tgu-requests-form__body">
<section className="tgu-requests-form__section">
<div className="tgu-map-panel__label">Геометрия (полигон)</div>
<div className="tgu-requests-form__hint">
{drawing
? "Кликайте по карте, чтобы добавить вершины. Нажмите «Завершить» (≥3 вершин)."
: geometryReady
? `Полигон задан: ${vertexCount} вершин.`
: "Полигон не задан."}
</div>
<div className="tgu-requests-form__geo-status">Вершин: {vertexCount}</div>
<div className="tgu-requests-form__actions">
{drawing ? (
<>
<button className="tgu-button tgu-button--sm" type="button" onClick={onUndoPoint} disabled={vertexCount === 0}>
Отменить точку
</button>
<button className="tgu-button tgu-button--sm" type="button" onClick={onClear} disabled={vertexCount === 0}>
Очистить
</button>
<button
className="tgu-button tgu-button--primary tgu-button--sm"
type="button"
onClick={onFinishDrawing}
disabled={vertexCount < 3}
>
Завершить
</button>
</>
) : (
<button className="tgu-button tgu-button--sm" type="button" onClick={onRestartDrawing}>
Перерисовать
</button>
)}
</div>
</section>
<section className="tgu-requests-form__section">
<div className="tgu-map-panel__label">Параметры заявки</div>
<label className="tgu-requests-field">
<span>Название</span>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Название задания" />
</label>
<label className="tgu-requests-field">
<span>Важность</span>
<input type="number" min="0" step="0.1" value={importance} onChange={(e) => setImportance(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Начало</span>
<DateTime24Field value={begin} onChange={setBegin} />
</label>
<label className="tgu-requests-field">
<span>Конец</span>
<DateTime24Field value={end} onChange={setEnd} />
</label>
<label className="tgu-requests-field">
<span>КПП</span>
<input type="text" value={kpp} onChange={(e) => setKpp(e.target.value)} placeholder="напр. 1, 2, 5" />
</label>
<label className="tgu-requests-checkbox">
<input type="checkbox" checked={highPriority} onChange={(e) => setHighPriority(e.target.checked)} />
<span>Высокоприоритетная передача</span>
</label>
</section>
<section className="tgu-requests-form__section">
<label className="tgu-requests-checkbox tgu-requests-form__block-toggle">
<input type="checkbox" checked={opticsEnabled} onChange={(e) => setOpticsEnabled(e.target.checked)} />
<span>Оптическая съёмка</span>
</label>
{opticsEnabled && (
<div className="tgu-requests-form__block">
<label className="tgu-requests-field">
<span>Результат</span>
<select value={opticsResultType} onChange={(e) => setOpticsResultType(e.target.value as OpticsResultType)}>
{OPTICS_RESULT_TYPES.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="tgu-requests-field">
<span>Разрешение, м</span>
<input type="number" min="0.1" step="0.1" value={opticsResolution} onChange={(e) => setOpticsResolution(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Высота Солнца мин, °</span>
<input type="number" min="0" max="90" step="1" value={sunAngleMin} onChange={(e) => setSunAngleMin(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Высота Солнца макс, °</span>
<input type="number" min="0" max="90" step="1" value={sunAngleMax} onChange={(e) => setSunAngleMax(e.target.value)} />
</label>
<label className="tgu-requests-field">
<span>Облачность макс, %</span>
<input type="number" min="0" max="100" step="1" value={clouds} onChange={(e) => setClouds(e.target.value)} />
</label>
</div>
)}
</section>
<section className="tgu-requests-form__section">
<label className="tgu-requests-checkbox tgu-requests-form__block-toggle">
<input type="checkbox" checked={rsaEnabled} onChange={(e) => setRsaEnabled(e.target.checked)} />
<span>Радиолокационная съёмка (РСА)</span>
</label>
{rsaEnabled && (
<div className="tgu-requests-form__block">
<label className="tgu-requests-field">
<span>Тип аппаратуры</span>
<select value={rsaResultType} onChange={(e) => setRsaResultType(e.target.value as RsaResultType)}>
{RSA_RESULT_TYPES.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="tgu-requests-field">
<span>Поляризация</span>
<select value={polarisation} onChange={(e) => setPolarisation(e.target.value as Polarisation)}>
{POLARISATIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</label>
<label className="tgu-requests-field">
<span>Разрешение, м</span>
<input type="number" min="0.1" step="0.1" value={rsaResolution} onChange={(e) => setRsaResolution(e.target.value)} />
</label>
<label className="tgu-requests-checkbox">
<input type="checkbox" checked={interferometry} onChange={(e) => setInterferometry(e.target.checked)} />
<span>Интерферометрия</span>
</label>
</div>
)}
</section>
{(validationError || error) && (
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{error ?? validationError}</div>
)}
</div>
<div className="tgu-requests-form__footer">
<button
className="tgu-button tgu-button--primary"
type="button"
disabled={submitting || validationError != null}
onClick={handleSubmit}
>
{submitting ? "Создание..." : "Создать заявку"}
</button>
</div>
</aside>
);
}
function parseKpp(raw: string): number[] | null {
const trimmed = raw.trim();
if (trimmed === "") return [];
const parts = trimmed.split(/[,\s]+/).filter(Boolean);
const result: number[] = [];
for (const part of parts) {
const n = Number(part);
if (!Number.isInteger(n) || n < 0) return null;
result.push(n);
}
return result;
}
function buildValidationError(input: {
geometryReady: boolean;
drawing: boolean;
vertexCount: number;
name: string;
importance: string;
importanceNum: number;
beginMs: number;
endMs: number;
kppValid: boolean;
opticsEnabled: boolean;
opticsResolution: string;
rsaEnabled: boolean;
rsaResolution: string;
}): string | null {
if (input.drawing) return "Завершите рисование полигона (≥3 вершин).";
if (!input.geometryReady) return "Задайте полигон минимум из 3 вершин.";
if (input.name.trim() === "") return "Укажите название заявки.";
if (input.importance.trim() === "" || !Number.isFinite(input.importanceNum) || input.importanceNum < 0) {
return "Важность должна быть числом ≥ 0.";
}
if (!Number.isFinite(input.beginMs) || !Number.isFinite(input.endMs) || input.beginMs >= input.endMs) {
return "Начало должно быть раньше конца.";
}
if (!input.kppValid) return "КПП — список целых чисел через запятую.";
if (!input.opticsEnabled && !input.rsaEnabled) {
return "Включите хотя бы один блок съёмки: Оптика или РСА.";
}
if (input.opticsEnabled && !(Number(input.opticsResolution) >= 0.1)) {
return "Разрешение оптики должно быть ≥ 0.1 м.";
}
if (input.rsaEnabled && !(Number(input.rsaResolution) >= 0.1)) {
return "Разрешение РСА должно быть ≥ 0.1 м.";
}
return null;
}