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:
root
2026-08-20 16:14:25 +00:00
parent bef93d159c
commit d0f309cdf6
23 changed files with 0 additions and 4262 deletions
@@ -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;
-146
View File
@@ -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;
}
}
-273
View File
@@ -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";
-353
View File
@@ -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);
}
-361
View File
@@ -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;
-296
View File
@@ -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;
-203
View File
@@ -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;
}
}
-175
View File
@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18
}).addTo(map);
mapInstanceRef.current = map;
return () => {
if (mapInstanceRef.current) {
mapInstanceRef.current.remove();
mapInstanceRef.current = null;
}
};
}, []);
// Fetch heatmap data
const fetchHeatmapData = async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({
type: filterType
});
if (filterType === 'age' && ageGroup) {
params.append('age_group', ageGroup);
}
if (filterType === 'season' && season) {
params.append('season', season);
}
if (yearFrom) {
params.append('year_from', yearFrom);
}
if (yearTo) {
params.append('year_to', yearTo);
}
const response = await fetch(`http://192.168.0.99:8000/api/v1/stats/heatmap?${params}`);
if (!response.ok) {
throw new Error('Failed to fetch heatmap data');
}
const data = await response.json();
setHeatmapData(data.points);
setTotal(data.total);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Update heatmap layer
useEffect(() => {
if (!mapInstanceRef.current) return;
// Remove existing heat layer
if (heatLayerRef.current) {
mapInstanceRef.current.removeLayer(heatLayerRef.current);
heatLayerRef.current = null;
}
// Add new heat layer if visible and data exists
if (showHeatmap && heatmapData.length > 0) {
const heatData = heatmapData.map(point => [point.lat, point.lon, point.intensity]);
const heatLayer = L.heatLayer(heatData, {
radius: 25,
blur: 15,
maxZoom: 10,
max: 1.0,
gradient: {
0.0: 'blue',
0.5: 'lime',
0.7: 'yellow',
1.0: 'red'
}
});
heatLayer.addTo(mapInstanceRef.current);
heatLayerRef.current = heatLayer;
}
}, [heatmapData, showHeatmap]);
// Load data on mount and filter change
useEffect(() => {
fetchHeatmapData();
}, [filterType, ageGroup, season, yearFrom, yearTo]);
const handleFilterTypeChange = (type) => {
setFilterType(type);
// Reset specific filters
if (type !== 'age') setAgeGroup('');
if (type !== 'season') setSeason('');
};
return (
<div className="heatmap-view">
<div className="heatmap-controls">
<h2>Тепловая карта находок</h2>
<div className="control-group">
<label>Тип фильтра:</label>
<div className="button-group">
<button
className={filterType === 'all' ? 'active' : ''}
onClick={() => handleFilterTypeChange('all')}
>
Все
</button>
<button
className={filterType === 'age' ? 'active' : ''}
onClick={() => handleFilterTypeChange('age')}
>
По возрасту
</button>
<button
className={filterType === 'season' ? 'active' : ''}
onClick={() => handleFilterTypeChange('season')}
>
По сезону
</button>
<button
className={filterType === 'outcome' ? 'active' : ''}
onClick={() => handleFilterTypeChange('outcome')}
>
По исходу
</button>
</div>
</div>
{filterType === 'age' && (
<div className="control-group">
<label>Возрастная группа:</label>
<select value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)}>
<option value="">Все</option>
<option value="0-6">0-6 лет</option>
<option value="7-10">7-10 лет</option>
<option value="11-14">11-14 лет</option>
<option value="15-17">15-17 лет</option>
<option value="18+">18+ лет</option>
</select>
</div>
)}
{filterType === 'season' && (
<div className="control-group">
<label>Сезон:</label>
<select value={season} onChange={(e) => setSeason(e.target.value)}>
<option value="">Все</option>
<option value="зима">Зима</option>
<option value="весна">Весна</option>
<option value="лето">Лето</option>
<option value="осень">Осень</option>
</select>
</div>
)}
<div className="control-group">
<label>Период:</label>
<div className="year-range">
<input
type="number"
placeholder="От года"
value={yearFrom}
onChange={(e) => setYearFrom(e.target.value)}
min="2000"
max="2100"
/>
<span>—</span>
<input
type="number"
placeholder="До года"
value={yearTo}
onChange={(e) => setYearTo(e.target.value)}
min="2000"
max="2100"
/>
</div>
</div>
<div className="control-group">
<label>
<input
type="checkbox"
checked={showHeatmap}
onChange={(e) => setShowHeatmap(e.target.checked)}
/>
Показать тепловую карту
</label>
</div>
<div className="stats-info">
<p>Всего точек: <strong>{total}</strong></p>
{loading && <p className="loading">Загрузка...</p>}
{error && <p className="error">Ошибка: {error}</p>}
</div>
<div className="legend">
<h3>Легенда</h3>
<div className="legend-gradient">
<span className="legend-label">Низкая плотность</span>
<div className="gradient-bar"></div>
<span className="legend-label">Высокая плотность</span>
</div>
</div>
</div>
<div className="map-container">
<div ref={mapRef} className="map"></div>
</div>
</div>
);
};
export default HeatmapView;
@@ -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;