pcp-tgu-ops-ui: надёжный 24ч ввод времени (своё текстовое поле вместо нативного)

Нативные input type=time/datetime-local рендерят время по локали браузера/ОС
(AM/PM) и игнорируют атрибут lang в Chrome — поэтому lang=ru-RU не помогал.
Заменено на собственные контролы (components/timeFields.tsx):
- Time24Field — текстовый ввод ЧЧ:ММ[:СС] с валидацией, детерминированный 24ч;
- DateTime24Field — нативный календарь для даты + Time24Field для времени.
Подключено в окне маршрута редактора и во всех datetime-local фильтрах
(Планы, заявки на карте, создание/фильтр заявок).
This commit is contained in:
Дмитрий Соловьев
2026-06-03 09:02:27 +03:00
parent f0e16aa4e8
commit 51fd8a446b
6 changed files with 112 additions and 31 deletions
@@ -0,0 +1,94 @@
import { useEffect, useState, type CSSProperties } from "react";
/**
* Ввод времени суток в строгом 24-часовом формате `ЧЧ:ММ[:СС]` (UTC).
*
* Нативные `<input type="time">`/`type="datetime-local"` рендерят время по локали
* браузера/ОС (часто AM/PM) и не подчиняются атрибуту `lang`, поэтому время вводим
* обычным текстовым полем с собственной валидацией. Локальный черновик позволяет
* печатать промежуточные значения; наружу отдаём только корректное `ЧЧ:ММ[:СС]`.
*/
type Time24FieldProps = {
/** Текущее значение в формате `ЧЧ:ММ` или `ЧЧ:ММ:СС`. */
value: string;
/** Показывать и принимать секунды. */
withSeconds?: boolean;
/** Вызывается при вводе корректного значения. */
onChange: (value: string) => void;
className?: string;
style?: CSSProperties;
"aria-label"?: string;
};
const TIME_RE_HM = /^([01]\d|2[0-3]):[0-5]\d$/;
const TIME_RE_HMS = /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d$/;
export function Time24Field({ value, withSeconds = false, onChange, className, style, ...rest }: Time24FieldProps) {
const re = withSeconds ? TIME_RE_HMS : TIME_RE_HM;
const [draft, setDraft] = useState(value);
const [editing, setEditing] = useState(false);
// Пока поле не редактируется — синхронизируем отображение с внешним значением.
useEffect(() => {
if (!editing) setDraft(value);
}, [value, editing]);
return (
<input
{...rest}
type="text"
inputMode="numeric"
className={className}
style={style}
placeholder={withSeconds ? "чч:мм:сс" : "чч:мм"}
value={draft}
onFocus={() => setEditing(true)}
onChange={(event) => {
const next = event.target.value;
setDraft(next);
if (re.test(next)) onChange(next);
}}
onBlur={() => {
setEditing(false);
if (re.test(draft)) onChange(draft);
else setDraft(value); // невалидный ввод — откат к последнему корректному
}}
/>
);
}
/**
* Замена нативного `<input type="datetime-local">`: нативный календарь для даты
* (у даты нет проблемы AM/PM) + [Time24Field] для времени в 24-часовом формате.
* Контракт значения тот же — строка `YYYY-MM-DDTHH:mm` (UTC), как у datetime-local.
*/
type DateTime24FieldProps = {
value: string;
onChange: (value: string) => void;
className?: string;
};
export function DateTime24Field({ value, onChange, className }: DateTime24FieldProps) {
const sep = value.indexOf("T");
const datePart = sep >= 0 ? value.slice(0, sep) : value;
const timePart = sep >= 0 ? value.slice(sep + 1, sep + 6) : "";
const combine = (date: string, time: string) => `${date}T${time || "00:00"}`;
return (
<span className={`tgu-dt24${className ? ` ${className}` : ""}`} style={{ display: "flex", alignItems: "center", gap: "0.3rem" }}>
<input
type="date"
lang="ru-RU"
style={{ width: "8rem" }}
value={datePart}
onChange={(event) => onChange(combine(event.target.value, timePart))}
/>
<Time24Field
style={{ width: "4.6rem", textAlign: "center" }}
value={timePart}
onChange={(time) => onChange(combine(datePart, time))}
/>
</span>
);
}
@@ -1,8 +1,8 @@
import { formatDate, formatDateTime, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus";
import { formatUtcDateTime, parseUtc } from "../../utils/utcTime";
import { Time24Field } from "../../components/timeFields";
import type { RvaZone } from "../../api/ballisticsApi";
import type { ChangeEvent } from "react";
import type { SurveyRoute } from "../../api/missionPlanningApi";
import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { RequestMapItem } from "../../model/requestTypes";
@@ -710,10 +710,8 @@ function RouteTimeStepper({
}) {
const clamp = (next: number) => Math.max(min ?? Number.NEGATIVE_INFINITY, Math.min(max ?? Number.POSITIVE_INFINITY, next));
const change = (deltaMs: number) => onChange(clamp(value + deltaMs));
const onInput = (event: ChangeEvent<HTMLInputElement>) => {
const raw = event.target.value;
if (!raw) return;
// Ввод — только время суток; день оставляем прежним (UTC).
const onTimeChange = (raw: string) => {
// Ввод — только время суток (ЧЧ:ММ:СС, UTC); день оставляем прежним.
const datePart = new Date(value).toISOString().slice(0, 10);
const ms = parseUtc(`${datePart}T${raw}`);
if (Number.isFinite(ms)) onChange(clamp(ms));
@@ -722,8 +720,7 @@ function RouteTimeStepper({
return (
<label className="tgu-editor-time-stepper tgu-editor-time-stepper--seconds">
<span>{label}</span>
{/* lang=ru-RU — чтобы поле показывало время в 24-часовом формате, а не AM/PM. */}
<input lang="ru-RU" type="time" step={1} value={new Date(value).toISOString().slice(11, 19)} onChange={onInput} />
<Time24Field withSeconds value={new Date(value).toISOString().slice(11, 19)} onChange={onTimeChange} />
<div className="tgu-editor-time-stepper__steps">
<button type="button" onClick={() => change(-30_000)}>30с</button>
<button type="button" onClick={() => change(-5_000)}>5с</button>
@@ -4,6 +4,7 @@ import { TGU_2D_MAP_LAYERS, type Tgu2DMapLayerKey, type Tgu2DMapLayersState } fr
import type { Tgu2DMapLayerAvailability } from "./Tgu2DMapLayers";
import type { RequestsState } from "./Tgu2DMapTab";
import { parseUtc } from "../../utils/utcTime";
import { DateTime24Field } from "../../components/timeFields";
const DAY_MS = 24 * 60 * 60 * 1000;
@@ -105,21 +106,11 @@ export function Tgu2DMapSidebar({
<div className="tgu-map-requests-filter">
<label className="tgu-field">
<span>С</span>
<input
lang="ru-RU"
type="datetime-local"
value={requestsState.fromValue}
onChange={(e) => onRequestsFromChange(e.target.value)}
/>
<DateTime24Field value={requestsState.fromValue} onChange={onRequestsFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<input
lang="ru-RU"
type="datetime-local"
value={requestsState.toValue}
onChange={(e) => onRequestsToChange(e.target.value)}
/>
<DateTime24Field value={requestsState.toValue} onChange={onRequestsToChange} />
</label>
<div className="tgu-map-requests-filter__actions">
<button
@@ -1,4 +1,5 @@
import type { TguActiveTab } from "../../model/timelineTypes";
import { DateTime24Field } from "../../components/timeFields";
type TguToolbarProps = {
fromValue: string;
@@ -77,11 +78,11 @@ export function TguToolbar({
<label className="tgu-field">
<span>С</span>
<input lang="ru-RU" type="datetime-local" value={fromValue} onChange={(event) => onFromChange(event.target.value)} />
<DateTime24Field value={fromValue} onChange={onFromChange} />
</label>
<label className="tgu-field">
<span>По</span>
<input lang="ru-RU" type="datetime-local" value={toValue} onChange={(event) => onToChange(event.target.value)} />
<DateTime24Field value={toValue} onChange={onToChange} />
</label>
<button className="tgu-button tgu-button--primary" type="button" onClick={onApply} disabled={loading}>
@@ -7,6 +7,7 @@ import type {
RsaResultType
} from "../../model/requestTypes";
import { parseUtc } from "../../utils/utcTime";
import { DateTime24Field } from "../../components/timeFields";
export type RequestFormSubmit = {
name: string;
@@ -200,11 +201,11 @@ export function RequestCreatePanel({
</label>
<label className="tgu-requests-field">
<span>Начало</span>
<input lang="ru-RU" type="datetime-local" value={begin} onChange={(e) => setBegin(e.target.value)} />
<DateTime24Field value={begin} onChange={setBegin} />
</label>
<label className="tgu-requests-field">
<span>Конец</span>
<input lang="ru-RU" type="datetime-local" value={end} onChange={(e) => setEnd(e.target.value)} />
<DateTime24Field value={end} onChange={setEnd} />
</label>
<label className="tgu-requests-field">
<span>КПП</span>
@@ -10,6 +10,7 @@ import type { Tgu2DMapSelection } from "../tgu-map-2d/model/mapSelectionTypes";
import type { GeoPoint, MapPolygon } from "../tgu-map-2d/model/mapTypes";
import { generateUuid } from "../../utils/uuid";
import { parseUtc, toUtcInputValue, toUtcIso } from "../../utils/utcTime";
import { DateTime24Field } from "../../components/timeFields";
import { RequestCreatePanel, type RequestFormSubmit } from "./RequestCreatePanel";
import { RequestDetailsPanel } from "./RequestDetailsPanel";
import { polygonToWkt } from "./requestGeometry";
@@ -288,20 +289,16 @@ export function TguRequestsTab({ range, invalidRange, platforms }: TguRequestsTa
<div className="tgu-map-requests-filter">
<label className="tgu-field">
<span>С</span>
<input
lang="ru-RU"
type="datetime-local"
<DateTime24Field
value={requestsState.fromValue}
onChange={(e) => setRequestsState((cur) => ({ ...cur, fromValue: e.target.value }))}
onChange={(value) => setRequestsState((cur) => ({ ...cur, fromValue: value }))}
/>
</label>
<label className="tgu-field">
<span>По</span>
<input
lang="ru-RU"
type="datetime-local"
<DateTime24Field
value={requestsState.toValue}
onChange={(e) => setRequestsState((cur) => ({ ...cur, toValue: e.target.value }))}
onChange={(value) => setRequestsState((cur) => ({ ...cur, toValue: value }))}
/>
</label>
<div className="tgu-map-requests-filter__actions">