294b17757d
Отказ от остаточной 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. Включает также прочие незакоммиченные правки рабочего дерева.
358 lines
14 KiB
TypeScript
358 lines
14 KiB
TypeScript
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;
|
||
}
|