Import Vector lab project

This commit is contained in:
root
2026-06-06 18:31:55 +00:00
commit fcc22063ed
105 changed files with 31639 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
+14645
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "sar-mchs-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@types/leaflet": "^1.9.8",
"@types/leaflet.heat": "^0.2.2",
"axios": "^1.6.0",
"leaflet": "^1.9.4",
"leaflet.heat": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-leaflet": "^4.2.1",
"react-router-dom": "^6.20.0",
"react-scripts": "5.0.1"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"proxy": "http://backend:8000"
}
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="SAR-MCHS - Search and Rescue Management System" />
<title>SAR-MCHS</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.App {
min-height: 100vh;
}
/* Mobile-first responsive breakpoints */
@media (max-width: 768px) {
.admin-dashboard .sidebar {
position: fixed;
left: -260px;
z-index: 1000;
transition: left 0.3s ease;
}
.admin-dashboard .sidebar.open {
left: 0;
}
}
+52
View File
@@ -0,0 +1,52 @@
import React, { useEffect } from "react";
import { BrowserRouter as Router, Routes, Route, Navigate, useLocation } from "react-router-dom";
import AdminDashboard from "./pages/AdminDashboard";
import MobileFormPage from "./pages/MobileFormPage";
import DesktopSARPage from "./pages/DesktopSARPage";
import AnalysisResult from "./pages/analysis/AnalysisResult";
import "./App.css";
function ThemeProvider({ children }) {
const location = useLocation();
useEffect(() => {
let theme = "desktop";
if (location.pathname.startsWith("/mobile") || location.pathname.startsWith("/m")) {
theme = "mobile";
} else if (location.pathname.startsWith("/admin")) {
theme = "admin";
} else if (location.pathname.startsWith("/sar") || location.pathname.startsWith("/desktop") || location.pathname.startsWith("/analysis")) {
theme = "desktop";
}
document.documentElement.setAttribute("data-theme", theme);
}, [location.pathname]);
return children;
}
function App() {
const isMobile = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent) || window.innerWidth < 768;
return (
<Router>
<ThemeProvider>
<Routes>
<Route path="/" element={<Navigate to={isMobile ? "/mobile" : "/desktop"} replace />} />
<Route path="/sar" element={<DesktopSARPage />} />
<Route path="/desktop" element={<DesktopSARPage />} />
<Route path="/analysis/:caseId" element={<AnalysisResult />} />
<Route path="/admin/*" element={<AdminDashboard />} />
<Route path="/mobile" element={<MobileFormPage />} />
</Routes>
</ThemeProvider>
</Router>
);
}
export default App;
@@ -0,0 +1,112 @@
.analysis-layout {
min-height: 100vh;
background: #0a0c0f;
padding: 24px;
}
/* Двухколоночный layout */
.layout-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
max-width: 1800px;
margin: 0 auto;
}
/* Левая колонка - Форма */
.layout-left {
background: #0a0c0f;
}
/* Правая колонка - Карта */
.layout-right {
position: relative;
}
.map-container-sticky {
position: sticky;
top: 24px;
height: fit-content;
}
/* Placeholder для карты */
.map-placeholder {
height: 600px;
background: #161b22;
border: 2px dashed #30363d;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
}
.placeholder-content {
text-align: center;
padding: 40px;
}
.placeholder-icon {
font-size: 64px;
margin-bottom: 16px;
opacity: 0.5;
}
.placeholder-content h3 {
margin: 0 0 12px 0;
font-size: 20px;
color: #e6edf3;
font-weight: 600;
}
.placeholder-content p {
margin: 0;
font-size: 14px;
color: #8b949e;
line-height: 1.5;
max-width: 300px;
}
/* Результаты снизу на всю ширину */
.layout-bottom {
max-width: 1800px;
margin: 0 auto;
width: 100%;
}
/* Адаптивность */
@media (max-width: 1400px) {
.layout-container {
grid-template-columns: 1fr;
}
.map-container-sticky {
position: relative;
top: 0;
}
.map-placeholder {
height: 400px;
}
}
@media (max-width: 768px) {
.analysis-layout {
padding: 16px;
}
.layout-container {
gap: 16px;
}
.placeholder-icon {
font-size: 48px;
}
.placeholder-content h3 {
font-size: 18px;
}
.placeholder-content p {
font-size: 13px;
}
}
@@ -0,0 +1,67 @@
import React, { useState } from "react";
import CaseForm from "../CaseForm/CaseForm";
import MapView from "../MapView/MapView";
import ResultPanel from "../ResultPanel/ResultPanel";
import "./AnalysisLayout.css";
const AnalysisLayout = () => {
const [analysisResult, setAnalysisResult] = useState(null);
const [heatmapData, setHeatmapData] = useState([]);
const [showMap, setShowMap] = useState(false);
const handleAnalysisComplete = (result) => {
setAnalysisResult(result);
setShowMap(true);
};
const handleHeatmapLoad = (data) => {
setHeatmapData(data);
};
return (
<div className="analysis-layout">
<div className="layout-container">
{/* Левая колонка - Форма */}
<div className="layout-left">
<CaseForm
onAnalysisComplete={handleAnalysisComplete}
onHeatmapLoad={handleHeatmapLoad}
/>
</div>
{/* Правая колонка - Карта */}
<div className="layout-right">
{showMap && analysisResult ? (
<div className="map-container-sticky">
<MapView
tnpLat={analysisResult.tnp_lat || 53.9}
tnpLon={analysisResult.tnp_lon || 27.56}
zones={analysisResult.zones || []}
direction={analysisResult.direction}
heatmapData={heatmapData}
showHeatmap={false}
/>
</div>
) : (
<div className="map-placeholder">
<div className="placeholder-content">
<div className="placeholder-icon">🗺️</div>
<h3>Карта поиска</h3>
<p>Заполните форму и запустите анализ для отображения зон поиска</p>
</div>
</div>
)}
</div>
</div>
{/* Результаты снизу на всю ширину */}
{analysisResult && (
<div className="layout-bottom">
<ResultPanel analysisResult={analysisResult} />
</div>
)}
</div>
);
};
export default AnalysisLayout;
@@ -0,0 +1 @@
export { default } from "./AnalysisLayout";
@@ -0,0 +1,186 @@
.behavioral-profiles {
margin: 20px 0;
padding: 20px;
background: #f9f9f9;
border-radius: 8px;
}
.profiles-title {
margin: 0 0 15px 0;
font-size: 1.3rem;
color: #333;
font-weight: 600;
}
.profiles-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 15px;
}
.profile-card {
background: white;
border-radius: 8px;
padding: 15px;
border-left: 4px solid;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.profile-card-red {
border-left-color: #dc3545;
}
.profile-card-orange {
border-left-color: #fd7e14;
}
.profile-card-yellow {
border-left-color: #ffc107;
}
.profile-card-blue {
border-left-color: #0d6efd;
}
.profile-card-purple {
border-left-color: #6f42c1;
}
.profile-card-gray {
border-left-color: #6c757d;
}
.profile-card-green {
border-left-color: #198754;
}
.profile-header {
margin-bottom: 12px;
padding-bottom: 10px;
border-bottom: 1px solid #e9ecef;
}
.profile-title {
margin: 0;
font-size: 1.1rem;
color: #333;
font-weight: 600;
}
.profile-recommendations {
margin-bottom: 12px;
}
.profile-recommendations h5,
.profile-modifiers h5 {
margin: 0 0 8px 0;
font-size: 0.9rem;
color: #666;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.recommendations-list {
margin: 0;
padding-left: 20px;
list-style-type: disc;
}
.recommendations-list li {
margin-bottom: 6px;
font-size: 0.95rem;
color: #555;
line-height: 1.4;
}
.profile-modifiers {
padding-top: 10px;
border-top: 1px solid #e9ecef;
}
.modifiers-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.modifier-item {
display: flex;
align-items: center;
gap: 5px;
padding: 4px 10px;
background: #f8f9fa;
border-radius: 4px;
font-size: 0.9rem;
}
.modifier-zone {
color: #666;
font-weight: 500;
}
.modifier-value {
color: #0d6efd;
font-weight: 700;
}
/* Mobile styles */
@media (max-width: 768px) {
.profiles-grid {
grid-template-columns: 1fr;
gap: 12px;
}
.profile-card {
padding: 12px;
}
.profiles-title {
font-size: 1.1rem;
}
.profile-title {
font-size: 1rem;
}
.recommendations-list li.mobile-hidden {
display: none;
}
.profile-recommendations h5,
.profile-modifiers h5 {
font-size: 0.85rem;
}
.recommendations-list li {
font-size: 0.9rem;
margin-bottom: 5px;
}
.modifier-item {
font-size: 0.85rem;
padding: 3px 8px;
}
}
/* Desktop - full cards */
@media (min-width: 769px) {
.mobile-hidden {
display: list-item !important;
}
}
/* Tablet */
@media (min-width: 769px) and (max-width: 1024px) {
.profiles-grid {
grid-template-columns: repeat(2, 1fr);
}
}
/* Large desktop */
@media (min-width: 1400px) {
.profiles-grid {
grid-template-columns: repeat(3, 1fr);
}
}
@@ -0,0 +1,268 @@
import React from 'react';
import './BehavioralProfile.css';
interface BehavioralProfileProps {
diagnosis_type?: string[];
has_transport?: string;
age_years?: number;
reason?: string;
}
interface ProfileCardData {
id: string;
title: string;
color: string;
recommendations: string[];
zoneModifiers: { zone: string; multiplier: number }[];
}
const BehavioralProfile: React.FC<BehavioralProfileProps> = ({
diagnosis_type = [],
has_transport,
age_years,
reason
}) => {
const getActiveProfiles = (): ProfileCardData[] => {
const profiles: ProfileCardData[] = [];
// РАС (Расстройство аутистического спектра)
if (diagnosis_type.includes('РАС') || diagnosis_type.includes('аутизм')) {
profiles.push({
id: 'ras',
title: 'РАС (Расстройство аутистического спектра)',
color: 'red',
recommendations: [
'Не кричать имя — может спрятаться от громких звуков',
'Приоритет: водоёмы, тихие места, укрытия',
'Перекрыть доступ к воде (пруды, реки, колодцы)',
'Проверить железнодорожные пути и технические помещения',
'Искать в радиусе до 5 км, часто движется по прямой'
],
zoneModifiers: [
{ zone: 'Водоём', multiplier: 3.0 },
{ zone: 'Укрытие', multiplier: 2.0 },
{ zone: 'Населённый пункт', multiplier: 0.4 }
]
});
}
// Эпилепсия
if (diagnosis_type.includes('эпилепсия')) {
profiles.push({
id: 'epilepsy',
title: 'Эпилепсия',
color: 'orange',
recommendations: [
'Искать близко к точке потери — радиус до 1 км',
'Проверить канавы, овраги, низины (риск падения)',
'Приоритет: водоёмы и опасные участки',
'Срочная медицинская помощь при обнаружении',
'Проверить укрытия (мог укрыться после приступа)'
],
zoneModifiers: [
{ zone: 'Водоём', multiplier: 3.5 },
{ zone: 'Укрытие', multiplier: 2.5 },
{ zone: 'Радиус поиска', multiplier: 0.6 }
]
});
}
// СДВГ (Синдром дефицита внимания и гиперактивности)
if (diagnosis_type.includes('СДВГ') || diagnosis_type.includes('гиперактивность')) {
profiles.push({
id: 'adhd',
title: 'СДВГ (Гиперактивность)',
color: 'yellow',
recommendations: [
'Расширенный радиус поиска — может уйти далеко',
'Приоритет: дороги, тропы, интересные объекты',
'Проверить игровые площадки, стройки, заброшенные здания',
'Может отвлечься и изменить направление',
'Искать вдоль дорог и троп'
],
zoneModifiers: [
{ zone: 'Дороги', multiplier: 1.6 },
{ zone: 'Радиус поиска', multiplier: 1.4 }
]
});
}
// Деменция
if (diagnosis_type.includes('деменция') || diagnosis_type.includes('Альцгеймер')) {
profiles.push({
id: 'dementia',
title: 'Деменция / Альцгеймер',
color: 'purple',
recommendations: [
'Искать в радиусе до 2 км от дома',
'Проверить знакомые места (бывший дом, работа)',
'Приоритет: дороги, тропы (идёт по привычному маршруту)',
'Может не откликаться на имя',
'Проверить водоёмы и опасные участки'
],
zoneModifiers: [
{ zone: 'Дороги', multiplier: 1.8 },
{ zone: 'Исторические места', multiplier: 2.0 },
{ zone: 'Водоём', multiplier: 1.5 }
]
});
}
// Велосипед
if (has_transport === 'велосипед') {
profiles.push({
id: 'bicycle',
title: 'Транспорт: Велосипед',
color: 'blue',
recommendations: [
'Радиус поиска ×5 — может уехать на 10-25 км',
'Приоритет: дороги, велодорожки, тропы',
'Запросить записи с камер на дорогах',
'Проверить популярные велосипедные маршруты',
'Лес и бездорожье менее вероятны'
],
zoneModifiers: [
{ zone: 'Радиус поиска', multiplier: 5.0 },
{ zone: 'Дороги', multiplier: 1.8 },
{ zone: 'Лес', multiplier: 0.8 }
]
});
}
// Автомобиль
if (has_transport === 'автомобиль' || has_transport === 'машина') {
profiles.push({
id: 'car',
title: 'Транспорт: Автомобиль',
color: 'blue',
recommendations: [
'Радиус поиска ×10 — может уехать на 50+ км',
'Запросить записи с камер ГИБДД и дорожных камер',
'Проверить заправки, парковки, придорожные кафе',
'Ориентация на дороги и трассы',
'Розыск по номеру автомобиля'
],
zoneModifiers: [
{ zone: 'Радиус поиска', multiplier: 10.0 },
{ zone: 'Дороги', multiplier: 2.5 },
{ zone: 'Лес', multiplier: 0.3 }
]
});
}
// Подросток, ушедший из дома
if (age_years && age_years >= 13 && age_years <= 17 &&
(reason?.includes('ушёл из дома') || reason?.includes('конфликт'))) {
profiles.push({
id: 'runaway',
title: 'Подросток: Уход из дома',
color: 'gray',
recommendations: [
'Розыск через соцсети и друзей',
'Проверить дома друзей, торговые центры, вокзалы',
'Лес и удалённые места менее вероятны',
'Может иметь телефон — запросить геолокацию',
'Проверить популярные места встреч подростков'
],
zoneModifiers: [
{ zone: 'Населённый пункт', multiplier: 2.0 },
{ zone: 'Лес', multiplier: 0.3 },
{ zone: 'Дороги', multiplier: 1.5 }
]
});
}
// Грибник
if (reason?.includes('грибы') || reason?.includes('грибник')) {
profiles.push({
id: 'mushroom',
title: 'Активность: Сбор грибов',
color: 'green',
recommendations: [
'Приоритет: лесные массивы, особенно смешанные леса',
'Радиус до 3 км от точки входа в лес',
'Проверить популярные грибные места',
'Может углубиться в лес, потеряв ориентацию',
'Искать вдоль просек и лесных дорог'
],
zoneModifiers: [
{ zone: 'Лес', multiplier: 3.0 },
{ zone: 'Дороги', multiplier: 0.6 },
{ zone: 'Радиус поиска', multiplier: 1.2 }
]
});
}
// Охотник
if (reason?.includes('охота') || reason?.includes('охотник')) {
profiles.push({
id: 'hunter',
title: 'Активность: Охота',
color: 'green',
recommendations: [
'Радиус до 5-10 км — охотники перемещаются активно',
'Приоритет: лесные массивы, поля, болота',
'Проверить охотничьи вышки, избушки, кормушки',
'Может иметь оружие — соблюдать осторожность',
'Искать вдоль звериных троп и просек'
],
zoneModifiers: [
{ zone: 'Лес', multiplier: 2.5 },
{ zone: 'Радиус поиска', multiplier: 2.0 },
{ zone: 'Укрытие', multiplier: 1.5 }
]
});
}
return profiles;
};
const activeProfiles = getActiveProfiles();
if (activeProfiles.length === 0) {
return null;
}
return (
<div className="behavioral-profiles">
<h3 className="profiles-title">Поведенческие профили</h3>
<div className="profiles-grid">
{activeProfiles.map((profile) => (
<div
key={profile.id}
className={`profile-card profile-card-${profile.color}`}
>
<div className="profile-header">
<h4 className="profile-title">{profile.title}</h4>
</div>
<div className="profile-recommendations">
<h5>Рекомендации:</h5>
<ul className="recommendations-list">
{profile.recommendations.map((rec, idx) => (
<li key={idx} className={idx >= 2 ? 'mobile-hidden' : ''}>
{rec}
</li>
))}
</ul>
</div>
<div className="profile-modifiers">
<h5>Модификаторы зон:</h5>
<div className="modifiers-list">
{profile.zoneModifiers.map((mod, idx) => (
<div key={idx} className="modifier-item">
<span className="modifier-zone">{mod.zone}:</span>
<span className="modifier-value">×{mod.multiplier.toFixed(1)}</span>
</div>
))}
</div>
</div>
</div>
))}
</div>
</div>
);
};
export default BehavioralProfile;
@@ -0,0 +1,344 @@
.case-form {
max-width: 800px;
margin: 0 auto;
padding: 24px;
background: #0a0c0f;
min-height: 100vh;
}
.form-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #21262d;
}
.form-header h1 {
margin: 0;
font-size: 28px;
color: #e6edf3;
font-weight: 700;
}
.step-indicator {
font-size: 14px;
color: #8b949e;
font-weight: 500;
}
.form-content {
margin-bottom: 24px;
}
.form-step {
background: #161b22;
border: 1px solid #21262d;
border-radius: 12px;
padding: 24px;
}
.form-step h2 {
margin: 0 0 20px 0;
font-size: 20px;
color: #e6edf3;
font-weight: 600;
}
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: #e6edf3;
font-weight: 500;
}
.form-group input[type="text"],
.form-group input[type="number"],
.form-group textarea,
.form-group select {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus,
.form-group select:focus {
outline: none;
border-color: #ff4d00;
}
.form-group textarea {
resize: vertical;
font-family: inherit;
}
.button-group {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.button-group button {
padding: 10px 16px;
background: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
}
.button-group button:hover {
background: #30363d;
border-color: #ff4d00;
}
.button-group button.selected {
background: #ff4d00;
color: #ffffff;
border-color: #ff4d00;
font-weight: 600;
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
}
.checkbox-label:hover {
background: #161b22;
border-color: #ff4d00;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
accent-color: #ff4d00;
}
.form-navigation {
display: flex;
gap: 12px;
justify-content: flex-end;
}
.form-navigation button {
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.form-navigation button:first-child {
background: #21262d;
color: #e6edf3;
border: 1px solid #30363d;
}
.form-navigation button:first-child:hover {
background: #30363d;
}
.form-navigation button:last-child {
background: #ff4d00;
color: #ffffff;
}
.form-navigation button:last-child:hover {
background: #ff6a2e;
}
.form-navigation button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error-banner {
background: #da3633;
color: white;
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
font-size: 14px;
}
.error-banner div {
margin-bottom: 4px;
}
.error-banner div:last-child {
margin-bottom: 0;
}
/* Психотип - улучшенная читаемость */
.psychotype-section {
background: #161b22;
border-left: 4px solid #ff4d00;
padding: 24px;
}
.section-header {
margin-bottom: 24px;
}
.section-subtitle {
margin: 8px 0 0 0;
font-size: 14px;
color: #8b949e;
font-style: italic;
}
.psychotype-question {
margin-bottom: 32px;
padding-bottom: 24px;
border-bottom: 1px solid #21262d;
}
.psychotype-question:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.question-label {
display: block;
margin-bottom: 16px;
font-size: 16px;
color: #e6edf3;
font-weight: 600;
}
.button-group-vertical {
display: flex;
flex-direction: column;
gap: 12px;
}
.psychotype-btn {
padding: 16px 20px;
background: #0d1117;
color: #e6edf3;
border: 2px solid #30363d;
border-radius: 8px;
font-size: 15px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
width: 100%;
}
.psychotype-btn:hover {
background: #161b22;
border-color: #ff4d00;
transform: translateX(4px);
}
.psychotype-btn.selected {
background: #ff4d00;
color: #ffffff;
border-color: #ff4d00;
font-weight: 600;
}
@media (max-width: 768px) {
.case-form {
padding: 16px;
}
.form-header {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.form-step {
padding: 16px;
}
.form-navigation {
flex-direction: column;
}
.form-navigation button {
width: 100%;
}
.button-group {
flex-direction: column;
}
.button-group button {
width: 100%;
}
}
/* GPS Button */
.gps-button {
width: 100%;
padding: 12px 16px;
background: #238636;
color: #ffffff;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.gps-button:hover {
background: #2ea043;
}
.gps-button:disabled {
background: #21262d;
color: #8b949e;
cursor: not-allowed;
}
/* Datetime input */
input[type="datetime-local"] {
width: 100%;
padding: 10px 12px;
background: #0d1117;
border: 1px solid #30363d;
border-radius: 6px;
color: #e6edf3;
font-size: 14px;
transition: border-color 0.2s;
}
input[type="datetime-local"]:focus {
outline: none;
border-color: #ff4d00;
}
@@ -0,0 +1,212 @@
import React, { useState } from "react";
import Step1Child from "./Step1Child";
import Step2Health from "./Step2Health";
import Step2bPsychotype from "./Step2bPsychotype";
import Step3Circumstances from "./Step3Circumstances";
import Step4Environment from "./Step4Environment";
import "./CaseForm.css";
const INITIAL_DATA = {
has_diagnosis: false,
diagnosis_type: [],
cant_swim: false,
terrain: [],
has_dog: false,
extra_resources: []
};
const CaseForm = ({ isMobile = false }) => {
const [currentStep, setCurrentStep] = useState(1);
const [formData, setFormData] = useState(INITIAL_DATA);
const [errors, setErrors] = useState([]);
const [submitting, setSubmitting] = useState(false);
// Мобильная: 3 шага, Десктоп: 5 шагов
const totalSteps = isMobile ? 3 : 5;
const updateFormData = (updates) => {
setFormData(prev => ({ ...prev, ...updates }));
};
const validateStep = (step) => {
const newErrors = [];
if (step === 1) {
if (!formData.age_years || formData.age_years < 0 || formData.age_years > 120) {
newErrors.push("Укажите корректный возраст");
}
if (!formData.gender) {
newErrors.push("Выберите пол");
}
}
// Для десктопа: Шаг 4 = Обстоятельства
if (step === 4 && !isMobile) {
if (!formData.reason) {
newErrors.push("Укажите причину потери");
}
if (!formData.lost_time) {
newErrors.push("Укажите дату и время потери");
}
if (!formData.last_known_place) {
newErrors.push("Укажите последнее известное место");
}
}
// Для десктопа: Шаг 5 = Среда и местность
if (step === 5 && !isMobile) {
if (!formData.terrain || formData.terrain.length === 0) {
newErrors.push("Выберите хотя бы один тип местности");
}
if (!formData.tnp_lat || !formData.tnp_lon) {
newErrors.push("Укажите GPS координаты точки потери");
}
}
// Для мобильной: Шаг 3 = Обстоятельства (с GPS)
if (step === 3 && isMobile) {
if (!formData.reason) {
newErrors.push("Укажите причину потери");
}
if (!formData.tnp_lat || !formData.tnp_lon) {
newErrors.push("Укажите GPS координаты ТНП");
}
}
setErrors(newErrors);
return newErrors.length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prev => Math.min(prev + 1, totalSteps));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 1));
};
const handleSubmit = async () => {
if (!validateStep(currentStep)) return;
setSubmitting(true);
setErrors([]);
try {
const payload = {
...formData,
clothes_description: [
formData.clothes_upper,
formData.clothes_lower,
formData.clothes_shoes
].filter(Boolean).join(", ")
};
// Создаем карточку
const response = await fetch("/api/v1/cases", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.detail || "Ошибка при создании карточки");
}
const result = await response.json();
console.log("Case created:", result);
// Перенаправляем на страницу анализа с ID карточки
window.location.href = `/analysis/${result.id}`;
} catch (err) {
setErrors([err.message || "Ошибка при отправке данных"]);
} finally {
setSubmitting(false);
}
};
const renderStep = () => {
// Мобильная версия: 3 шага
// 1 = Ребёнок, 2 = Здоровье, 3 = Обстоятельства
if (isMobile) {
switch (currentStep) {
case 1: return <Step1Child data={formData} updateData={updateFormData} />;
case 2: return <Step2Health data={formData} updateData={updateFormData} />;
case 3: return <Step3Circumstances data={formData} updateData={updateFormData} />;
default: return null;
}
}
// Десктопная версия: 5 шагов
// 1 = Ребёнок, 2 = Здоровье, 3 = Психотип, 4 = Обстоятельства, 5 = Среда
switch (currentStep) {
case 1: return <Step1Child data={formData} updateData={updateFormData} />;
case 2: return <Step2Health data={formData} updateData={updateFormData} />;
case 3: return <Step2bPsychotype data={formData} updateData={updateFormData} />;
case 4: return <Step3Circumstances data={formData} updateData={updateFormData} />;
case 5: return <Step4Environment data={formData} updateData={updateFormData} />;
default: return null;
}
};
return (
<div className="case-form">
<div className="form-header">
<h1>Карточка поиска</h1>
<div className="step-indicator">
Шаг {currentStep} из {totalSteps}
</div>
</div>
{errors.length > 0 && (
<div className="error-banner">
{errors.map((err, idx) => (
<div key={idx}>{err}</div>
))}
</div>
)}
<div className="form-content">
{renderStep()}
</div>
<div className="form-navigation">
{currentStep > 1 && (
<button
type="button"
className="btn-secondary"
onClick={handleBack}
disabled={submitting}
>
Назад
</button>
)}
{currentStep < totalSteps ? (
<button
type="button"
className="btn-primary"
onClick={handleNext}
disabled={submitting}
>
Далее
</button>
) : (
<button
type="button"
className="btn-primary submit-btn"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? "Создание карточки..." : "Создать карточку"}
</button>
)}
</div>
</div>
);
};
export default CaseForm;
@@ -0,0 +1,92 @@
import React from "react";
const Step1Child = ({ data, updateData }) => {
return (
<div className="form-step">
<h2>Шаг 1: Ребёнок</h2>
<div className="form-group">
<label>Имя *</label>
<input
type="text"
value={data.child_name || ""}
onChange={(e) => updateData({ child_name: e.target.value })}
placeholder="Имя ребёнка"
/>
</div>
<div className="form-group">
<label>Возраст (лет) *</label>
<input
type="number"
value={data.age_years || ""}
onChange={(e) => updateData({ age_years: parseInt(e.target.value) })}
min="0"
max="120"
/>
</div>
<div className="form-group">
<label>Пол *</label>
<div className="button-group">
<button
type="button"
className={data.gender === "М" ? "selected" : ""}
onClick={() => updateData({ gender: "М" })}
>
Мужской
</button>
<button
type="button"
className={data.gender === "Ж" ? "selected" : ""}
onClick={() => updateData({ gender: "Ж" })}
>
Женский
</button>
</div>
</div>
<div className="form-group">
<label>Одежда: верх (цвет, тип) *</label>
<input
type="text"
value={data.clothes_upper || ""}
onChange={(e) => updateData({ clothes_upper: e.target.value })}
placeholder="Красная куртка"
/>
</div>
<div className="form-group">
<label>Одежда: низ (цвет, тип)</label>
<input
type="text"
value={data.clothes_lower || ""}
onChange={(e) => updateData({ clothes_lower: e.target.value })}
placeholder="Синие джинсы"
/>
</div>
<div className="form-group">
<label>Обувь (тип, цвет)</label>
<input
type="text"
value={data.clothes_shoes || ""}
onChange={(e) => updateData({ clothes_shoes: e.target.value })}
placeholder="Чёрные кроссовки"
/>
</div>
<div className="form-group">
<label>Особые приметы</label>
<textarea
value={data.special_marks || ""}
onChange={(e) => updateData({ special_marks: e.target.value })}
placeholder="Родинка на щеке, носит очки..."
rows={3}
/>
</div>
</div>
);
};
export default Step1Child;
@@ -0,0 +1,188 @@
import React from "react";
const Step2Health = ({ data, updateData }) => {
const toggleDiagnosis = (diagnosis) => {
const current = data.diagnosis_type || [];
const updated = current.includes(diagnosis)
? current.filter((d) => d !== diagnosis)
: [...current, diagnosis];
updateData({
diagnosis_type: updated,
has_diagnosis: updated.length > 0
});
};
return (
<div className="form-step">
<h2>Шаг 2: Здоровье и особенности</h2>
<div className="form-group">
<label>Заболевания / диагнозы *</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("здоров")}
onChange={() => toggleDiagnosis("здоров")}
/>
Здоров
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("РАС")}
onChange={() => toggleDiagnosis("РАС")}
/>
РАС / Аутизм
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("эпилепсия")}
onChange={() => toggleDiagnosis("эпилепсия")}
/>
Эпилепсия
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("СДВГ")}
onChange={() => toggleDiagnosis("СДВГ")}
/>
СДВГ
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("ЗПР")}
onChange={() => toggleDiagnosis("ЗПР")}
/>
Задержка ПР / ЗПР
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("ДЦП")}
onChange={() => toggleDiagnosis("ДЦП")}
/>
ДЦП
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("слабое_зрение")}
onChange={() => toggleDiagnosis("слабое_зрение")}
/>
Слабое зрение
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.diagnosis_type?.includes("слабый_слух")}
onChange={() => toggleDiagnosis("слабый_слух")}
/>
Слабый слух
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.cant_swim}
onChange={(e) => updateData({ cant_swim: e.target.checked })}
/>
Не умеет плавать
</label>
</div>
</div>
<div className="form-group">
<label>Уровень физподготовки</label>
<div className="button-group">
<button
type="button"
className={data.fitness_level === "low" ? "selected" : ""}
onClick={() => updateData({ fitness_level: "low" })}
>
Низкий
</button>
<button
type="button"
className={data.fitness_level === "medium" ? "selected" : ""}
onClick={() => updateData({ fitness_level: "medium" })}
>
Средний
</button>
<button
type="button"
className={data.fitness_level === "high" ? "selected" : ""}
onClick={() => updateData({ fitness_level: "high" })}
>
Высокий
</button>
</div>
</div>
<div className="form-group">
<label>Наличие транспорта *</label>
<div className="button-group">
<button
type="button"
className={data.has_transport === "none" ? "selected" : ""}
onClick={() => updateData({ has_transport: "none" })}
>
Нет
</button>
<button
type="button"
className={data.has_transport === "bike" ? "selected" : ""}
onClick={() => updateData({ has_transport: "bike" })}
>
Велосипед
</button>
<button
type="button"
className={data.has_transport === "scooter" ? "selected" : ""}
onClick={() => updateData({ has_transport: "scooter" })}
>
Самокат
</button>
<button
type="button"
className={data.has_transport === "other" ? "selected" : ""}
onClick={() => updateData({ has_transport: "other" })}
>
Другое
</button>
</div>
</div>
<div className="form-group">
<label>Телефон *</label>
<div className="button-group">
<button
type="button"
className={data.phone_status === "answers" ? "selected" : ""}
onClick={() => updateData({ phone_status: "answers" })}
>
Есть, отвечает
</button>
<button
type="button"
className={data.phone_status === "silent" ? "selected" : ""}
onClick={() => updateData({ phone_status: "silent" })}
>
Есть, не отвечает
</button>
<button
type="button"
className={data.phone_status === "none" ? "selected" : ""}
onClick={() => updateData({ phone_status: "none" })}
>
Нет
</button>
</div>
</div>
</div>
);
};
export default Step2Health;
@@ -0,0 +1,152 @@
import React from "react";
const Step2bPsychotype = ({ data, updateData }) => {
const updateAnswer = (question, value) => {
updateData({
psychotype_answers: {
...data.psychotype_answers,
[question]: value
}
});
};
const isSelected = (question, value) => {
return data.psychotype_answers?.[question] === value;
};
return (
<div className="form-step psychotype-section">
<div className="section-header">
<h2>Шаг 2б: Поведение ребёнка</h2>
<p className="section-subtitle">
Спросить у родителей. Помогает уточнить зону поиска. Можно пропустить.
</p>
</div>
<div className="psychotype-question">
<label className="question-label">
Как ведёт себя в незнакомой обстановке?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("unfamiliar_behavior", "explore") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "explore")}
>
Активно исследует
</button>
<button
type="button"
className={isSelected("unfamiliar_behavior", "wait") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "wait")}
>
Ждёт и наблюдает
</button>
<button
type="button"
className={isSelected("unfamiliar_behavior", "freeze") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "freeze")}
>
Замирает
</button>
<button
type="button"
className={isSelected("unfamiliar_behavior", "panic") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("unfamiliar_behavior", "panic")}
>
Паникует
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">
Реакция на стресс и неудачи?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("stress_reaction", "angry") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("stress_reaction", "angry")}
>
Злится, кричит
</button>
<button
type="button"
className={isSelected("stress_reaction", "cry") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("stress_reaction", "cry")}
>
Плачет, замыкается
</button>
<button
type="button"
className={isSelected("stress_reaction", "calm") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("stress_reaction", "calm")}
>
Спокойно ищет выход
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">
Лидер или ведомый?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("leadership", "always_leader") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("leadership", "always_leader")}
>
Всегда лидер
</button>
<button
type="button"
className={isSelected("leadership", "sometimes") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("leadership", "sometimes")}
>
Иногда
</button>
<button
type="button"
className={isSelected("leadership", "always_follower") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("leadership", "always_follower")}
>
Всегда ведомый
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">
Любит рисковать?
</label>
<div className="button-group-vertical">
<button
type="button"
className={isSelected("risk_taking", "very") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("risk_taking", "very")}
>
Очень
</button>
<button
type="button"
className={isSelected("risk_taking", "sometimes") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("risk_taking", "sometimes")}
>
Иногда
</button>
<button
type="button"
className={isSelected("risk_taking", "no_cautious") ? "psychotype-btn selected" : "psychotype-btn"}
onClick={() => updateAnswer("risk_taking", "no_cautious")}
>
Нет, осторожный
</button>
</div>
</div>
</div>
);
};
export default Step2bPsychotype;
@@ -0,0 +1,162 @@
import React from "react";
const Step3Circumstances = ({ data, updateData }) => {
return (
<div className="form-step">
<h2>Шаг 3: Обстоятельства потери</h2>
<div className="form-group">
<label>Причина потери *</label>
<div className="button-group">
<button
type="button"
className={data.reason === "ушёл_сам" ? "selected" : ""}
onClick={() => updateData({ reason: "ушёл_сам" })}
>
Ушёл сам
</button>
<button
type="button"
className={data.reason === "заблудился" ? "selected" : ""}
onClick={() => updateData({ reason: "заблудился" })}
>
Заблудился
</button>
<button
type="button"
className={data.reason === "отстал_от_группы" ? "selected" : ""}
onClick={() => updateData({ reason: "отстал_от_группы" })}
>
Отстал от группы
</button>
<button
type="button"
className={data.reason === "конфликт" ? "selected" : ""}
onClick={() => updateData({ reason: "конфликт" })}
>
После конфликта
</button>
<button
type="button"
className={data.reason === "неизвестно" ? "selected" : ""}
onClick={() => updateData({ reason: "неизвестно" })}
>
Неизвестно
</button>
</div>
</div>
<div className="form-group">
<label>Дата и время потери *</label>
<input
type="datetime-local"
value={data.lost_time || ""}
onChange={(e) => updateData({ lost_time: e.target.value })}
/>
</div>
<div className="form-group">
<label>Прошло времени с момента потери (часов)</label>
<input
type="number"
value={data.elapsed_hours || ""}
onChange={(e) => updateData({ elapsed_hours: 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="Деревня Заречье, у магазина"
/>
</div>
<div className="form-group">
<label>Предполагаемое направление движения</label>
<div className="button-group">
<button
type="button"
className={data.direction === "N" ? "selected" : ""}
onClick={() => updateData({ direction: "N" })}
>
Север
</button>
<button
type="button"
className={data.direction === "NE" ? "selected" : ""}
onClick={() => updateData({ direction: "NE" })}
>
Северо-Восток
</button>
<button
type="button"
className={data.direction === "E" ? "selected" : ""}
onClick={() => updateData({ direction: "E" })}
>
Восток
</button>
<button
type="button"
className={data.direction === "SE" ? "selected" : ""}
onClick={() => updateData({ direction: "SE" })}
>
Юго-Восток
</button>
<button
type="button"
className={data.direction === "S" ? "selected" : ""}
onClick={() => updateData({ direction: "S" })}
>
Юг
</button>
<button
type="button"
className={data.direction === "SW" ? "selected" : ""}
onClick={() => updateData({ direction: "SW" })}
>
Юго-Запад
</button>
<button
type="button"
className={data.direction === "W" ? "selected" : ""}
onClick={() => updateData({ direction: "W" })}
>
Запад
</button>
<button
type="button"
className={data.direction === "NW" ? "selected" : ""}
onClick={() => updateData({ direction: "NW" })}
>
Северо-Запад
</button>
<button
type="button"
className={data.direction === "unknown" ? "selected" : ""}
onClick={() => updateData({ direction: "unknown" })}
>
Неизвестно
</button>
</div>
</div>
<div className="form-group">
<label>Описание обстоятельств</label>
<textarea
value={data.circumstances_description || ""}
onChange={(e) => updateData({ circumstances_description: e.target.value })}
placeholder="Подробное описание: что произошло, кто видел последним, что говорил ребёнок..."
rows={5}
/>
</div>
</div>
);
};
export default Step3Circumstances;
@@ -0,0 +1,274 @@
import React, { useEffect } from "react";
const Step4Environment = ({ data, updateData }) => {
const toggleTerrain = (terrain) => {
const current = data.terrain || [];
const updated = current.includes(terrain)
? current.filter((t) => t !== terrain)
: [...current, terrain];
updateData({ terrain: updated });
};
// Автоматически определяем сезон из даты потери
useEffect(() => {
if (data.lost_time && !data.season) {
const date = new Date(data.lost_time);
const month = date.getMonth() + 1; // 1-12
let season = 'лето';
if (month >= 3 && month <= 5) season = 'весна';
else if (month >= 6 && month <= 8) season = 'лето';
else if (month >= 9 && month <= 11) season = 'осень';
else season = 'зима'; // 12, 1, 2
updateData({ season });
}
}, [data.lost_time]);
const handleGetLocation = () => {
if (!navigator.geolocation) {
alert("Геолокация не поддерживается вашим браузером");
return;
}
updateData({ gps_loading: true });
navigator.geolocation.getCurrentPosition(
(position) => {
updateData({
tnp_lat: position.coords.latitude.toFixed(6),
tnp_lon: position.coords.longitude.toFixed(6),
gps_loading: false
});
},
(error) => {
alert("Ошибка получения координат: " + error.message);
updateData({ gps_loading: false });
},
{
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 0
}
);
};
return (
<div className="form-step">
<h2>Шаг 5: Среда и местность</h2>
{/* Сезон определяется автоматически, показываем только для информации */}
{data.season && (
<div className="form-group">
<label>Сезон (определен автоматически)</label>
<div style={{ padding: "12px", background: "#161b22", borderRadius: "6px", color: "#8b949e" }}>
{data.season.charAt(0).toUpperCase() + data.season.slice(1)}
</div>
</div>
)}
<div className="form-group">
<label>Температура (°C)</label>
<input
type="number"
value={data.temperature || ""}
onChange={(e) => updateData({ temperature: parseFloat(e.target.value) })}
min="-40"
max="40"
step="1"
placeholder="Например: 15"
/>
</div>
<div className="form-group">
<label>Осадки</label>
<div className="button-group">
<button
type="button"
className={data.precipitation === "нет" ? "selected" : ""}
onClick={() => updateData({ precipitation: "нет" })}
>
Нет
</button>
<button
type="button"
className={data.precipitation === "дождь" ? "selected" : ""}
onClick={() => updateData({ precipitation: "дождь" })}
>
Дождь
</button>
<button
type="button"
className={data.precipitation === "снег" ? "selected" : ""}
onClick={() => updateData({ precipitation: "снег" })}
>
Снег
</button>
<button
type="button"
className={data.precipitation === "туман" ? "selected" : ""}
onClick={() => updateData({ precipitation: "туман" })}
>
Туман
</button>
</div>
</div>
<div className="form-group">
<label>Видимость</label>
<div className="button-group">
<button
type="button"
className={data.visibility === "хорошая" ? "selected" : ""}
onClick={() => updateData({ visibility: "хорошая" })}
>
Хорошая
</button>
<button
type="button"
className={data.visibility === "средняя" ? "selected" : ""}
onClick={() => updateData({ visibility: "средняя" })}
>
Средняя
</button>
<button
type="button"
className={data.visibility === "плохая" ? "selected" : ""}
onClick={() => updateData({ visibility: "плохая" })}
>
Плохая
</button>
</div>
</div>
<div className="form-group">
<label>Типы местности (можно выбрать несколько) *</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("лес")}
onChange={() => toggleTerrain("лес")}
/>
Лес
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("поле")}
onChange={() => toggleTerrain("поле")}
/>
Поле
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("болото")}
onChange={() => toggleTerrain("болото")}
/>
Болото
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("водоём")}
onChange={() => toggleTerrain("водоём")}
/>
Водоём (река, озеро)
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("населённый_пункт")}
onChange={() => toggleTerrain("населённый_пункт")}
/>
Населённый пункт
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("дороги")}
onChange={() => toggleTerrain("дороги")}
/>
Дороги / тропы
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("заброшенные_здания")}
onChange={() => toggleTerrain("заброшенные_здания")}
/>
Заброшенные здания
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("сельхоз")}
onChange={() => toggleTerrain("сельхоз")}
/>
Сельхоз угодья
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.terrain?.includes("овраги")}
onChange={() => toggleTerrain("овраги")}
/>
Овраги / ямы
</label>
</div>
</div>
<div className="form-group">
<label>Координаты точки потери (GPS) *</label>
<div style={{ marginBottom: "12px" }}>
<button
type="button"
className="gps-button"
onClick={handleGetLocation}
disabled={data.gps_loading}
>
{data.gps_loading ? "Получение координат..." : "📍 Получить текущие координаты"}
</button>
</div>
<div style={{ display: "flex", gap: "12px" }}>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "12px", color: "#8b949e" }}>Широта</label>
<input
type="number"
value={data.tnp_lat || ""}
onChange={(e) => updateData({ tnp_lat: e.target.value })}
placeholder="53.9000"
step="0.000001"
/>
</div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "12px", color: "#8b949e" }}>Долгота</label>
<input
type="number"
value={data.tnp_lon || ""}
onChange={(e) => updateData({ tnp_lon: e.target.value })}
placeholder="27.5667"
step="0.000001"
/>
</div>
</div>
</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>
</div>
</div>
);
};
export default Step4Environment;
@@ -0,0 +1,159 @@
import React from "react";
const Step5Resources = ({ data, updateData }) => {
const toggleEquipment = (equipment) => {
const current = data.equipment || [];
const updated = current.includes(equipment)
? current.filter((e) => e !== equipment)
: [...current, equipment];
updateData({ equipment: updated });
};
return (
<div className="form-step">
<h2>Шаг 5: Ресурсы для поиска</h2>
<div className="form-group">
<label>Количество поисковых групп</label>
<input
type="number"
value={data.search_groups || ""}
onChange={(e) => updateData({ search_groups: parseInt(e.target.value) })}
min="0"
max="100"
placeholder="Например: 3"
/>
</div>
<div className="form-group">
<label>Количество человек в поиске</label>
<input
type="number"
value={data.search_people || ""}
onChange={(e) => updateData({ search_people: parseInt(e.target.value) })}
min="0"
max="1000"
placeholder="Например: 25"
/>
</div>
<div className="form-group">
<label>Поисковая собака</label>
<div className="button-group">
<button
type="button"
className={data.has_dog === true ? "selected" : ""}
onClick={() => updateData({ has_dog: true })}
>
Да
</button>
<button
type="button"
className={data.has_dog === false ? "selected" : ""}
onClick={() => updateData({ has_dog: false })}
>
Нет
</button>
</div>
</div>
<div className="form-group">
<label>Дополнительное оборудование (можно выбрать несколько)</label>
<div className="checkbox-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("дрон")}
onChange={() => toggleEquipment("дрон")}
/>
Дрон / БПЛА
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("тепловизор")}
onChange={() => toggleEquipment("тепловизор")}
/>
Тепловизор
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("квадроцикл")}
onChange={() => toggleEquipment("квадроцикл")}
/>
Квадроцикл / мотовездеход
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("лодка")}
onChange={() => toggleEquipment("лодка")}
/>
Лодка
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("вертолёт")}
onChange={() => toggleEquipment("вертолёт")}
/>
Вертолёт
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("рация")}
onChange={() => toggleEquipment("рация")}
/>
Рации
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("gps")}
onChange={() => toggleEquipment("gps")}
/>
GPS навигаторы
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("медик")}
onChange={() => toggleEquipment("медик")}
/>
Медицинская бригада
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("прожектор")}
onChange={() => toggleEquipment("прожектор")}
/>
Прожекторы / фонари
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={data.equipment?.includes("мегафон")}
onChange={() => toggleEquipment("мегафон")}
/>
Мегафон / громкоговоритель
</label>
</div>
</div>
<div className="form-group">
<label>Дополнительная информация о ресурсах</label>
<textarea
value={data.resources_notes || ""}
onChange={(e) => updateData({ resources_notes: e.target.value })}
placeholder="Особенности организации поиска, доступные ресурсы, ограничения..."
rows={4}
/>
</div>
</div>
);
};
export default Step5Resources;
@@ -0,0 +1 @@
export { default } from "./CaseForm";
+146
View File
@@ -0,0 +1,146 @@
.map-view-container {
position: relative;
width: 100%;
height: 600px;
background: #0a0c0f;
border-radius: 12px;
overflow: hidden;
border: 1px solid #21262d;
}
.map-view {
width: 100%;
height: 100%;
}
.map-controls {
position: absolute;
top: 16px;
right: 16px;
z-index: 1000;
display: flex;
gap: 8px;
}
.control-btn {
padding: 10px 16px;
background: #161b22;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.control-btn:hover:not(:disabled) {
background: #21262d;
border-color: #ff4d00;
}
.control-btn.active {
background: #ff4d00;
color: #ffffff;
border-color: #ff4d00;
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ТНП маркер */
.tnp-marker {
background: transparent;
border: none;
}
.tnp-marker-inner {
width: 40px;
height: 40px;
background: #ff4d00;
color: #ffffff;
border: 3px solid #ffffff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
box-shadow: 0 2px 8px rgba(255, 77, 0, 0.5);
}
/* Стрелка направления */
.direction-arrow {
background: transparent;
border: none;
}
.direction-arrow div {
font-size: 30px;
color: #ff4d00;
text-shadow: 0 0 4px rgba(255, 77, 0, 0.8);
}
/* Leaflet popup стилизация */
.leaflet-popup-content-wrapper {
background: #161b22;
color: #e6edf3;
border: 1px solid #30363d;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
}
.leaflet-popup-content {
margin: 12px;
font-size: 13px;
line-height: 1.5;
}
.leaflet-popup-content b {
color: #ff4d00;
font-size: 14px;
}
.leaflet-popup-content small {
color: #8b949e;
font-size: 11px;
}
.leaflet-popup-tip {
background: #161b22;
border: 1px solid #30363d;
}
.leaflet-popup-close-button {
color: #e6edf3 !important;
}
.leaflet-popup-close-button:hover {
color: #ff4d00 !important;
}
/* Адаптивность */
@media (max-width: 768px) {
.map-view-container {
height: 400px;
}
.map-controls {
top: 8px;
right: 8px;
}
.control-btn {
padding: 8px 12px;
font-size: 12px;
}
.tnp-marker-inner {
width: 32px;
height: 32px;
font-size: 10px;
}
}
+273
View File
@@ -0,0 +1,273 @@
import React, { useEffect, useRef, useState } from "react";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
import "leaflet.heat";
import "./MapView.css";
// Fix Leaflet default marker icons
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png",
iconUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png",
shadowUrl: "https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png",
});
const MapView = ({
tnpLat,
tnpLon,
zones = [],
direction = null,
heatmapData = [],
showHeatmap = false
}) => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const zonesLayerRef = useRef(null);
const heatmapLayerRef = useRef(null);
const tnpMarkerRef = useRef(null);
const directionArrowRef = useRef(null);
const [heatmapVisible, setHeatmapVisible] = useState(showHeatmap);
// Цвета зон из §5 ЦОУ палитры
const ZONE_COLORS = {
0: "#ff4d00", // Зона 1 (0-500м) - красный
1: "#ff9f1a", // Зона 2 (500-1500м) - оранжевый
2: "#ffd166", // Зона 3 (1500-2500м) - желтый
};
// Направления в градусы
const DIRECTION_ANGLES = {
N: 0,
NE: 45,
E: 90,
SE: 135,
S: 180,
SW: 225,
W: 270,
NW: 315,
};
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Инициализация карты
const map = L.map(mapRef.current).setView([tnpLat || 53.9, tnpLon || 27.56], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
maxZoom: 19,
}).addTo(map);
mapInstanceRef.current = map;
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, []);
// Обновление ТНП маркера
useEffect(() => {
if (!mapInstanceRef.current || !tnpLat || !tnpLon) return;
// Удаляем старый маркер
if (tnpMarkerRef.current) {
mapInstanceRef.current.removeLayer(tnpMarkerRef.current);
}
// Создаем красный маркер ТНП
const tnpIcon = L.divIcon({
className: "tnp-marker",
html: "<div class=\"tnp-marker-inner\">ТНП</div>",
iconSize: [40, 40],
iconAnchor: [20, 20],
});
const marker = L.marker([tnpLat, tnpLon], { icon: tnpIcon }).addTo(mapInstanceRef.current);
marker.bindPopup("<b>Точка начала поиска</b><br>" + tnpLat.toFixed(6) + ", " + tnpLon.toFixed(6));
tnpMarkerRef.current = marker;
// Центрируем карту
mapInstanceRef.current.setView([tnpLat, tnpLon], 13);
}, [tnpLat, tnpLon]);
// Обновление стрелки направления
useEffect(() => {
if (!mapInstanceRef.current || !tnpLat || !tnpLon || !direction || direction === "unknown") {
if (directionArrowRef.current) {
mapInstanceRef.current.removeLayer(directionArrowRef.current);
directionArrowRef.current = null;
}
return;
}
// Удаляем старую стрелку
if (directionArrowRef.current) {
mapInstanceRef.current.removeLayer(directionArrowRef.current);
}
const angle = DIRECTION_ANGLES[direction];
if (angle === undefined) return;
// Создаем стрелку (линия 1км в направлении)
const distance = 1000; // метров
const endPoint = calculateDestination(tnpLat, tnpLon, angle, distance);
const arrow = L.polyline(
[[tnpLat, tnpLon], endPoint],
{
color: "#ff4d00",
weight: 3,
opacity: 0.8,
dashArray: "10, 5",
}
).addTo(mapInstanceRef.current);
// Добавляем стрелку на конце
const arrowHead = L.marker(endPoint, {
icon: L.divIcon({
className: "direction-arrow",
html: "<div style=\"transform: rotate(" + angle + "deg)\">➤</div>",
iconSize: [30, 30],
iconAnchor: [15, 15],
}),
}).addTo(mapInstanceRef.current);
directionArrowRef.current = L.layerGroup([arrow, arrowHead]).addTo(mapInstanceRef.current);
}, [tnpLat, tnpLon, direction]);
// Обновление зон (секторные полигоны)
useEffect(() => {
if (!mapInstanceRef.current || !tnpLat || !tnpLon || zones.length === 0) {
if (zonesLayerRef.current) {
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
zonesLayerRef.current = null;
}
return;
}
// Удаляем старые зоны
if (zonesLayerRef.current) {
mapInstanceRef.current.removeLayer(zonesLayerRef.current);
}
const zonePolygons = [];
zones.forEach((zone, index) => {
const color = ZONE_COLORS[index] || "#cccccc";
const directionAngle = DIRECTION_ANGLES[zone.direction] || 0;
// Создаем секторный полигон (угол ±45° от направления)
const sectorAngle = 90; // ширина сектора
const startAngle = directionAngle - sectorAngle / 2;
const endAngle = directionAngle + sectorAngle / 2;
const radiusMeters = zone.distance_km * 1000;
const sectorPoints = [[tnpLat, tnpLon]]; // Начало в ТНП
// Генерируем точки по дуге
for (let angle = startAngle; angle <= endAngle; angle += 5) {
const point = calculateDestination(tnpLat, tnpLon, angle, radiusMeters);
sectorPoints.push(point);
}
sectorPoints.push([tnpLat, tnpLon]); // Замыкаем полигон
const polygon = L.polygon(sectorPoints, {
color: color,
fillColor: color,
fillOpacity: 0.2,
weight: 2,
opacity: 0.6,
});
polygon.bindPopup(
"<b>" + zone.name + "</b><br>" +
"Приоритет: " + zone.priority + "<br>" +
"Оценка: " + zone.score + "<br>" +
"Расстояние: " + zone.distance_km.toFixed(2) + " км<br>" +
"<small>" + zone.reasoning + "</small>"
);
zonePolygons.push(polygon);
});
zonesLayerRef.current = L.layerGroup(zonePolygons).addTo(mapInstanceRef.current);
}, [tnpLat, tnpLon, zones]);
// Обновление тепловой карты
useEffect(() => {
if (!mapInstanceRef.current) return;
// Удаляем старый слой
if (heatmapLayerRef.current) {
mapInstanceRef.current.removeLayer(heatmapLayerRef.current);
heatmapLayerRef.current = null;
}
if (!heatmapVisible || heatmapData.length === 0) return;
// Создаем heatmap слой
const heatPoints = heatmapData.map(point => [
point.lat,
point.lon,
point.intensity || 1.0
]);
const heatLayer = L.heatLayer(heatPoints, {
radius: 25,
blur: 15,
maxZoom: 17,
max: 1.0,
gradient: {
0.0: "#0d1117",
0.3: "#238636",
0.5: "#ffd166",
0.7: "#ff9f1a",
1.0: "#ff4d00"
}
}).addTo(mapInstanceRef.current);
heatmapLayerRef.current = heatLayer;
}, [heatmapVisible, heatmapData]);
// Вычисление точки назначения (lat, lon, angle, distance)
const calculateDestination = (lat, lon, bearing, distance) => {
const R = 6371000; // Радиус Земли в метрах
const φ1 = (lat * Math.PI) / 180;
const λ1 = (lon * Math.PI) / 180;
const θ = (bearing * Math.PI) / 180;
const φ2 = Math.asin(
Math.sin(φ1) * Math.cos(distance / R) +
Math.cos(φ1) * Math.sin(distance / R) * Math.cos(θ)
);
const λ2 = λ1 + Math.atan2(
Math.sin(θ) * Math.sin(distance / R) * Math.cos(φ1),
Math.cos(distance / R) - Math.sin(φ1) * Math.sin(φ2)
);
return [(φ2 * 180) / Math.PI, (λ2 * 180) / Math.PI];
};
return (
<div className="map-view-container">
<div className="map-controls">
<button
className={heatmapVisible ? "control-btn active" : "control-btn"}
onClick={() => setHeatmapVisible(!heatmapVisible)}
disabled={heatmapData.length === 0}
>
{heatmapVisible ? "🔥 Скрыть тепловую карту" : "🔥 Показать тепловую карту"}
</button>
</div>
<div ref={mapRef} className="map-view" />
</div>
);
};
export default MapView;
@@ -0,0 +1 @@
export { default } from "./MapView";
+353
View File
@@ -0,0 +1,353 @@
/* Мобильная форма — палитра из §5 контекста */
.mobile-form {
max-width: 600px;
margin: 0 auto;
padding: 20px;
background: var(--bg-primary);
min-height: 100vh;
}
.mobile-form h1 {
margin: 0 0 24px 0;
font-size: 24px;
color: var(--text-primary);
text-align: center;
font-weight: 700;
}
.form-section {
background: var(--bg-secondary);
padding: 20px;
border-radius: 12px;
margin-bottom: 20px;
border: 1px solid var(--border);
}
.form-section h2 {
margin: 0 0 16px 0;
font-size: 18px;
color: var(--text-primary);
font-weight: 600;
}
.section-header {
margin-bottom: 20px;
}
.section-subtitle {
margin: 8px 0 0 0;
font-size: 14px;
color: var(--text-secondary);
font-style: italic;
}
.form-group {
margin-bottom: 20px;
}
.form-group:last-child {
margin-bottom: 0;
}
.form-group label {
display: block;
margin-bottom: 8px;
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
}
.form-group input[type=number],
.form-group input[type=text],
.form-group input[type=time],
.form-group textarea {
width: 100%;
padding: 12px;
background: var(--input-bg);
color: var(--text-primary);
border: 2px solid var(--input-border);
border-radius: 8px;
font-size: 16px;
transition: border-color 0.2s;
min-height: var(--btn-min-height, 52px);
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--accent);
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.button-group {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.button-group button {
padding: 12px;
background: var(--input-bg);
color: var(--text-secondary);
border: 2px solid var(--input-border);
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
min-height: var(--btn-min-height, 52px);
}
.button-group button:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.button-group button.selected {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.checkbox-group {
display: flex;
flex-direction: column;
gap: 12px;
}
.checkbox-label {
display: flex;
align-items: center;
padding: 12px;
background: var(--input-bg);
border-radius: 8px;
cursor: pointer;
transition: background 0.2s;
min-height: var(--btn-min-height, 52px);
border: 1px solid var(--input-border);
}
.checkbox-label:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.checkbox-label input[type=checkbox] {
width: 24px;
height: 24px;
margin-right: 12px;
cursor: pointer;
accent-color: var(--accent);
}
.psychotype-section {
background: var(--bg-secondary);
border-left: 4px solid var(--accent);
}
.psychotype-question {
margin-bottom: 24px;
}
.psychotype-question:last-child {
margin-bottom: 0;
}
.question-label {
display: block;
margin-bottom: 12px;
font-size: 15px;
color: var(--text-primary);
font-weight: 600;
}
.button-group-vertical {
display: flex;
flex-direction: column;
gap: 10px;
}
.psychotype-btn {
padding: 14px 16px;
background: var(--input-bg);
color: var(--text-secondary);
border: 2px solid var(--input-border);
border-radius: 8px;
font-size: 15px;
text-align: left;
cursor: pointer;
transition: all 0.2s;
min-height: var(--btn-min-height, 52px);
}
.psychotype-btn:hover {
background: var(--bg-secondary);
border-color: var(--accent);
}
.psychotype-btn.selected {
background: var(--accent);
color: white;
border-color: var(--accent);
font-weight: 500;
}
/* GPS кнопка */
.btn-gps {
width: 100%;
padding: 16px;
background: var(--btn-gps, #0a84ff);
color: white;
border: none;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
min-height: var(--btn-min-height, 52px);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.btn-gps:active {
transform: scale(0.98);
}
/* Кнопка подтверждения */
.submit-btn {
width: 100%;
padding: 16px;
background: var(--btn-confirm, var(--accent));
color: white;
border: none;
border-radius: 12px;
font-size: 17px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
margin-top: 24px;
min-height: var(--btn-min-height, 52px);
}
.submit-btn:hover {
opacity: 0.9;
}
.submit-btn:active {
transform: scale(0.98);
}
.submit-btn:disabled {
background: var(--text-secondary);
cursor: not-allowed;
opacity: 0.5;
}
/* Success and Error messages */
.success-message {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
text-align: center;
}
.success-icon {
width: 80px;
height: 80px;
background: var(--success);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 48px;
margin-bottom: 24px;
animation: scaleIn 0.3s ease-out;
}
@keyframes scaleIn {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
.success-message h2 {
margin: 0 0 12px 0;
font-size: 24px;
color: var(--text-primary);
}
.success-message p {
margin: 0;
font-size: 16px;
color: var(--text-secondary);
}
.error-banner {
padding: 16px;
background: var(--danger);
border-left: 4px solid var(--danger);
border-radius: 8px;
color: white;
margin-bottom: 20px;
font-size: 14px;
font-weight: 500;
}
/* Responsive для больших экранов */
@media (min-width: 768px) {
.mobile-form {
padding: 40px 20px;
}
}
/* Оптимизация для маленьких экранов */
@media (max-width: 480px) {
.mobile-form {
padding: 16px;
}
.mobile-form h1 {
font-size: 20px;
}
.form-section {
padding: 16px;
}
.form-section h2 {
font-size: 16px;
}
}
/* Зоны поиска с цветами из палитры */
.zone-card {
border-left: 4px solid;
padding: 16px;
margin-bottom: 12px;
background: var(--bg-secondary);
border-radius: 8px;
}
.zone-card.priority-1 {
border-left-color: var(--zone-1);
}
.zone-card.priority-2 {
border-left-color: var(--zone-2);
}
.zone-card.priority-3 {
border-left-color: var(--zone-3);
}
+361
View File
@@ -0,0 +1,361 @@
import React, { useState } from 'react';
import './MobileForm.css';
const MobileForm = () => {
const [formData, setFormData] = useState({
age: '',
gender: '',
terrain: '',
weather: '',
time_missing: '',
last_location: '',
circumstances: '',
physical_condition: '',
experience: '',
season: '',
diagnosis_type: [],
psychotype_answers: {}
});
const handlePsychotypeAnswer = (question, value) => {
setFormData(prev => ({
...prev,
psychotype_answers: {
...prev.psychotype_answers,
[question]: value
}
}));
};
const isPsychotypeSelected = (question, value) => {
return formData.psychotype_answers?.[question] === value;
};
const handleDiagnosisChange = (diagnosis, checked) => {
const current = formData.diagnosis_type || [];
setFormData({
...formData,
diagnosis_type: checked
? [...current, diagnosis]
: current.filter(d => d !== diagnosis)
});
};
const handleSubmit = async () => {
console.log('Form data:', formData);
// Отправка на backend
try {
const response = await fetch('http://192.168.0.99:8000/api/v1/analyze/combined', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
const result = await response.json();
console.log('Analysis result:', result);
alert('Анализ выполнен! Смотри консоль для результатов.');
} catch (error) {
console.error('Error:', error);
alert('Ошибка при отправке данных');
}
};
return (
<div className="mobile-form">
<h1>Карточка пропавшего</h1>
{/* Базовая информация */}
<section className="form-section">
<h2>Основная информация</h2>
<div className="form-group">
<label>Возраст</label>
<input
type="number"
placeholder="Лет"
value={formData.age}
onChange={(e) => setFormData({...formData, age: e.target.value})}
/>
</div>
<div className="form-group">
<label>Пол</label>
<div className="button-group">
<button
className={formData.gender === 'мужской' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'мужской'})}
>
Мужской
</button>
<button
className={formData.gender === 'женский' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'женский'})}
>
Женский
</button>
</div>
</div>
<div className="form-group">
<label>Сезон</label>
<div className="button-group">
<button
className={formData.season === 'winter' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'winter'})}
>
Зима
</button>
<button
className={formData.season === 'spring' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'spring'})}
>
Весна
</button>
<button
className={formData.season === 'summer' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'summer'})}
>
Лето
</button>
<button
className={formData.season === 'autumn' ? 'selected' : ''}
onClick={() => setFormData({...formData, season: 'autumn'})}
>
Осень
</button>
</div>
</div>
<div className="form-group">
<label>Физическое состояние</label>
<input
type="text"
placeholder="Например: хорошее, слабое, травмы"
value={formData.physical_condition}
onChange={(e) => setFormData({...formData, physical_condition: e.target.value})}
/>
</div>
</section>
{/* Обстоятельства пропажи */}
<section className="form-section">
<h2>Обстоятельства пропажи</h2>
<div className="form-group">
<label>Местность</label>
<select
value={formData.terrain}
onChange={(e) => setFormData({...formData, terrain: e.target.value})}
>
<option value="">Выберите...</option>
<option value="лес">Лес</option>
<option value="поле">Поле</option>
<option value="горы">Горы</option>
<option value="город">Город</option>
<option value="болото">Болото</option>
<option value="водоём">Водоём</option>
</select>
</div>
<div className="form-group">
<label>Погода</label>
<select
value={formData.weather}
onChange={(e) => setFormData({...formData, weather: e.target.value})}
>
<option value="">Выберите...</option>
<option value="ясно">Ясно</option>
<option value="облачно">Облачно</option>
<option value="дождь">Дождь</option>
<option value="снег">Снег</option>
<option value="туман">Туман</option>
<option value="жара">Жара</option>
<option value="холод">Холод</option>
</select>
</div>
<div className="form-group">
<label>Время пропажи</label>
<input
type="text"
placeholder="Например: 2 часа, 6 часов, 1 день"
value={formData.time_missing}
onChange={(e) => setFormData({...formData, time_missing: e.target.value})}
/>
</div>
<div className="form-group">
<label>Последнее известное местоположение</label>
<input
type="text"
placeholder="Например: лесная тропа у озера"
value={formData.last_location}
onChange={(e) => setFormData({...formData, last_location: e.target.value})}
/>
</div>
<div className="form-group">
<label>Обстоятельства</label>
<textarea
placeholder="Например: вышел за грибами, не вернулся к вечеру"
value={formData.circumstances}
onChange={(e) => setFormData({...formData, circumstances: e.target.value})}
rows="3"
/>
</div>
<div className="form-group">
<label>Опыт нахождения на природе</label>
<select
value={formData.experience}
onChange={(e) => setFormData({...formData, experience: e.target.value})}
>
<option value="">Выберите...</option>
<option value="опытный">Опытный</option>
<option value="средний">Средний опыт</option>
<option value="новичок">Новичок</option>
<option value="нет опыта">Нет опыта</option>
</select>
</div>
</section>
{/* Диагнозы */}
<section className="form-section">
<h2>Диагнозы (если есть)</h2>
<div className="checkbox-group">
{['РАС', 'эпилепсия', 'СДВГ', 'деменция', 'Альцгеймер'].map(diagnosis => (
<label key={diagnosis} className="checkbox-label">
<input
type="checkbox"
checked={formData.diagnosis_type?.includes(diagnosis)}
onChange={(e) => handleDiagnosisChange(diagnosis, e.target.checked)}
/>
{diagnosis}
</label>
))}
</div>
</section>
{/* Блок психотипа */}
<section className="form-section psychotype-section">
<div className="section-header">
<h2>Поведение ребёнка (спросить родителей)</h2>
<p className="section-subtitle">Помогает уточнить зону поиска</p>
</div>
{/* Вопрос 1: Незнакомая обстановка */}
<div className="psychotype-question">
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
>
Активно исследует
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
>
Ждёт
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
>
Замирает
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
>
Паникует
</button>
</div>
</div>
{/* Вопрос 2: Реакция на неудачи */}
<div className="psychotype-question">
<label className="question-label">Реакция на неудачи?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
>
Злится, кричит
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
>
Плачет, замыкается
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
>
Спокойно ищет выход
</button>
</div>
</div>
{/* Вопрос 3: Лидер или ведомый */}
<div className="psychotype-question">
<label className="question-label">Лидер или ведомый?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'leader') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
>
Лидер
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
>
Иногда
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('group_role', 'follower') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
>
Всегда ведомый
</button>
</div>
</div>
{/* Вопрос 4: Любит рисковать */}
<div className="psychotype-question">
<label className="question-label">Любит рисковать?</label>
<div className="button-group-vertical">
<button
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'high') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
>
Очень
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'medium') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
>
Иногда
</button>
<button
className={`psychotype-btn ${isPsychotypeSelected('risk_taking', 'low') ? 'selected' : ''}`}
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
>
Нет, осторожный
</button>
</div>
</div>
</section>
{/* Кнопка отправки */}
<button className="submit-btn" onClick={handleSubmit}>
Создать карточку и получить анализ
</button>
</div>
);
};
export default MobileForm;
+296
View File
@@ -0,0 +1,296 @@
import React, { useState } from 'react';
import axios from 'axios';
import './MobileForm.css';
interface PsychotypeAnswers {
unfamiliar_env?: string;
stress_reaction?: string;
group_role?: string;
risk_taking?: string;
}
interface FormData {
age?: number;
gender?: string;
diagnosis_type?: string[];
psychotype_answers?: PsychotypeAnswers;
}
const MobileForm: React.FC = () => {
const [formData, setFormData] = useState<FormData>({
diagnosis_type: [],
psychotype_answers: {}
});
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const handlePsychotypeAnswer = (question: keyof PsychotypeAnswers, value: string) => {
setFormData(prev => ({
...prev,
psychotype_answers: {
...prev.psychotype_answers,
[question]: value
}
}));
};
const isPsychotypeSelected = (question: keyof PsychotypeAnswers, value: string) => {
return formData.psychotype_answers?.[question] === value;
};
const validateForm = (): string | null => {
if (!formData.age || formData.age < 0 || formData.age > 120) {
return 'Укажите корректный возраст';
}
if (!formData.gender) {
return 'Выберите пол';
}
return null;
};
const handleSubmit = async () => {
const validationError = validateForm();
if (validationError) {
setError(validationError);
return;
}
setSubmitting(true);
setError(null);
try {
const response = await axios.post('/api/v1/cases', formData);
console.log('Case created:', response.data);
setSuccess(true);
setTimeout(() => {
setFormData({
diagnosis_type: [],
psychotype_answers: {}
});
setSuccess(false);
}, 2000);
} catch (err: any) {
setError(err.response?.data?.detail || 'Ошибка при создании карточки');
console.error('Submit error:', err);
} finally {
setSubmitting(false);
}
};
if (success) {
return (
<div className="mobile-form">
<div className="success-message">
<div className="success-icon">✓</div>
<h2>Карточка создана!</h2>
<p>Данные успешно сохранены</p>
</div>
</div>
);
}
return (
<div className="mobile-form">
<h1>Карточка пропавшего</h1>
{error && (
<div className="error-banner">
{error}
</div>
)}
<section className="form-section">
<h2>Основная информация</h2>
<div className="form-group">
<label>Возраст *</label>
<input
type="number"
placeholder="Лет"
value={formData.age || ''}
onChange={(e) => setFormData({...formData, age: parseInt(e.target.value)})}
required
/>
</div>
<div className="form-group">
<label>Пол *</label>
<div className="button-group">
<button
type="button"
className={formData.gender === 'male' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'male'})}
>
Мужской
</button>
<button
type="button"
className={formData.gender === 'female' ? 'selected' : ''}
onClick={() => setFormData({...formData, gender: 'female'})}
>
Женский
</button>
</div>
</div>
</section>
<section className="form-section">
<h2>Диагнозы (если есть)</h2>
<div className="checkbox-group">
{['РАС', 'эпилепсия', 'СДВГ', 'деменция'].map(diagnosis => (
<label key={diagnosis} className="checkbox-label">
<input
type="checkbox"
checked={formData.diagnosis_type?.includes(diagnosis)}
onChange={(e) => {
const current = formData.diagnosis_type || [];
setFormData({
...formData,
diagnosis_type: e.target.checked
? [...current, diagnosis]
: current.filter(d => d !== diagnosis)
});
}}
/>
{diagnosis}
</label>
))}
</div>
</section>
<section className="form-section psychotype-section">
<div className="section-header">
<h2>Поведение ребёнка (спросить родителей)</h2>
<p className="section-subtitle">Помогает уточнить зону поиска</p>
</div>
<div className="psychotype-question">
<label className="question-label">Как ведёт себя в незнакомой обстановке?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'explore') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'explore')}
>
Активно исследует
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'wait') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'wait')}
>
Ждёт
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'freeze') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'freeze')}
>
Замирает
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('unfamiliar_env', 'panic') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('unfamiliar_env', 'panic')}
>
Паникует
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">Реакция на неудачи?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'active') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'active')}
>
Злится, кричит
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'cry') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'cry')}
>
Плачет, замыкается
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('stress_reaction', 'calm') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('stress_reaction', 'calm')}
>
Спокойно ищет выход
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">Лидер или ведомый?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'leader') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('group_role', 'leader')}
>
Лидер
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'sometimes') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('group_role', 'sometimes')}
>
Иногда
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('group_role', 'follower') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('group_role', 'follower')}
>
Всегда ведомый
</button>
</div>
</div>
<div className="psychotype-question">
<label className="question-label">Любит рисковать?</label>
<div className="button-group-vertical">
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'high') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('risk_taking', 'high')}
>
Очень
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsycotypeSelected('risk_taking', 'medium') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('risk_taking', 'medium')}
>
Иногда
</button>
<button
type="button"
className={'psychotype-btn ' + (isPsychotypeSelected('risk_taking', 'low') ? 'selected' : '')}
onClick={() => handlePsychotypeAnswer('risk_taking', 'low')}
>
Нет, осторожный
</button>
</div>
</div>
</section>
<button
className="submit-btn"
onClick={handleSubmit}
disabled={submitting}
>
{submitting ? 'Создание...' : 'Создать карточку'}
</button>
</div>
);
};
export default MobileForm;
@@ -0,0 +1,416 @@
.result-panel {
background: #0a0c0f;
border: 1px solid #21262d;
border-radius: 12px;
padding: 24px;
margin-top: 24px;
}
.result-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
padding-bottom: 16px;
border-bottom: 1px solid #21262d;
}
.result-header h2 {
margin: 0;
font-size: 24px;
color: #e6edf3;
font-weight: 700;
}
.result-meta {
display: flex;
gap: 12px;
align-items: center;
}
.execution-time {
font-size: 13px;
color: #8b949e;
padding: 4px 10px;
background: #161b22;
border-radius: 6px;
}
.services-badge {
font-size: 13px;
color: #e6edf3;
padding: 4px 10px;
background: #238636;
border-radius: 6px;
font-weight: 600;
}
/* Блок срочности */
.urgency-block {
display: flex;
gap: 16px;
padding: 20px;
border-radius: 8px;
border-left: 4px solid;
margin-bottom: 24px;
}
.urgency-icon {
font-size: 32px;
line-height: 1;
}
.urgency-content {
flex: 1;
}
.urgency-label {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
letter-spacing: 0.5px;
}
.urgency-summary {
font-size: 14px;
color: #e6edf3;
line-height: 1.5;
}
/* Behavioral Profile карточки */
.behavioral-section {
margin-bottom: 24px;
}
.behavioral-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.behavioral-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.behavioral-card {
background: #161b22;
border: 1px solid #30363d;
border-left: 4px solid;
border-radius: 8px;
padding: 16px;
transition: all 0.2s;
}
.behavioral-card:hover {
background: #1c2128;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.card-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.card-icon {
font-size: 24px;
}
.card-title {
font-size: 15px;
font-weight: 600;
color: #e6edf3;
}
.card-points {
margin: 0;
padding-left: 20px;
list-style: disc;
}
.card-points li {
font-size: 13px;
color: #8b949e;
line-height: 1.6;
margin-bottom: 6px;
}
.card-points li:last-child {
margin-bottom: 0;
}
/* Психотип */
.psychotype-section {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.psychotype-section h3 {
margin: 0 0 12px 0;
font-size: 18px;
color: #ff9f1a;
font-weight: 600;
}
.psychotype-description {
font-size: 14px;
color: #8b949e;
margin-bottom: 16px;
font-style: italic;
}
.psychotype-recommendations {
display: flex;
flex-direction: column;
gap: 10px;
}
.recommendation-item {
font-size: 13px;
color: #e6edf3;
line-height: 1.5;
}
.recommendation-item strong {
color: #ff9f1a;
font-weight: 600;
}
/* Немедленные действия */
.actions-section {
margin-bottom: 24px;
}
.actions-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.actions-list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 12px;
}
.action-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
background: #161b22;
border: 1px solid #30363d;
border-left: 3px solid #ff4d00;
border-radius: 6px;
}
.action-number {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: #ff4d00;
color: #ffffff;
border-radius: 50%;
font-weight: 700;
font-size: 14px;
flex-shrink: 0;
}
.action-text {
flex: 1;
font-size: 14px;
color: #e6edf3;
line-height: 1.6;
padding-top: 4px;
}
/* Зоны */
.zones-section {
margin-bottom: 24px;
}
.zones-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.zones-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.zone-item {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 16px;
transition: all 0.2s;
}
.zone-item:hover {
background: #1c2128;
border-color: #ff4d00;
}
.zone-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
}
.zone-priority {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: #ff4d00;
color: #ffffff;
border-radius: 6px;
font-weight: 700;
font-size: 14px;
}
.zone-name {
flex: 1;
font-size: 16px;
font-weight: 600;
color: #e6edf3;
}
.zone-score {
font-size: 18px;
font-weight: 700;
color: #ff9f1a;
}
.zone-details {
display: flex;
gap: 16px;
margin-bottom: 8px;
font-size: 13px;
color: #8b949e;
}
.zone-distance,
.zone-direction {
display: flex;
align-items: center;
gap: 4px;
}
.zone-reasoning {
font-size: 13px;
color: #8b949e;
line-height: 1.5;
font-style: italic;
}
/* Ключевые объекты */
.locations-section {
margin-bottom: 24px;
}
.locations-section h3 {
font-size: 18px;
color: #e6edf3;
margin: 0 0 16px 0;
font-weight: 600;
}
.locations-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.location-tag {
padding: 8px 14px;
background: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
font-size: 13px;
color: #e6edf3;
font-weight: 500;
transition: all 0.2s;
}
.location-tag:hover {
background: #21262d;
border-color: #ff4d00;
}
/* Прогноз поведения */
.prediction-section {
background: #161b22;
border: 1px solid #30363d;
border-radius: 8px;
padding: 20px;
}
.prediction-section h3 {
margin: 0 0 12px 0;
font-size: 18px;
color: #e6edf3;
font-weight: 600;
}
.prediction-text {
margin: 0;
font-size: 14px;
color: #8b949e;
line-height: 1.6;
}
/* Адаптивность */
@media (max-width: 768px) {
.result-panel {
padding: 16px;
}
.result-header {
flex-direction: column;
align-items: flex-start;
gap: 12px;
}
.behavioral-cards {
grid-template-columns: 1fr;
}
.urgency-block {
flex-direction: column;
gap: 12px;
}
.zone-header {
flex-wrap: wrap;
}
.locations-tags {
gap: 6px;
}
.location-tag {
font-size: 12px;
padding: 6px 10px;
}
}
@@ -0,0 +1,289 @@
import React from "react";
import "./ResultPanel.css";
const ResultPanel = ({ analysisResult }) => {
if (!analysisResult) {
return null;
}
const {
urgency,
zones = [],
key_locations = [],
immediate_actions = [],
psychotype,
psychotype_modifiers,
psychotype_recommendations,
behavioral_prediction,
summary,
execution_time_ms,
services_used = []
} = analysisResult;
// Цвета срочности
const urgencyConfig = {
"критическая": {
color: "#ff4d00",
label: "КРИТИЧЕСКАЯ",
icon: "🚨"
},
"высокая": {
color: "#ff9f1a",
label: "ВЫСОКАЯ",
icon: "⚠️"
},
"средняя": {
color: "#ffd166",
label: "УМЕРЕННАЯ",
icon: "ℹ️"
},
"низкая": {
color: "#238636",
label: "НИЗКАЯ",
icon: "✓"
}
};
const urgencyInfo = urgencyConfig[urgency] || urgencyConfig["средняя"];
// Behavioral Profile карточки
const getBehavioralCards = () => {
const cards = [];
// РАС карточка (красная)
if (analysisResult.diagnosis_type?.includes("РАС")) {
cards.push({
type: "РАС",
color: "#ff4d00",
icon: "🧩",
title: "Расстройство аутистического спектра",
points: [
"Может не откликаться на имя",
"Избегает зрительного контакта",
"Предпочитает укромные места",
"Не боится опасности"
]
});
}
// Эпилепсия карточка (оранжевая)
if (analysisResult.diagnosis_type?.includes("эпилепсия")) {
cards.push({
type: "Эпилепсия",
color: "#ff9f1a",
icon: "⚡",
title: "Эпилепсия",
points: [
"Риск приступа при стрессе",
"Может быть дезориентирован",
"Требуется медицинская помощь",
"Проверить водоёмы и ямы"
]
});
}
// Велосипед карточка (синяя)
if (analysisResult.has_transport === "bike") {
cards.push({
type: "Велосипед",
color: "#58a6ff",
icon: "🚴",
title: "Есть велосипед",
points: [
"Радиус поиска увеличен в 3-5 раз",
"Проверить дороги и тропы",
"Может преодолеть большое расстояние",
"Искать вдоль маршрутов"
]
});
}
// Самокат карточка (синяя)
if (analysisResult.has_transport === "scooter") {
cards.push({
type: "Самокат",
color: "#58a6ff",
icon: "🛴",
title: "Есть самокат",
points: [
"Радиус поиска увеличен в 2-3 раза",
"Проверить асфальтированные дороги",
"Ограничен рельефом местности",
"Искать вдоль ровных путей"
]
});
}
// Не умеет плавать (красная)
if (analysisResult.cant_swim) {
cards.push({
type: "Не плавает",
color: "#ff4d00",
icon: "🌊",
title: "Не умеет плавать",
points: [
"КРИТИЧНО: проверить водоёмы первыми",
"Высокий риск утопления",
"Привлечь водолазов",
"Обследовать берега и мосты"
]
});
}
return cards;
};
const behavioralCards = getBehavioralCards();
return (
<div className="result-panel">
<div className="result-header">
<h2>Результаты анализа</h2>
<div className="result-meta">
<span className="execution-time">
⏱ {(execution_time_ms / 1000).toFixed(2)}с
</span>
<span className="services-badge">
{services_used.length} сервисов
</span>
</div>
</div>
{/* Блок срочности */}
<div
className="urgency-block"
style={{
borderLeftColor: urgencyInfo.color,
background: urgencyInfo.color + "15"
}}
>
<div className="urgency-icon">{urgencyInfo.icon}</div>
<div className="urgency-content">
<div
className="urgency-label"
style={{ color: urgencyInfo.color }}
>
{urgencyInfo.label}
</div>
<div className="urgency-summary">{summary}</div>
</div>
</div>
{/* Behavioral Profile карточки */}
{behavioralCards.length > 0 && (
<div className="behavioral-section">
<h3>Поведенческий профиль</h3>
<div className="behavioral-cards">
{behavioralCards.map((card, index) => (
<div
key={index}
className="behavioral-card"
style={{ borderLeftColor: card.color }}
>
<div className="card-header">
<span className="card-icon">{card.icon}</span>
<span className="card-title">{card.title}</span>
</div>
<ul className="card-points">
{card.points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</div>
))}
</div>
</div>
)}
{/* Психотип */}
{psychotype && (
<div className="psychotype-section">
<h3>Психотип: {psychotype}</h3>
{psychotype_modifiers && (
<div className="psychotype-description">
{psychotype_modifiers.description}
</div>
)}
{psychotype_recommendations && (
<div className="psychotype-recommendations">
<div className="recommendation-item">
<strong>Приоритетные зоны:</strong> {psychotype_recommendations.priority_zones}
</div>
<div className="recommendation-item">
<strong>Паттерн поиска:</strong> {psychotype_recommendations.search_pattern}
</div>
<div className="recommendation-item">
<strong>Ключевые локации:</strong> {psychotype_recommendations.key_locations}
</div>
<div className="recommendation-item">
<strong>Коммуникация:</strong> {psychotype_recommendations.communication}
</div>
</div>
)}
</div>
)}
{/* Немедленные действия */}
{immediate_actions.length > 0 && (
<div className="actions-section">
<h3>Немедленные действия</h3>
<ol className="actions-list">
{immediate_actions.slice(0, 3).map((action, index) => (
<li key={index} className="action-item">
<span className="action-number">{index + 1}</span>
<span className="action-text">{action}</span>
</li>
))}
</ol>
</div>
)}
{/* Зоны с приоритетом */}
{zones.length > 0 && (
<div className="zones-section">
<h3>Приоритетные зоны поиска</h3>
<div className="zones-list">
{zones.slice(0, 5).map((zone, index) => (
<div key={index} className="zone-item">
<div className="zone-header">
<span className="zone-priority">#{zone.priority}</span>
<span className="zone-name">{zone.name}</span>
<span className="zone-score">{zone.score}%</span>
</div>
<div className="zone-details">
<span className="zone-distance">📍 {zone.distance_km.toFixed(2)} км</span>
<span className="zone-direction">🧭 {zone.direction}</span>
</div>
<div className="zone-reasoning">{zone.reasoning}</div>
</div>
))}
</div>
</div>
)}
{/* Ключевые объекты */}
{key_locations.length > 0 && (
<div className="locations-section">
<h3>Ключевые объекты для проверки</h3>
<div className="locations-tags">
{key_locations.map((location, index) => (
<span key={index} className="location-tag">
{location}
</span>
))}
</div>
</div>
)}
{/* Прогноз поведения */}
{behavioral_prediction && (
<div className="prediction-section">
<h3>Прогноз поведения</h3>
<p className="prediction-text">{behavioral_prediction}</p>
</div>
)}
</div>
);
};
export default ResultPanel;
@@ -0,0 +1 @@
export { default } from "./ResultPanel";
+204
View File
@@ -0,0 +1,204 @@
import React from 'react';
import { MapContainer, TileLayer, Marker, Popup, Polygon } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import L from 'leaflet';
// Fix default marker icon issue with webpack
delete L.Icon.Default.prototype._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon-2x.png',
iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-icon.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
});
const SearchMap = ({ tnpLat, tnpLon, maxDistance, zones }) => {
const tnpPosition = [tnpLat || 53.9, tnpLon || 27.56];
const distance = maxDistance || 5.0;
// Функция для создания точек сектора
const createSectorPoints = (centerLat, centerLon, radiusKm, startAngle, endAngle, points = 20) => {
const coords = [[centerLat, centerLon]]; // Начинаем с центра
// Конвертируем км в градусы (приблизительно)
const latPerKm = 1 / 111.0;
const lonPerKm = 1 / (111.0 * Math.cos(centerLat * Math.PI / 180));
// Создаем точки дуги
for (let i = 0; i <= points; i++) {
const angle = startAngle + (endAngle - startAngle) * (i / points);
const angleRad = angle * Math.PI / 180;
const lat = centerLat + radiusKm * latPerKm * Math.cos(angleRad);
const lon = centerLon + radiusKm * lonPerKm * Math.sin(angleRad);
coords.push([lat, lon]);
}
coords.push([centerLat, centerLon]); // Замыкаем в центре
return coords;
};
// Направления и их углы
const directionAngles = {
'N': 0,
'NE': 45,
'E': 90,
'SE': 135,
'S': 180,
'SW': 225,
'W': 270,
'NW': 315
};
// Создаем секторы на основе зон
const sectors = zones && zones.length > 0 ? zones.slice(0, 8).map((zone, idx) => {
const angle = directionAngles[zone.direction] || 0;
const startAngle = angle - 22.5; // ±22.5° = 45° сектор
const endAngle = angle + 22.5;
// Определяем радиус на основе score
let radius = distance;
if (zone.score >= 90) radius = distance * 1.0;
else if (zone.score >= 70) radius = distance * 0.8;
else if (zone.score >= 50) radius = distance * 0.6;
else radius = distance * 0.4;
// Цвет на основе приоритета
let color, fillOpacity;
if (idx === 0) {
color = '#ff4d00';
fillOpacity = 0.35;
} else if (idx === 1) {
color = '#ff9f1a';
fillOpacity = 0.25;
} else if (idx === 2) {
color = '#ffd166';
fillOpacity = 0.2;
} else {
color = '#8b949e';
fillOpacity = 0.1;
}
return {
points: createSectorPoints(tnpPosition[0], tnpPosition[1], radius, startAngle, endAngle),
color,
fillOpacity,
zone
};
}) : [];
// Если зон нет, создаем базовые концентрические круги
const createCirclePoints = (centerLat, centerLon, radiusKm, points = 40) => {
const coords = [];
const latPerKm = 1 / 111.0;
const lonPerKm = 1 / (111.0 * Math.cos(centerLat * Math.PI / 180));
for (let i = 0; i <= points; i++) {
const angle = (i / points) * 2 * Math.PI;
const lat = centerLat + radiusKm * latPerKm * Math.cos(angle);
const lon = centerLon + radiusKm * lonPerKm * Math.sin(angle);
coords.push([lat, lon]);
}
return coords;
};
const defaultZones = [
{
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance),
color: '#ffd166',
fillOpacity: 0.15,
label: `Зона 3 (${distance.toFixed(1)} км)`,
probability: 10
},
{
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.75),
color: '#ff9f1a',
fillOpacity: 0.2,
label: `Зона 2 (${(distance * 0.75).toFixed(1)} км)`,
probability: 20
},
{
points: createCirclePoints(tnpPosition[0], tnpPosition[1], distance * 0.5),
color: '#ff4d00',
fillOpacity: 0.3,
label: `Зона 1 (${(distance * 0.5).toFixed(1)} км)`,
probability: 70
}
];
return (
<MapContainer
center={tnpPosition}
zoom={12}
style={{ width: '100%', height: '500px', borderRadius: '8px' }}
>
<TileLayer
attribution=""
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Рисуем секторы или круги */}
{sectors.length > 0 ? (
sectors.map((sector, idx) => (
<Polygon
key={idx}
positions={sector.points}
pathOptions={{
color: sector.color,
fillColor: sector.color,
fillOpacity: sector.fillOpacity,
weight: 2
}}
>
<Popup>
<strong>#{idx + 1} {sector.zone.name}</strong><br/>
Направление: {sector.zone.direction}<br/>
Приоритет: {sector.zone.score}%<br/>
{sector.zone.reasoning}
</Popup>
</Polygon>
))
) : (
defaultZones.map((zone, idx) => (
<Polygon
key={idx}
positions={zone.points}
pathOptions={{
color: zone.color,
fillColor: zone.color,
fillOpacity: zone.fillOpacity,
weight: 2
}}
>
<Popup>
<strong>{zone.label}</strong><br/>
Вероятность: {zone.probability}%
</Popup>
</Polygon>
))
)}
{/* ТНП - красный маркер */}
<Marker
position={tnpPosition}
icon={L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.7.1/images/marker-shadow.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowSize: [41, 41]
})}
>
<Popup>
<strong>ТНП - Точка начала поиска</strong><br/>
Координаты: {tnpLat?.toFixed(6)}, {tnpLon?.toFixed(6)}<br/>
Максимальная дистанция: {distance.toFixed(1)} км
</Popup>
</Marker>
</MapContainer>
);
};
export default SearchMap;
@@ -0,0 +1,180 @@
.case-detail {
padding: 32px;
max-width: 1200px;
margin: 0 auto;
}
.detail-header {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 32px;
}
.btn-back {
padding: 8px 16px;
background: #e2e8f0;
color: #475569;
border: none;
border-radius: 6px;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.btn-back:hover {
background: #cbd5e1;
}
.detail-header h1 {
margin: 0;
font-size: 28px;
color: #1e293b;
}
.detail-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.detail-section {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.detail-section h2 {
margin: 0 0 20px 0;
font-size: 20px;
color: #1e293b;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-item label {
font-size: 13px;
color: #64748b;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.info-item span {
font-size: 16px;
color: #1e293b;
font-weight: 500;
}
.diagnosis-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.diagnosis-badge {
padding: 8px 16px;
background: #fef3c7;
color: #92400e;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
}
.psychotype-section {
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
border-left: 4px solid #3b82f6;
}
.psychotype-info {
display: flex;
flex-direction: column;
gap: 16px;
}
.psychotype-description {
font-size: 16px;
color: #1e293b;
margin: 0;
}
.search-tips {
padding: 16px;
background: white;
border-radius: 6px;
border-left: 3px solid #10b981;
}
.search-tips h3 {
margin: 0 0 8px 0;
font-size: 15px;
color: #059669;
font-weight: 600;
}
.search-tips p {
margin: 0;
font-size: 14px;
color: #334155;
line-height: 1.6;
}
.answers-list {
display: flex;
flex-direction: column;
gap: 16px;
}
.answer-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f8fafc;
border-radius: 6px;
}
.answer-item label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.answer-item span {
font-size: 14px;
color: #1e293b;
font-weight: 600;
}
/* Responsive */
@media (max-width: 768px) {
.case-detail {
padding: 16px;
}
.detail-header {
flex-direction: column;
align-items: flex-start;
}
.info-grid {
grid-template-columns: 1fr;
}
.answer-item {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
}
@@ -0,0 +1,162 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import axios from 'axios';
import './CaseDetail.css';
const CaseDetail = () => {
const { id } = useParams();
const navigate = useNavigate();
const [caseData, setCase] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchCase();
}, [id]);
const fetchCase = async () => {
setLoading(true);
try {
const response = await axios.get(`/api/v1/cases/${id}`);
setCase(response.data);
setError(null);
} catch (err) {
setError('Ошибка загрузки карточки');
console.error(err);
} finally {
setLoading(false);
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Не указано';
return new Date(dateString).toLocaleString('ru-RU');
};
const getPsychotypeDescription = (psychotype) => {
const descriptions = {
'explorer': {
title: 'Исследователь',
description: 'Активно исследует территорию, может уйти далеко от точки потери',
searchTips: 'Расширенный радиус поиска, проверка интересных объектов'
},
'cautious': {
title: 'Осторожный',
description: 'Осторожен, держится знакомых мест',
searchTips: 'Поиск вблизи точки потери, знакомые маршруты'
},
'passive': {
title: 'Пассивный',
description: 'Ждёт помощи, малоподвижен',
searchTips: 'Тщательный осмотр укрытий, проверка мест для отдыха'
},
'panicker': {
title: 'Паникёр',
description: 'Паникует, может двигаться хаотично',
searchTips: 'Проверка опасных мест, водоёмов, обрывов'
}
};
return descriptions[psychotype] || { title: psychotype, description: '', searchTips: '' };
};
if (loading) return <div className="loading-state">Загрузка...</div>;
if (error) return <div className="error-state">{error}</div>;
if (!caseData) return <div className="error-state">Карточка не найдена</div>;
const psychotypeInfo = caseData.psychotype_result
? getPsychotypeDescription(caseData.psychotype_result)
: null;
return (
<div className="case-detail">
<div className="detail-header">
<button className="btn-back" onClick={() => navigate('/admin/cases')}>
← Назад к списку
</button>
<h1>Карточка #{caseData.id}</h1>
</div>
<div className="detail-content">
{/* Основная информация */}
<section className="detail-section">
<h2>Основная информация</h2>
<div className="info-grid">
<div className="info-item">
<label>Возраст:</label>
<span>{caseData.age || '—'} лет</span>
</div>
<div className="info-item">
<label>Пол:</label>
<span>{caseData.gender === 'male' ? 'Мужской' : caseData.gender === 'female' ? 'Женский' : '—'}</span>
</div>
<div className="info-item">
<label>Дата создания:</label>
<span>{formatDate(caseData.created_at)}</span>
</div>
</div>
</section>
{/* Диагнозы */}
{caseData.diagnosis_type && caseData.diagnosis_type.length > 0 && (
<section className="detail-section">
<h2>Диагнозы</h2>
<div className="diagnosis-list">
{caseData.diagnosis_type.map((diagnosis, idx) => (
<span key={idx} className="diagnosis-badge">{diagnosis}</span>
))}
</div>
</section>
)}
{/* Психотип */}
{psychotypeInfo && (
<section className="detail-section psychotype-section">
<h2>Психотип: {psychotypeInfo.title}</h2>
<div className="psychotype-info">
<p className="psychotype-description">{psychotypeInfo.description}</p>
<div className="search-tips">
<h3>Рекомендации по поиску:</h3>
<p>{psychotypeInfo.searchTips}</p>
</div>
</div>
</section>
)}
{/* Ответы на вопросы психотипа */}
{caseData.psychotype_answers && Object.keys(caseData.psychotype_answers).length > 0 && (
<section className="detail-section">
<h2>Ответы на вопросы</h2>
<div className="answers-list">
{caseData.psychotype_answers.unfamiliar_env && (
<div className="answer-item">
<label>Поведение в незнакомой обстановке:</label>
<span>{caseData.psychotype_answers.unfamiliar_env}</span>
</div>
)}
{caseData.psychotype_answers.stress_reaction && (
<div className="answer-item">
<label>Реакция на стресс:</label>
<span>{caseData.psychotype_answers.stress_reaction}</span>
</div>
)}
{caseData.psychotype_answers.group_role && (
<div className="answer-item">
<label>Роль в группе:</label>
<span>{caseData.psychotype_answers.group_role}</span>
</div>
)}
{caseData.psychotype_answers.risk_taking && (
<div className="answer-item">
<label>Склонность к риску:</label>
<span>{caseData.psychotype_answers.risk_taking}</span>
</div>
)}
</div>
</section>
)}
</div>
</div>
);
};
export default CaseDetail;
+203
View File
@@ -0,0 +1,203 @@
.cases-list {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.cases-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.cases-header h1 {
margin: 0;
font-size: 28px;
color: #1e293b;
}
.header-actions {
display: flex;
gap: 12px;
}
.btn-primary {
padding: 10px 20px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: #2563eb;
}
.btn-secondary {
padding: 10px 20px;
background: #e2e8f0;
color: #475569;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover {
background: #cbd5e1;
}
.filters-panel {
display: flex;
flex-wrap: wrap;
gap: 16px;
padding: 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.filter-group {
display: flex;
align-items: center;
gap: 8px;
}
.filter-group label {
font-size: 14px;
color: #64748b;
font-weight: 500;
}
.filter-group select,
.filter-group input {
padding: 8px 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
font-size: 14px;
min-width: 120px;
}
.filter-group input[type=number] {
min-width: 80px;
}
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.cases-table {
width: 100%;
border-collapse: collapse;
}
.cases-table thead {
background: #f8fafc;
border-bottom: 2px solid #e2e8f0;
}
.cases-table th {
padding: 12px 16px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #475569;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.cases-table td {
padding: 16px;
border-bottom: 1px solid #f1f5f9;
font-size: 14px;
color: #334155;
}
.cases-table tbody tr:hover {
background: #f8fafc;
}
.psychotype-badge {
display: inline-block;
padding: 4px 12px;
background: #dbeafe;
color: #1e40af;
border-radius: 12px;
font-size: 13px;
font-weight: 500;
}
.btn-link {
color: #3b82f6;
text-decoration: none;
font-weight: 500;
transition: color 0.2s;
}
.btn-link:hover {
color: #2563eb;
text-decoration: underline;
}
.loading-state,
.error-state,
.empty-state {
padding: 60px 20px;
text-align: center;
color: #64748b;
font-size: 16px;
}
.error-state {
color: #dc2626;
}
.cases-footer {
margin-top: 20px;
padding: 16px 20px;
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cases-footer p {
margin: 0;
font-size: 14px;
color: #64748b;
}
/* Responsive */
@media (max-width: 768px) {
.cases-list {
padding: 16px;
}
.cases-header {
flex-direction: column;
align-items: flex-start;
gap: 16px;
}
.filters-panel {
flex-direction: column;
}
.table-container {
overflow-x: auto;
}
.cases-table {
min-width: 800px;
}
}
+175
View File
@@ -0,0 +1,175 @@
import React, { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import axios from 'axios';
import './CasesList.css';
const CasesList = () => {
const [cases, setCases] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [filters, setFilters] = useState({
gender: '',
ageFrom: '',
ageTo: '',
diagnosis: '',
psychotype: ''
});
useEffect(() => {
fetchCases();
}, [filters]);
const fetchCases = async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (filters.gender) params.append('gender', filters.gender);
if (filters.ageFrom) params.append('age_from', filters.ageFrom);
if (filters.ageTo) params.append('age_to', filters.ageTo);
if (filters.diagnosis) params.append('diagnosis', filters.diagnosis);
if (filters.psychotype) params.append('psychotype', filters.psychotype);
const response = await axios.get(`/api/v1/cases?${params}`);
setCases(response.data);
setError(null);
} catch (err) {
setError('Ошибка загрузки данных');
console.error(err);
} finally {
setLoading(false);
}
};
const formatDate = (dateString) => {
if (!dateString) return 'Не указано';
return new Date(dateString).toLocaleDateString('ru-RU');
};
const getPsychotypeLabel = (psychotype) => {
const labels = {
'explorer': 'Исследователь',
'cautious': 'Осторожный',
'passive': 'Пассивный',
'panicker': 'Паникёр'
};
return labels[psychotype] || psychotype;
};
return (
<div className="cases-list">
<div className="cases-header">
<h1>Карточки пропавших</h1>
<div className="header-actions">
<button className="btn-primary" onClick={fetchCases}>
🔄 Обновить
</button>
</div>
</div>
{/* Фильтры */}
<div className="filters-panel">
<div className="filter-group">
<label>Пол:</label>
<select value={filters.gender} onChange={(e) => setFilters({...filters, gender: e.target.value})}>
<option value="">Все</option>
<option value="male">Мужской</option>
<option value="female">Женский</option>
</select>
</div>
<div className="filter-group">
<label>Возраст от:</label>
<input
type="number"
value={filters.ageFrom}
onChange={(e) => setFilters({...filters, ageFrom: e.target.value})}
placeholder="0"
/>
</div>
<div className="filter-group">
<label>до:</label>
<input
type="number"
value={filters.ageTo}
onChange={(e) => setFilters({...filters, ageTo: e.target.value})}
placeholder="100"
/>
</div>
<div className="filter-group">
<label>Диагноз:</label>
<select value={filters.diagnosis} onChange={(e) => setFilters({...filters, diagnosis: e.target.value})}>
<option value="">Все</option>
<option value="РАС">РАС</option>
<option value="эпилепсия">Эпилепсия</option>
<option value="СДВГ">СДВГ</option>
<option value="деменция">Деменция</option>
</select>
</div>
<button className="btn-secondary" onClick={() => setFilters({
gender: '', ageFrom: '', ageTo: '', diagnosis: '', psychotype: ''
})}>
Сбросить фильтры
</button>
</div>
{/* Таблица */}
{loading ? (
<div className="loading-state">Загрузка...</div>
) : error ? (
<div className="error-state">{error}</div>
) : cases.length === 0 ? (
<div className="empty-state">Карточки не найдены</div>
) : (
<div className="table-container">
<table className="cases-table">
<thead>
<tr>
<th>ID</th>
<th>Возраст</th>
<th>Пол</th>
<th>Диагнозы</th>
<th>Психотип</th>
<th>Дата создания</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{cases.map(caseItem => (
<tr key={caseItem.id}>
<td>{caseItem.id}</td>
<td>{caseItem.age || '—'}</td>
<td>{caseItem.gender === 'male' ? 'М' : caseItem.gender === 'female' ? 'Ж' : '—'}</td>
<td>
{caseItem.diagnosis_type && caseItem.diagnosis_type.length > 0
? caseItem.diagnosis_type.join(', ')
: '—'}
</td>
<td>
{caseItem.psychotype_result
? <span className="psychotype-badge">{getPsychotypeLabel(caseItem.psychotype_result)}</span>
: '—'}
</td>
<td>{formatDate(caseItem.created_at)}</td>
<td>
<Link to={`/admin/cases/${caseItem.id}`} className="btn-link">
Подробнее
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<div className="cases-footer">
<p>Всего карточек: <strong>{cases.length}</strong></p>
</div>
</div>
);
};
export default CasesList;
@@ -0,0 +1,157 @@
.heatmap-view {
display: flex;
height: 100vh;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
}
.heatmap-controls {
width: 320px;
padding: 20px;
background: #f5f5f5;
border-right: 1px solid #ddd;
overflow-y: auto;
}
.heatmap-controls h2 {
margin: 0 0 20px 0;
font-size: 1.5rem;
color: #333;
}
.control-group {
margin-bottom: 20px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 500;
color: #555;
}
.button-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.button-group button {
padding: 8px 16px;
border: 1px solid #ccc;
background: white;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.2s;
}
.button-group button:hover {
background: #e9e9e9;
}
.button-group button.active {
background: #007bff;
color: white;
border-color: #007bff;
}
.control-group select {
width: 100%;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
background: white;
}
.year-range {
display: flex;
align-items: center;
gap: 8px;
}
.year-range input {
flex: 1;
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 0.9rem;
}
.year-range span {
color: #666;
}
.control-group input[type="checkbox"] {
margin-right: 8px;
}
.stats-info {
padding: 15px;
background: white;
border-radius: 4px;
margin-bottom: 20px;
}
.stats-info p {
margin: 5px 0;
color: #555;
}
.stats-info strong {
color: #007bff;
}
.loading {
color: #007bff;
font-style: italic;
}
.error {
color: #dc3545;
font-weight: 500;
}
.legend {
padding: 15px;
background: white;
border-radius: 4px;
}
.legend h3 {
margin: 0 0 10px 0;
font-size: 1rem;
color: #333;
}
.legend-gradient {
display: flex;
flex-direction: column;
gap: 8px;
}
.gradient-bar {
height: 20px;
background: linear-gradient(to right, blue, lime, yellow, red);
border-radius: 4px;
}
.legend-label {
font-size: 0.85rem;
color: #666;
}
.map-container {
flex: 1;
position: relative;
}
.map {
width: 100%;
height: 100%;
}
/* Leaflet overrides */
.leaflet-container {
font-family: inherit;
}
@@ -0,0 +1,252 @@
import React, { useState, useEffect, useRef } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import 'leaflet.heat';
import './HeatmapView.css';
const HeatmapView = () => {
const mapRef = useRef(null);
const mapInstanceRef = useRef(null);
const heatLayerRef = useRef(null);
const [heatmapData, setHeatmapData] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const [total, setTotal] = useState(0);
// Filters
const [filterType, setFilterType] = useState('all');
const [ageGroup, setAgeGroup] = useState('');
const [season, setSeason] = useState('');
const [yearFrom, setYearFrom] = useState('');
const [yearTo, setYearTo] = useState('');
// Layer visibility
const [showHeatmap, setShowHeatmap] = useState(true);
// Initialize map
useEffect(() => {
if (!mapRef.current || mapInstanceRef.current) return;
// Create map centered on Russia
const map = L.map(mapRef.current).setView([55.7558, 37.6173], 5);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(map);
mapInstanceRef.current = map;
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, []);
// Fetch heatmap data
const fetchHeatmapData = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
type: filterType
});
if (filterType === 'age' && ageGroup) {
params.append('age_group', ageGroup);
}
if (filterType === 'season' && season) {
params.append('season', season);
}
if (yearFrom) {
params.append('year_from', yearFrom);
}
if (yearTo) {
params.append('year_to', yearTo);
}
const response = await fetch(`http://192.168.0.99:8000/api/v1/stats/heatmap?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch heatmap data');
}
const data = await response.json();
setHeatmapData(data.points);
setTotal(data.total);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Update heatmap layer
useEffect(() => {
if (!mapInstanceRef.current) return;
// Remove existing heat layer
if (heatLayerRef.current) {
mapInstanceRef.current.removeLayer(heatLayerRef.current);
heatLayerRef.current = null;
}
// Add new heat layer if visible and data exists
if (showHeatmap && heatmapData.length > 0) {
const heatData = heatmapData.map(point => [point.lat, point.lon, point.intensity]);
const heatLayer = L.heatLayer(heatData, {
radius: 25,
blur: 15,
maxZoom: 10,
max: 1.0,
gradient: {
0.0: 'blue',
0.5: 'lime',
0.7: 'yellow',
1.0: 'red'
}
});
heatLayer.addTo(mapInstanceRef.current);
heatLayerRef.current = heatLayer;
}
}, [heatmapData, showHeatmap]);
// Load data on mount and filter change
useEffect(() => {
fetchHeatmapData();
}, [filterType, ageGroup, season, yearFrom, yearTo]);
const handleFilterTypeChange = (type) => {
setFilterType(type);
// Reset specific filters
if (type !== 'age') setAgeGroup('');
if (type !== 'season') setSeason('');
};
return (
<div className="heatmap-view">
<div className="heatmap-controls">
<h2>Тепловая карта находок</h2>
<div className="control-group">
<label>Тип фильтра:</label>
<div className="button-group">
<button
className={filterType === 'all' ? 'active' : ''}
onClick={() => handleFilterTypeChange('all')}
>
Все
</button>
<button
className={filterType === 'age' ? 'active' : ''}
onClick={() => handleFilterTypeChange('age')}
>
По возрасту
</button>
<button
className={filterType === 'season' ? 'active' : ''}
onClick={() => handleFilterTypeChange('season')}
>
По сезону
</button>
<button
className={filterType === 'outcome' ? 'active' : ''}
onClick={() => handleFilterTypeChange('outcome')}
>
По исходу
</button>
</div>
</div>
{filterType === 'age' && (
<div className="control-group">
<label>Возрастная группа:</label>
<select value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)}>
<option value="">Все</option>
<option value="0-6">0-6 лет</option>
<option value="7-10">7-10 лет</option>
<option value="11-14">11-14 лет</option>
<option value="15-17">15-17 лет</option>
<option value="18+">18+ лет</option>
</select>
</div>
)}
{filterType === 'season' && (
<div className="control-group">
<label>Сезон:</label>
<select value={season} onChange={(e) => setSeason(e.target.value)}>
<option value="">Все</option>
<option value="зима">Зима</option>
<option value="весна">Весна</option>
<option value="лето">Лето</option>
<option value="осень">Осень</option>
</select>
</div>
)}
<div className="control-group">
<label>Период:</label>
<div className="year-range">
<input
type="number"
placeholder="От года"
value={yearFrom}
onChange={(e) => setYearFrom(e.target.value)}
min="2000"
max="2100"
/>
<span>—</span>
<input
type="number"
placeholder="До года"
value={yearTo}
onChange={(e) => setYearTo(e.target.value)}
min="2000"
max="2100"
/>
</div>
</div>
<div className="control-group">
<label>
<input
type="checkbox"
checked={showHeatmap}
onChange={(e) => setShowHeatmap(e.target.checked)}
/>
Показать тепловую карту
</label>
</div>
<div className="stats-info">
<p>Всего точек: <strong>{total}</strong></p>
{loading && <p className="loading">Загрузка...</p>}
{error && <p className="error">Ошибка: {error}</p>}
</div>
<div className="legend">
<h3>Легенда</h3>
<div className="legend-gradient">
<span className="legend-label">Низкая плотность</span>
<div className="gradient-bar"></div>
<span className="legend-label">Высокая плотность</span>
</div>
</div>
</div>
<div className="map-container">
<div ref={mapRef} className="map"></div>
</div>
</div>
);
};
export default HeatmapView;
@@ -0,0 +1,98 @@
.statistics {
padding: 32px;
max-width: 1400px;
margin: 0 auto;
}
.stats-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 32px;
}
.stats-header h1 {
margin: 0;
font-size: 28px;
color: #1e293b;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.stat-card {
background: white;
padding: 24px;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.stat-card.wide {
grid-column: span 2;
}
.stat-card h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: #64748b;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 48px;
font-weight: 700;
color: #3b82f6;
line-height: 1;
}
.stat-breakdown {
display: flex;
flex-direction: column;
gap: 12px;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: #f8fafc;
border-radius: 6px;
}
.stat-item span {
font-size: 14px;
color: #64748b;
}
.stat-item strong {
font-size: 18px;
color: #1e293b;
font-weight: 600;
}
/* Responsive */
@media (max-width: 768px) {
.statistics {
padding: 16px;
}
.stats-grid {
grid-template-columns: 1fr;
}
.stat-card.wide {
grid-column: span 1;
}
}
@@ -0,0 +1,106 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './Statistics.css';
const Statistics = () => {
const [stats, setStats] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchStatistics();
}, []);
const fetchStatistics = async () => {
setLoading(true);
try {
const response = await axios.get('/api/v1/stats/summary');
setStats(response.data);
setError(null);
} catch (err) {
setError('Ошибка загрузки статистики');
console.error(err);
} finally {
setLoading(false);
}
};
if (loading) return <div className="loading-state">Загрузка...</div>;
if (error) return <div className="error-state">{error}</div>;
if (!stats) return <div className="error-state">Нет данных</div>;
return (
<div className="statistics">
<div className="stats-header">
<h1>Статистика</h1>
<button className="btn-primary" onClick={fetchStatistics}>
🔄 Обновить
</button>
</div>
<div className="stats-grid">
{/* Общая статистика */}
<div className="stat-card">
<h3>Всего карточек</h3>
<div className="stat-value">{stats.total_cases || 0}</div>
</div>
{/* По полу */}
<div className="stat-card">
<h3>По полу</h3>
<div className="stat-breakdown">
<div className="stat-item">
<span>Мужской:</span>
<strong>{stats.by_gender?.male || 0}</strong>
</div>
<div className="stat-item">
<span>Женский:</span>
<strong>{stats.by_gender?.female || 0}</strong>
</div>
</div>
</div>
{/* По возрасту */}
<div className="stat-card">
<h3>По возрасту</h3>
<div className="stat-breakdown">
{stats.by_age && Object.entries(stats.by_age).map(([range, count]) => (
<div key={range} className="stat-item">
<span>{range}:</span>
<strong>{count}</strong>
</div>
))}
</div>
</div>
{/* По психотипам */}
<div className="stat-card">
<h3>По психотипам</h3>
<div className="stat-breakdown">
{stats.by_psychotype && Object.entries(stats.by_psychotype).map(([type, count]) => (
<div key={type} className="stat-item">
<span>{type}:</span>
<strong>{count}</strong>
</div>
))}
</div>
</div>
{/* По диагнозам */}
<div className="stat-card wide">
<h3>По диагнозам</h3>
<div className="stat-breakdown">
{stats.by_diagnosis && Object.entries(stats.by_diagnosis).map(([diagnosis, count]) => (
<div key={diagnosis} className="stat-item">
<span>{diagnosis}:</span>
<strong>{count}</strong>
</div>
))}
</div>
</div>
</div>
</div>
);
};
export default Statistics;
+184
View File
@@ -0,0 +1,184 @@
@import 'leaflet/dist/leaflet.css';
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/*
Цветовые палитры ВЕКТОР согласно §5 контекста
Три интерфейса: ЦОУ Десктоп, Мобильный, Админка
*/
/* ЦОУ Десктоп — аналитика, работа часами */
:root[data-theme="desktop"],
:root {
--bg-primary: #0a0c0f;
--bg-secondary: #12161b;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent: #ff4d00;
--accent-hover: #ff7a33;
--zone-1: #ff4d00;
--zone-2: #ff9f1a;
--zone-3: #ffd166;
--success: #2ea043;
--warning: #d29922;
--danger: #f85149;
--border: #30363d;
--input-bg: #161b22;
--input-border: #30363d;
}
/* Мобильный — быстрое действие под стрессом */
:root[data-theme="mobile"] {
--bg-primary: #000000;
--bg-secondary: #0d1117;
--text-primary: #ffffff;
--text-secondary: #b1bac4;
--accent: #ff3b30;
--accent-hover: #ff6b60;
--btn-confirm: #34c759;
--btn-gps: #0a84ff;
--zone-1: #ff3b30;
--zone-2: #ff9500;
--zone-3: #ffcc00;
--success: #34c759;
--warning: #ff9500;
--danger: #ff3b30;
--border: #1c1c1e;
--input-bg: #1c1c1e;
--input-border: #38383a;
/* Мобильные кнопки минимум 52px высота */
--btn-min-height: 52px;
}
/* Админка — спокойная верификация */
:root[data-theme="admin"] {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--accent: #2f81f7;
--accent-hover: #58a6ff;
--chart-distance: #2f81f7;
--chart-terrain: #3fb950;
--chart-outcome: #f85149;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
--border: #30363d;
--input-bg: #161b22;
--input-border: #30363d;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-primary);
color: var(--text-primary);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}
/* Базовые стили для всех интерфейсов */
button {
font-family: inherit;
cursor: pointer;
border: none;
transition: all 0.2s ease;
}
input, textarea, select {
font-family: inherit;
background-color: var(--input-bg);
color: var(--text-primary);
border: 1px solid var(--input-border);
border-radius: 6px;
padding: 8px 12px;
transition: border-color 0.2s ease;
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--accent);
}
/* Мобильные кнопки */
[data-theme="mobile"] button {
min-height: var(--btn-min-height);
font-size: 17px;
font-weight: 600;
}
/* Кнопки подтверждения */
.btn-primary {
background-color: var(--accent);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
}
.btn-primary:hover {
background-color: var(--accent-hover);
}
.btn-success {
background-color: var(--success);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
}
.btn-danger {
background-color: var(--danger);
color: white;
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
}
/* Мобильная кнопка GPS */
[data-theme="mobile"] .btn-gps {
background-color: var(--btn-gps);
color: white;
}
/* Мобильная кнопка подтверждения */
[data-theme="mobile"] .btn-confirm {
background-color: var(--btn-confirm);
color: white;
}
/* Карточки */
.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
}
/* Зоны поиска */
.zone-priority-1 {
color: var(--zone-1);
border-color: var(--zone-1);
}
.zone-priority-2 {
color: var(--zone-2);
border-color: var(--zone-2);
}
.zone-priority-3 {
color: var(--zone-3);
border-color: var(--zone-3);
}
+11
View File
@@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+332
View File
@@ -0,0 +1,332 @@
/* Админка — палитра из §5 контекста */
.admin-dashboard {
min-height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
}
.admin-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.admin-header h1 {
margin: 0;
font-size: 24px;
color: var(--text-primary);
font-weight: 700;
}
.admin-nav {
display: flex;
gap: 16px;
}
.admin-nav button {
padding: 8px 16px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.admin-nav button:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--accent);
}
.admin-nav button.active {
background: var(--accent);
color: white;
border-color: var(--accent);
}
.admin-content {
padding: 24px;
max-width: 1400px;
margin: 0 auto;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-bottom: 32px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.stat-card h3 {
margin: 0 0 8px 0;
font-size: 14px;
color: var(--text-secondary);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 32px;
font-weight: 700;
color: var(--text-primary);
margin: 0;
}
.stat-change {
font-size: 14px;
margin-top: 8px;
}
.stat-change.positive {
color: var(--success);
}
.stat-change.negative {
color: var(--danger);
}
.cases-table {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
justify-content: space-between;
align-items: center;
}
.table-header h2 {
margin: 0;
font-size: 18px;
color: var(--text-primary);
font-weight: 600;
}
.table-actions {
display: flex;
gap: 12px;
}
.btn-primary {
padding: 8px 16px;
background: var(--accent);
color: white;
border: none;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
padding: 8px 16px;
background: transparent;
color: var(--text-secondary);
border: 1px solid var(--border);
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: var(--bg-primary);
color: var(--text-primary);
border-color: var(--accent);
}
table {
width: 100%;
border-collapse: collapse;
}
thead {
background: var(--bg-primary);
}
thead th {
padding: 12px 20px;
text-align: left;
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
tbody tr {
border-bottom: 1px solid var(--border);
transition: background 0.2s;
}
tbody tr:hover {
background: var(--bg-primary);
}
tbody td {
padding: 16px 20px;
font-size: 14px;
color: var(--text-primary);
}
.status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
}
.status-badge.active {
background: var(--warning);
color: #000;
}
.status-badge.closed {
background: var(--success);
color: white;
}
.status-badge.archived {
background: var(--text-secondary);
color: white;
}
/* Графики */
.chart-container {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
margin-bottom: 24px;
}
.chart-container h3 {
margin: 0 0 16px 0;
font-size: 16px;
color: var(--text-primary);
font-weight: 600;
}
/* Цвета для графиков из палитры админки */
.chart-distance {
color: var(--chart-distance);
}
.chart-terrain {
color: var(--chart-terrain);
}
.chart-outcome {
color: var(--chart-outcome);
}
/* Форма загрузки документов */
.upload-zone {
background: var(--bg-secondary);
border: 2px dashed var(--border);
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
}
.upload-zone:hover {
border-color: var(--accent);
background: var(--bg-primary);
}
.upload-zone.dragging {
border-color: var(--accent);
background: var(--bg-primary);
}
.upload-icon {
font-size: 48px;
color: var(--text-secondary);
margin-bottom: 16px;
}
.upload-text {
font-size: 16px;
color: var(--text-primary);
margin-bottom: 8px;
}
.upload-hint {
font-size: 14px;
color: var(--text-secondary);
}
/* Responsive */
@media (max-width: 768px) {
.admin-header {
flex-direction: column;
gap: 16px;
align-items: flex-start;
}
.admin-nav {
width: 100%;
flex-direction: column;
}
.admin-nav button {
width: 100%;
}
.stats-grid {
grid-template-columns: 1fr;
}
.table-header {
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
.table-actions {
width: 100%;
flex-direction: column;
}
.table-actions button {
width: 100%;
}
table {
font-size: 12px;
}
thead th,
tbody td {
padding: 8px 12px;
}
}
+65
View File
@@ -0,0 +1,65 @@
import React from 'react';
import { Routes, Route, Link, useLocation } from 'react-router-dom';
import CasesList from '../components/admin/CasesList';
import CaseDetail from '../components/admin/CaseDetail';
import HeatmapView from '../components/admin/HeatmapView';
import Statistics from '../components/admin/Statistics';
import './AdminDashboard.css';
const AdminDashboard = () => {
const location = useLocation();
return (
<div className="admin-dashboard">
<aside className="sidebar">
<div className="sidebar-header">
<h1>SAR МЧС</h1>
<p className="subtitle">Система анализа поисков</p>
</div>
<nav className="sidebar-nav">
<Link
to="/admin/cases"
className={location.pathname.startsWith('/admin/cases') ? 'active' : ''}
>
<span className="icon">📋</span>
Карточки
</Link>
<Link
to="/admin/heatmap"
className={location.pathname === '/admin/heatmap' ? 'active' : ''}
>
<span className="icon">🗺️</span>
Тепловая карта
</Link>
<Link
to="/admin/statistics"
className={location.pathname === '/admin/statistics' ? 'active' : ''}
>
<span className="icon">📊</span>
Статистика
</Link>
</nav>
<div className="sidebar-footer">
<a href="/mobile" target="_blank" rel="noopener noreferrer" className="mobile-link">
<span className="icon">📱</span>
Мобильная форма
</a>
</div>
</aside>
<main className="main-content">
<Routes>
<Route path="/" element={<CasesList />} />
<Route path="/cases" element={<CasesList />} />
<Route path="/cases/:id" element={<CaseDetail />} />
<Route path="/heatmap" element={<HeatmapView />} />
<Route path="/statistics" element={<Statistics />} />
</Routes>
</main>
</div>
);
};
export default AdminDashboard;
+58
View File
@@ -0,0 +1,58 @@
.desktop-sar {
min-height: 100vh;
background: var(--bg-primary);
}
.sar-header {
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
padding: 16px 24px;
display: flex;
justify-content: space-between;
align-items: center;
}
.sar-header h1 {
margin: 0;
font-size: 24px;
color: var(--text-primary);
font-weight: 700;
}
.sar-header .logo {
color: var(--accent);
font-weight: 800;
}
.sar-status {
display: flex;
gap: 16px;
align-items: center;
}
.status-indicator {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--success);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.desktop-content {
max-width: 1200px;
margin: 0 auto;
padding: 24px;
}
+24
View File
@@ -0,0 +1,24 @@
import React from "react";
import CaseForm from "../components/CaseForm";
import "../pages/DesktopSAR.css";
const DesktopSARPage = () => {
return (
<div className="desktop-sar">
<div className="sar-header">
<h1><span className="logo">ВЕКТОР</span> ЦОУ</h1>
<div className="sar-status">
<div className="status-indicator">
<span className="status-dot"></span>
Система активна
</div>
</div>
</div>
<div className="desktop-content">
<CaseForm isMobile={false} />
</div>
</div>
);
};
export default DesktopSARPage;
+12
View File
@@ -0,0 +1,12 @@
import React from "react";
import CaseForm from "../components/CaseForm";
const MobileFormPage = () => {
return (
<div className="mobile-page">
<CaseForm isMobile={true} />
</div>
);
};
export default MobileFormPage;
@@ -0,0 +1,398 @@
.analysis-result {
min-height: 100vh;
background: var(--bg-primary);
color: var(--text-primary);
}
/* Header */
.result-header {
background: linear-gradient(135deg, rgba(255, 77, 0, 0.1) 0%, rgba(255, 77, 0, 0.05) 100%);
border-bottom: 2px solid rgba(255, 77, 0, 0.3);
padding: 32px 24px;
margin-bottom: 32px;
}
.result-header h1 {
font-size: 36px;
font-weight: 700;
color: var(--text-primary);
margin: 0 0 16px 0;
display: flex;
align-items: center;
gap: 12px;
}
.logo {
color: var(--accent);
font-weight: 800;
}
.case-info {
display: flex;
flex-wrap: wrap;
gap: 16px;
font-size: 16px;
color: var(--text-secondary);
align-items: center;
}
.separator {
color: rgba(255, 77, 0, 0.5);
font-weight: 700;
}
/* Content */
.result-content {
max-width: 1400px;
margin: 0 auto;
padding: 0 24px 48px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* Urgency Block */
.urgency-block {
padding: 24px;
border-radius: 12px;
border: 2px solid;
display: flex;
flex-direction: column;
gap: 12px;
}
.urgency-block.urgency-высокий {
background: rgba(255, 77, 0, 0.15);
border-color: #ff4d00;
}
.urgency-block.urgency-средний {
background: rgba(255, 159, 26, 0.15);
border-color: #ff9f1a;
}
.urgency-block.urgency-стандартный {
background: rgba(255, 209, 102, 0.15);
border-color: #ffd166;
}
.urgency-level {
font-size: 24px;
font-weight: 700;
letter-spacing: 0.5px;
}
.urgency-block.urgency-высокий .urgency-level {
color: #ff4d00;
}
.urgency-block.urgency-средний .urgency-level {
color: #ff9f1a;
}
.urgency-block.urgency-стандартный .urgency-level {
color: #ffd166;
}
.urgency-reason {
font-size: 16px;
line-height: 1.6;
color: var(--text-secondary);
}
/* Info Cards */
.info-card {
background: var(--bg-secondary);
border: 1px solid rgba(255, 77, 0, 0.2);
border-radius: 12px;
padding: 24px;
}
.info-card h3 {
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px 0;
display: flex;
align-items: center;
gap: 8px;
}
.info-card p {
margin: 12px 0;
line-height: 1.8;
color: var(--text-secondary);
}
.info-card p strong {
color: var(--text-primary);
font-weight: 600;
}
.info-card hr {
margin: 16px 0;
border: none;
border-top: 1px solid rgba(255, 77, 0, 0.2);
}
/* Distance Info */
.distance-info {
display: grid;
gap: 24px;
}
.distance-value {
font-size: 48px;
font-weight: 700;
color: var(--accent);
text-align: center;
padding: 24px;
background: rgba(255, 77, 0, 0.1);
border-radius: 12px;
border: 2px solid rgba(255, 77, 0, 0.3);
}
.distance-details {
display: grid;
gap: 8px;
}
.distance-details p {
padding: 12px 16px;
background: rgba(255, 77, 0, 0.05);
border-radius: 8px;
border-left: 3px solid var(--accent);
margin: 0;
font-size: 15px;
}
/* Zones Section */
.zones-section {
background: var(--bg-secondary);
border: 1px solid rgba(255, 77, 0, 0.2);
border-radius: 12px;
padding: 24px;
}
.zones-section h2 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 24px 0;
}
.zones-list {
display: grid;
gap: 16px;
}
.zone-card {
background: rgba(255, 77, 0, 0.05);
border-radius: 10px;
padding: 20px;
border-left: 4px solid;
transition: all 0.2s ease;
}
.zone-card:hover {
background: rgba(255, 77, 0, 0.08);
transform: translateX(4px);
}
.zone-card.priority-1 {
border-left-color: #ff4d00;
background: rgba(255, 77, 0, 0.1);
}
.zone-card.priority-2 {
border-left-color: #ff9f1a;
}
.zone-card.priority-3 {
border-left-color: #ffd166;
}
.zone-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 16px;
}
.zone-title {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
}
.zone-priority {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
background: var(--accent);
color: var(--bg-primary);
border-radius: 50%;
font-weight: 700;
font-size: 16px;
flex-shrink: 0;
}
.zone-name {
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
}
.zone-score {
font-size: 28px;
font-weight: 700;
color: var(--accent);
flex-shrink: 0;
}
.zone-details {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-bottom: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.zone-details .separator {
color: rgba(255, 77, 0, 0.5);
}
.zone-reasoning {
padding: 12px 16px;
background: rgba(255, 77, 0, 0.08);
border-radius: 6px;
font-size: 14px;
color: var(--text-secondary);
line-height: 1.6;
}
/* Map Section */
.map-section {
background: var(--bg-secondary);
border: 1px solid rgba(255, 77, 0, 0.2);
border-radius: 12px;
padding: 24px;
}
.map-section h2 {
font-size: 24px;
font-weight: 600;
color: var(--text-primary);
margin: 0 0 20px 0;
}
.map-info {
margin-top: 16px;
padding: 16px;
background: rgba(255, 77, 0, 0.05);
border-radius: 8px;
font-size: 14px;
color: var(--text-secondary);
}
.map-info p {
margin: 4px 0;
}
/* Loading & Error States */
.loading-state,
.error-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 60vh;
gap: 24px;
padding: 48px 24px;
}
.spinner {
width: 48px;
height: 48px;
border: 4px solid rgba(255, 77, 0, 0.2);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-state h2 {
font-size: 24px;
color: var(--text-primary);
margin: 0;
}
.error-state p {
font-size: 16px;
color: var(--text-secondary);
margin: 8px 0 24px;
}
.error-state button {
padding: 12px 24px;
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.error-state button:hover {
background: #ff6a1a;
transform: translateY(-2px);
}
/* Responsive */
@media (max-width: 768px) {
.result-header {
padding: 24px 16px;
}
.result-header h1 {
font-size: 28px;
}
.result-content {
padding: 0 16px 32px;
}
.distance-value {
font-size: 36px;
padding: 20px;
}
.zone-header {
flex-direction: column;
align-items: flex-start;
}
.zone-score {
font-size: 24px;
}
.case-info {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.separator {
display: none;
}
}
@@ -0,0 +1,414 @@
import React, { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import SearchMap from '../../components/SearchMap';
import "./AnalysisResult.css";
const AnalysisResult = () => {
const { caseId } = useParams();
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [caseData, setCaseData] = useState(null);
const [analysisData, setAnalysisData] = useState(null);
useEffect(() => {
const loadCaseAndAnalyze = async () => {
try {
setLoading(true);
setError(null);
const caseResponse = await fetch(`/api/v1/cases/${caseId}`);
if (!caseResponse.ok) {
throw new Error("Не удалось загрузить карточку");
}
const caseResult = await caseResponse.json();
setCaseData(caseResult);
const analysis = calculateFullDistance(caseResult);
setAnalysisData(analysis);
} catch (err) {
console.error("Error loading case:", err);
setError(err.message);
} finally {
setLoading(false);
}
};
if (caseId) {
loadCaseAndAnalyze();
}
}, [caseId]);
const getTimeOfDay = (lostTime) => {
if (!lostTime) return 'день';
const hour = parseInt(lostTime.split(':')[0]);
if (hour >= 6 && hour < 18) return 'день';
if (hour >= 18 && hour < 22) return 'сумерки';
return 'ночь';
};
const getPsychotypeModifiers = (psychotype) => {
const modifiers = {
'dominant': {
zone_0_500: 0.7,
zone_500_1500: 1.2,
zone_1500_2500: 1.4,
zone_2500plus: 1.1,
description: 'Доминантный: активное движение, большие расстояния'
},
'harmonic': {
zone_0_500: 0.8,
zone_500_1500: 1.0,
zone_1500_2500: 1.1,
zone_2500plus: 0.9,
description: 'Гармоничный: рациональное движение по ориентирам'
},
'anxious': {
zone_0_500: 1.4,
zone_500_1500: 1.1,
zone_1500_2500: 0.5,
zone_2500plus: 0.3,
description: 'Тревожный: минимальное движение, остается близко'
},
'introvert_passive': {
zone_0_500: 1.3,
zone_500_1500: 0.9,
zone_1500_2500: 0.6,
zone_2500plus: 0.2,
description: 'Интроверт пассивный: прячется, минимальное движение'
},
'introvert_active': {
zone_0_500: 0.8,
zone_500_1500: 1.1,
zone_1500_2500: 1.2,
zone_2500plus: 0.9,
description: 'Интроверт активный: целенаправленное движение'
}
};
return modifiers[psychotype] || modifiers['harmonic'];
};
const calculateFullDistance = (caseData) => {
const age = caseData.age_years || 10;
const elapsed = caseData.elapsed_hours || 1;
const terrain = caseData.terrain?.[0] || 'лес';
const timeOfDay = getTimeOfDay(caseData.lost_time);
const weather = caseData.precipitation || 'нет';
// НормС - Скорость смещения потерявшегося ребёнка (км/ч)
// Не скорость ходьбы, а реальное смещение: испуг, кружение, остановки
// Основа: ПСО ЭКСТРЕМУМ, 400 случаев — 94% найдены в пределах 3 км
let baseSpeed = 1.5;
if (age <= 2) baseSpeed = 0.3;
else if (age <= 5) baseSpeed = 0.7;
else if (age <= 8) baseSpeed = 1.2;
else if (age <= 12) baseSpeed = 1.5;
else if (age <= 15) baseSpeed = 2.0;
else if (age <= 17) baseSpeed = 2.5;
else if (age <= 64) baseSpeed = 2.5;
else baseSpeed = 1.5;
// СП - Коэффициент местности
let terrainCoef = 0.5;
const terrainLower = terrain.toLowerCase();
if (terrainLower.includes('дорог') || terrainLower.includes('троп')) terrainCoef = 0.8;
else if (terrainLower.includes('поле') || terrainLower.includes('луг')) terrainCoef = 0.9;
else if (terrainLower.includes('болот')) terrainCoef = 0.2;
else if (terrainLower.includes('лес')) terrainCoef = 0.5;
else if (terrainLower.includes('населённый')) terrainCoef = 1.0;
else if (terrainLower.includes('овраг')) terrainCoef = 0.3;
// СКД - Коэффициент диагноза (влияние на физическую способность двигаться)
let diagnosisCoef = 1.0;
const diagTypes = (caseData.diagnosis_type || [])
.filter(d => d && d !== 'нет' && d !== 'none')
.map(d => d.toLowerCase());
if (caseData.has_diagnosis && diagTypes.length > 0) {
if (diagTypes.some(d => d.includes('эпилепси'))) diagnosisCoef = 0.6;
else if (diagTypes.some(d => d.includes('зпр'))) diagnosisCoef = 0.8;
else if (diagTypes.some(d => d.includes('рас') || d.includes('аутизм'))) diagnosisCoef = 1.0;
else if (diagTypes.some(d => d.includes('сдвг'))) diagnosisCoef = 1.1;
else diagnosisCoef = 0.9;
}
// СУ - Коэффициент транспорта
// 'none' — это строка, проверяем явно чтобы не получить false positive
let urgencyCoef = 1.0;
if (caseData.has_transport && caseData.has_transport !== 'none') {
if (caseData.has_transport === 'bike') urgencyCoef = 5.0;
else if (caseData.has_transport === 'car') urgencyCoef = 8.0;
else urgencyCoef = 3.0;
}
// СУТ - Коэффициент усталости
const fatigueCoef = Math.max(0.3, 1.0 - (0.05 * elapsed));
// ВВС - Коэффициент времени суток
let timeCoef = 1.0;
if (timeOfDay === 'ночь') timeCoef = 0.5;
else if (timeOfDay === 'сумерки') timeCoef = 0.7;
// ВП - Коэффициент погоды
let weatherCoef = 1.0;
const weatherLower = weather.toLowerCase();
if (weatherLower.includes('дождь')) weatherCoef = 0.8;
else if (weatherLower.includes('снег')) weatherCoef = 0.6;
else if (weatherLower.includes('туман')) weatherCoef = 0.7;
// Полная формула
const distance = elapsed * baseSpeed * terrainCoef * diagnosisCoef * urgencyCoef * fatigueCoef * timeCoef * weatherCoef;
// Получаем модификаторы психотипа
const psychotypeModifiers = caseData.psychotype ? getPsychotypeModifiers(caseData.psychotype) : null;
return {
max_distance_km: distance.toFixed(2),
coefficients: {
base_speed: baseSpeed,
terrain: terrainCoef,
diagnosis: diagnosisCoef,
urgency: urgencyCoef,
fatigue: fatigueCoef,
time_of_day: timeCoef,
weather: weatherCoef
},
time_of_day: timeOfDay,
psychotype_modifiers: psychotypeModifiers,
zones: generateZonesWithPsychotype(distance, caseData, psychotypeModifiers)
};
};
const generateZonesWithPsychotype = (maxDist, caseData, psychotypeModifiers) => {
const directions = [
{ dir: 'N', name: 'Север' },
{ dir: 'NE', name: 'Северо-Восток' },
{ dir: 'E', name: 'Восток' },
{ dir: 'SE', name: 'Юго-Восток' },
{ dir: 'S', name: 'Юг' },
{ dir: 'SW', name: 'Юго-Запад' },
{ dir: 'W', name: 'Запад' },
{ dir: 'NW', name: 'Северо-Запад' }
];
const preferredDir = caseData.direction;
return directions.map((d, i) => {
let score = 100 - (i * 10);
// Применяем модификатор направления
if (preferredDir && d.dir === preferredDir) {
score += 30;
}
// Применяем модификатор психотипа в зависимости от дистанции
if (psychotypeModifiers) {
const distKm = maxDist * 0.75; // средняя дистанция сектора
let psychoMod = 1.0;
if (distKm < 0.5) psychoMod = psychotypeModifiers.zone_0_500;
else if (distKm < 1.5) psychoMod = psychotypeModifiers.zone_500_1500;
else if (distKm < 2.5) psychoMod = psychotypeModifiers.zone_1500_2500;
else psychoMod = psychotypeModifiers.zone_2500plus;
score = score * psychoMod;
}
let reasoning = 'Расчет по алгоритму Экстремум';
if (preferredDir === d.dir) reasoning += ' (указанное направление)';
if (psychotypeModifiers) reasoning += ` (психотип учтен)`;
return {
priority: i + 1,
name: `Сектор ${d.name}`,
direction: d.dir,
distance: `${(maxDist * 0.5).toFixed(1)}-${maxDist.toFixed(1)} км`,
score: Math.min(100, Math.round(score)),
reasoning
};
}).sort((a, b) => b.score - a.score);
};
if (loading) {
return (
<div className="analysis-result">
<div className="loading-state">
<div className="spinner"></div>
<p>Загрузка данных и расчет зон поиска...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="analysis-result">
<div className="error-state">
<h2>Ошибка загрузки</h2>
<p>{error}</p>
<button onClick={() => window.location.reload()}>Попробовать снова</button>
</div>
</div>
);
}
if (!caseData) {
return (
<div className="analysis-result">
<div className="error-state">
<h2>Карточка не найдена</h2>
<p>ID: {caseId}</p>
</div>
</div>
);
}
const urgencyLevel = caseData.elapsed_hours > 6 ? "ВЫСОКИЙ" : caseData.elapsed_hours > 3 ? "СРЕДНИЙ" : "СТАНДАРТНЫЙ";
const urgencyReason = `Прошло ${caseData.elapsed_hours || 1} часов. ${caseData.has_transport ? `Транспорт: ${caseData.has_transport} - расширенный радиус.` : ''} ${caseData.has_diagnosis ? 'Есть диагноз - требуется особое внимание.' : ''}`;
const zones = analysisData?.zones || [];
const coeffs = analysisData?.coefficients || {};
return (
<div className="analysis-result">
<div className="result-header">
<h1><span className="logo">ВЕКТОР</span> Результаты анализа</h1>
<div className="case-info">
<span>{caseData.child_name || 'Ребенок'}, {caseData.age_years} лет ({caseData.gender})</span>
<span className="separator">•</span>
<span>Время потери: {caseData.lost_time || 'не указано'}</span>
<span className="separator">•</span>
<span>Прошло: {caseData.elapsed_hours || 1} ч</span>
</div>
</div>
<div className="result-content">
{/* Блок 1: Уровень срочности */}
<div className={`urgency-block urgency-${urgencyLevel.toLowerCase()}`}>
<div className="urgency-level">{urgencyLevel}</div>
<div className="urgency-reason">{urgencyReason}</div>
</div>
{/* Блок 2: Расчет по алгоритму Экстремум */}
<div className="info-card">
<h3>📏 Расчет по алгоритму Экстремум</h3>
<div className="distance-info">
<div className="distance-value">{analysisData?.max_distance_km} км</div>
<div className="distance-details">
<p><strong>Формула:</strong> Distance = Time × НормС × СП × СКД × СУ × СУТ × ВВС × ВП</p>
<hr style={{margin: '8px 0', border: 'none', borderTop: '1px solid #30363d'}} />
<p><strong>Время (Time):</strong> {caseData.elapsed_hours || 1} ч</p>
<p><strong>Базовая скорость (НормС):</strong> {coeffs.base_speed} км/ч (возраст {caseData.age_years} лет)</p>
<p><strong>Местность (СП):</strong> {coeffs.terrain} ({caseData.terrain?.join(', ') || 'не указана'})</p>
<p><strong>Диагноз (СКД):</strong> {coeffs.diagnosis} {caseData.has_diagnosis ? `(${caseData.diagnosis_type?.join(', ')})` : '(нет)'}</p>
<p><strong>Транспорт (СУ):</strong> {coeffs.urgency} {caseData.has_transport ? `(${caseData.has_transport})` : '(нет)'}</p>
<p><strong>Усталость (СУТ):</strong> {coeffs.fatigue?.toFixed(2)} (снижение 5%/час)</p>
<p><strong>Время суток (ВВС):</strong> {coeffs.time_of_day} ({analysisData?.time_of_day})</p>
<p><strong>Погода (ВП):</strong> {coeffs.weather} ({caseData.precipitation || 'нет'})</p>
</div>
</div>
</div>
{/* Блок 3: Психотип */}
{caseData.psychotype && analysisData?.psychotype_modifiers && (
<div className="info-card">
<h3>🧠 Психотип и модель поведения</h3>
<p><strong>Психотип:</strong> {caseData.psychotype}</p>
<p><strong>Модель движения:</strong> {analysisData.psychotype_modifiers.description}</p>
<div style={{marginTop: '12px', padding: '12px', background: '#161b22', borderRadius: '6px'}}>
<p style={{fontSize: '14px', color: '#8b949e', margin: 0}}>
<strong>Модификаторы зон:</strong>
</p>
<p style={{fontSize: '13px', color: '#8b949e', margin: '4px 0 0 0'}}>
• 0-500м: ×{analysisData.psychotype_modifiers.zone_0_500}<br/>
• 500-1500м: ×{analysisData.psychotype_modifiers.zone_500_1500}<br/>
• 1500-2500м: ×{analysisData.psychotype_modifiers.zone_1500_2500}<br/>
• 2500м+: ×{analysisData.psychotype_modifiers.zone_2500plus}
</p>
</div>
</div>
)}
{/* Блок 4: Обстоятельства */}
<div className="info-card">
<h3>📋 Обстоятельства потери</h3>
<p><strong>Причина:</strong> {caseData.reason || 'не указана'}</p>
<p><strong>Последнее известное место:</strong> {caseData.last_known_place || 'не указано'}</p>
{caseData.direction && <p><strong>Направление движения:</strong> {caseData.direction}</p>}
{caseData.circumstances_description && (
<p><strong>Описание:</strong> {caseData.circumstances_description}</p>
)}
</div>
{/* Блок 5: Зоны поиска */}
<div className="zones-section">
<h2>Приоритетные зоны поиска</h2>
<div className="zones-list">
{zones.slice(0, 8).map((zone, idx) => (
<div key={idx} className={`zone-card priority-${Math.min(idx + 1, 3)}`}>
<div className="zone-header">
<div className="zone-title">
<span className="zone-priority">#{idx + 1}</span>
<span className="zone-name">{zone.name}</span>
</div>
<div className="zone-score">{zone.score}%</div>
</div>
<div className="zone-details">
<span><strong>Направление:</strong> {zone.direction}</span>
<span className="separator">•</span>
<span><strong>Дистанция:</strong> {zone.distance}</span>
</div>
<div className="zone-reasoning">{zone.reasoning}</div>
</div>
))}
</div>
</div>
{/* Блок 6: Карта */}
{caseData.tnp_lat && caseData.tnp_lon && (
<div className="map-section">
<h2>Карта зон поиска</h2>
<SearchMap zones={zones}
maxDistance={parseFloat(analysisData?.max_distance_km)}
tnpLat={parseFloat(caseData.tnp_lat)}
tnpLon={parseFloat(caseData.tnp_lon)}
/>
<div className="map-info" style={{ marginTop: "12px", fontSize: "14px", color: "#8b949e" }}>
<p>Координаты ТНП: {caseData.tnp_lat}, {caseData.tnp_lon}</p>
{caseData.tnp_address && <p>Адрес: {caseData.tnp_address}</p>}
</div>
</div>
)}
{/* Блок 7: Здоровье и особенности */}
{(caseData.has_diagnosis || caseData.cant_swim || caseData.fitness_level) && (
<div className="info-card">
<h3>🏥 Здоровье и особенности</h3>
{caseData.has_diagnosis && (
<p><strong>Диагнозы:</strong> {caseData.diagnosis_type?.join(', ') || 'есть'}</p>
)}
{caseData.fitness_level && (
<p><strong>Физическая подготовка:</strong> {caseData.fitness_level}</p>
)}
{caseData.cant_swim && <p>⚠️ <strong>Не умеет плавать</strong></p>}
{caseData.has_transport && (
<p><strong>Транспорт:</strong> {caseData.has_transport}</p>
)}
</div>
)}
{/* Блок 8: Условия среды */}
<div className="info-card">
<h3>🌤 Условия среды</h3>
<p><strong>Сезон:</strong> {caseData.season || 'не указан'}</p>
{caseData.temperature && <p><strong>Температура:</strong> {caseData.temperature}°C</p>}
<p><strong>Осадки:</strong> {caseData.precipitation || 'нет'}</p>
{caseData.visibility && <p><strong>Видимость:</strong> {caseData.visibility}</p>}
</div>
</div>
</div>
);
};
export default AnalysisResult;