UI: один адрес на шаге с координатами + рабочее геокодирование + авто«прошло времени»

- Step3Circumstances: удалено дублирующее «Последнее известное место»
  (last_known_place); остаётся только tnp_address на шаге координат.
- Step4Environment: кнопка «Найти координаты» теперь РАБОЧАЯ — вызывает
  новый GET /api/v1/geocode (прокси Nominatim, countrycodes=by, короткий
  запрос «пункт, адрес» по граблям geocoding), заполняет lat/lon,
  показывает найденный display_name или ошибку. Без debounce-автоплейса
  (Pitfall 2) — только явный клик.
- Step3Circumstances: «Прошло времени» считается автоматически из
  lost_time (пересчёт каждые 30 с, округление до 0.1 ч); поле остаётся
  редактируемым для корректировки.
- БАГФИКС: форма шлёт lost_time, модель хранит loss_time — алиас в
  _normalize_case_data. Раньше время пропажи молча терялось
  (Pydantic игнорирует неизвестные поля) — elapsed_hours приходилось
  вводить руками.
- last_known_place убран из валидации шага 4.
- backend/routers/geocode.py: Nominatim-прокси с auth (operator/field/admin).

192 passed, JSX валиден.
This commit is contained in:
2026-09-09 19:18:20 +03:00
parent 6ec9536203
commit fa466fc5ed
6 changed files with 177 additions and 29 deletions
@@ -51,9 +51,6 @@ const CaseForm = ({ isMobile = false }) => {
if (!formData.lost_time) {
newErrors.push("Укажите дату и время потери");
}
if (!formData.last_known_place) {
newErrors.push("Укажите последнее известное место");
}
}
// Для десктопа: Шаг 5 = Среда и местность
@@ -1,10 +1,27 @@
import React from "react";
import React, { useEffect } from "react";
const Step3Circumstances = ({ data, updateData }) => {
// «Прошло времени» — автоподсчёт от времени потери (пересчёт каждые 30 с,
// чтобы долго открытая форма не показывала устаревшее значение).
useEffect(() => {
if (!data.lost_time) return;
const recalc = () => {
const lost = new Date(data.lost_time);
if (isNaN(lost.getTime())) return;
const hours = (Date.now() - lost.getTime()) / 3600000;
if (hours >= 0) {
updateData({ elapsed_hours: Math.round(hours * 10) / 10 });
}
};
recalc();
const timer = setInterval(recalc, 30000);
return () => clearInterval(timer);
}, [data.lost_time]);
return (
<div className="form-step">
<h2>Шаг 3: Обстоятельства потери</h2>
<div className="form-group">
<label>Причина потери *</label>
<div className="button-group">
@@ -59,22 +76,17 @@ const Step3Circumstances = ({ data, updateData }) => {
<label>Прошло времени с момента потери (часов)</label>
<input
type="number"
value={data.elapsed_hours || ""}
onChange={(e) => updateData({ elapsed_hours: parseFloat(e.target.value) })}
value={data.elapsed_hours ?? ""}
onChange={(e) => updateData({ elapsed_hours: e.target.value === "" ? null : parseFloat(e.target.value) })}
min="0"
step="0.5"
placeholder="Например: 2.5"
/>
</div>
<div className="form-group">
<label>Последнее известное место *</label>
<input
type="text"
value={data.last_known_place || ""}
onChange={(e) => updateData({ last_known_place: e.target.value })}
placeholder="Деревня Заречье, у магазина"
placeholder="Рассчитается из времени потери"
/>
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
{data.lost_time
? "Заполняется автоматически от времени потери; можно скорректировать"
: "Заполните дату и время потери — посчитается автоматически"}
</p>
</div>
<div className="form-group">
@@ -159,4 +171,4 @@ const Step3Circumstances = ({ data, updateData }) => {
);
};
export default Step3Circumstances;
export default Step3Circumstances;
@@ -1,4 +1,5 @@
import React, { useEffect } from "react";
import { apiFetch } from "../../api/client";
const Step4Environment = ({ data, updateData }) => {
const toggleTerrain = (terrain) => {
@@ -25,6 +26,40 @@ const Step4Environment = ({ data, updateData }) => {
}
}, [data.lost_time]);
const handleGeocode = async () => {
const query = (data.tnp_address || "").trim();
if (query.length < 3) {
updateData({ geocode_error: "Введите адрес (минимум 3 символа)" });
return;
}
updateData({ geocode_loading: true, geocode_error: null, geocode_found: null });
try {
const resp = await apiFetch(`/api/v1/geocode?q=${encodeURIComponent(query)}&limit=1`);
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
throw new Error(err.detail || `Ошибка ${resp.status}`);
}
const result = await resp.json();
const hit = result.results && result.results[0];
if (!hit) {
updateData({
geocode_loading: false,
geocode_error: "Адрес не найден. Уточните: «пункт, адрес» без района/области.",
});
return;
}
updateData({
tnp_lat: hit.lat.toFixed(6),
tnp_lon: hit.lng.toFixed(6),
geocode_loading: false,
geocode_error: null,
geocode_found: hit.display_name,
});
} catch (e) {
updateData({ geocode_loading: false, geocode_error: "Не удалось найти адрес: " + e.message });
}
};
const handleGetLocation = () => {
if (!navigator.geolocation) {
updateData({ gps_error: 'Геолокация не поддерживается вашим браузером' });
@@ -256,16 +291,40 @@ const Step4Environment = ({ data, updateData }) => {
</div>
<div className="form-group">
<label>Или введите адрес</label>
<input
type="text"
value={data.tnp_address || ""}
onChange={(e) => updateData({ tnp_address: e.target.value })}
placeholder="Минск, ул. Ленина 10"
/>
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
Адрес будет преобразован в координаты автоматически
</p>
<label>Или найдите координаты по адресу</label>
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
style={{ flex: 1 }}
value={data.tnp_address || ""}
onChange={(e) => updateData({ tnp_address: e.target.value })}
placeholder="Минск, ул. Ленина 10"
/>
<button
type="button"
className="gps-button"
onClick={handleGeocode}
disabled={data.geocode_loading || !(data.tnp_address || "").trim()}
style={{ whiteSpace: "nowrap" }}
>
{data.geocode_loading ? "Поиск..." : "🔎 Найти координаты"}
</button>
</div>
{data.geocode_error && (
<p style={{ fontSize: "12px", color: "#f85149", marginTop: "4px" }}>
{data.geocode_error}
</p>
)}
{data.geocode_found && (
<p style={{ fontSize: "12px", color: "#3fb950", marginTop: "4px" }}>
Найдено: {data.geocode_found}
</p>
)}
{!data.geocode_error && !data.geocode_found && (
<p style={{ fontSize: "12px", color: "#8b949e", marginTop: "4px" }}>
Формат «пункт, адрес» (район и область не нужны). Координаты заполнятся в поля выше.
</p>
)}
</div>
</div>
);