chore(frontend): remove dead components (B4)
Недостижимые от index.js -> App компоненты (23 файла): - MobileForm.jsx/.tsx/.css (живая мобильная форма = CaseForm) - BehavioralProfile.tsx/.css - AnalysisLayout/, MapView/, ResultPanel/ (MapView/ResultPanel импортировались только из неиспользуемого AnalysisLayout; живая карта = SearchMap.jsx) - admin/ (CaseDetail, CasesList, HeatmapView, Statistics) - AdminDashboard использует inline-UI, не эти компоненты - CaseForm/Step5Resources.jsx - не подключён в CaseForm.jsx Сборка проходит. Мёртвый код с его багами (isPsycotypeSelected опечатка, хардкод URL, NaN-гварды) уходит вместе с файлами. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,112 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "./AnalysisLayout";
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
.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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,268 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,146 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "./MapView";
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
/* Мобильная форма — палитра из §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);
|
|
||||||
}
|
|
||||||
@@ -1,361 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,416 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,289 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "./ResultPanel";
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,162 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,203 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
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: '© <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;
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
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;
|
|
||||||
Reference in New Issue
Block a user