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