Import Vector lab project
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
.case-detail {
|
||||
padding: 32px;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 16px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-back:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.detail-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.detail-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.detail-section h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 20px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.info-item label {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.info-item span {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.diagnosis-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.diagnosis-badge {
|
||||
padding: 8px 16px;
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.psychotype-section {
|
||||
background: linear-gradient(135deg, #dbeafe 0%, #e0e7ff 100%);
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
|
||||
.psychotype-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.psychotype-description {
|
||||
font-size: 16px;
|
||||
color: #1e293b;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-tips {
|
||||
padding: 16px;
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #10b981;
|
||||
}
|
||||
|
||||
.search-tips h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 15px;
|
||||
color: #059669;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.search-tips p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.answers-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.answer-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.answer-item label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.answer-item span {
|
||||
font-size: 14px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.case-detail {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.answer-item {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './CaseDetail.css';
|
||||
|
||||
const CaseDetail = () => {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [caseData, setCase] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCase();
|
||||
}, [id]);
|
||||
|
||||
const fetchCase = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get(`/api/v1/cases/${id}`);
|
||||
setCase(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки карточки');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Не указано';
|
||||
return new Date(dateString).toLocaleString('ru-RU');
|
||||
};
|
||||
|
||||
const getPsychotypeDescription = (psychotype) => {
|
||||
const descriptions = {
|
||||
'explorer': {
|
||||
title: 'Исследователь',
|
||||
description: 'Активно исследует территорию, может уйти далеко от точки потери',
|
||||
searchTips: 'Расширенный радиус поиска, проверка интересных объектов'
|
||||
},
|
||||
'cautious': {
|
||||
title: 'Осторожный',
|
||||
description: 'Осторожен, держится знакомых мест',
|
||||
searchTips: 'Поиск вблизи точки потери, знакомые маршруты'
|
||||
},
|
||||
'passive': {
|
||||
title: 'Пассивный',
|
||||
description: 'Ждёт помощи, малоподвижен',
|
||||
searchTips: 'Тщательный осмотр укрытий, проверка мест для отдыха'
|
||||
},
|
||||
'panicker': {
|
||||
title: 'Паникёр',
|
||||
description: 'Паникует, может двигаться хаотично',
|
||||
searchTips: 'Проверка опасных мест, водоёмов, обрывов'
|
||||
}
|
||||
};
|
||||
return descriptions[psychotype] || { title: psychotype, description: '', searchTips: '' };
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-state">Загрузка...</div>;
|
||||
if (error) return <div className="error-state">{error}</div>;
|
||||
if (!caseData) return <div className="error-state">Карточка не найдена</div>;
|
||||
|
||||
const psychotypeInfo = caseData.psychotype_result
|
||||
? getPsychotypeDescription(caseData.psychotype_result)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="case-detail">
|
||||
<div className="detail-header">
|
||||
<button className="btn-back" onClick={() => navigate('/admin/cases')}>
|
||||
← Назад к списку
|
||||
</button>
|
||||
<h1>Карточка #{caseData.id}</h1>
|
||||
</div>
|
||||
|
||||
<div className="detail-content">
|
||||
{/* Основная информация */}
|
||||
<section className="detail-section">
|
||||
<h2>Основная информация</h2>
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<label>Возраст:</label>
|
||||
<span>{caseData.age || '—'} лет</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Пол:</label>
|
||||
<span>{caseData.gender === 'male' ? 'Мужской' : caseData.gender === 'female' ? 'Женский' : '—'}</span>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<label>Дата создания:</label>
|
||||
<span>{formatDate(caseData.created_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Диагнозы */}
|
||||
{caseData.diagnosis_type && caseData.diagnosis_type.length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h2>Диагнозы</h2>
|
||||
<div className="diagnosis-list">
|
||||
{caseData.diagnosis_type.map((diagnosis, idx) => (
|
||||
<span key={idx} className="diagnosis-badge">{diagnosis}</span>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Психотип */}
|
||||
{psychotypeInfo && (
|
||||
<section className="detail-section psychotype-section">
|
||||
<h2>Психотип: {psychotypeInfo.title}</h2>
|
||||
<div className="psychotype-info">
|
||||
<p className="psychotype-description">{psychotypeInfo.description}</p>
|
||||
<div className="search-tips">
|
||||
<h3>Рекомендации по поиску:</h3>
|
||||
<p>{psychotypeInfo.searchTips}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Ответы на вопросы психотипа */}
|
||||
{caseData.psychotype_answers && Object.keys(caseData.psychotype_answers).length > 0 && (
|
||||
<section className="detail-section">
|
||||
<h2>Ответы на вопросы</h2>
|
||||
<div className="answers-list">
|
||||
{caseData.psychotype_answers.unfamiliar_env && (
|
||||
<div className="answer-item">
|
||||
<label>Поведение в незнакомой обстановке:</label>
|
||||
<span>{caseData.psychotype_answers.unfamiliar_env}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.psychotype_answers.stress_reaction && (
|
||||
<div className="answer-item">
|
||||
<label>Реакция на стресс:</label>
|
||||
<span>{caseData.psychotype_answers.stress_reaction}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.psychotype_answers.group_role && (
|
||||
<div className="answer-item">
|
||||
<label>Роль в группе:</label>
|
||||
<span>{caseData.psychotype_answers.group_role}</span>
|
||||
</div>
|
||||
)}
|
||||
{caseData.psychotype_answers.risk_taking && (
|
||||
<div className="answer-item">
|
||||
<label>Склонность к риску:</label>
|
||||
<span>{caseData.psychotype_answers.risk_taking}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CaseDetail;
|
||||
@@ -0,0 +1,203 @@
|
||||
.cases-list {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cases-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.cases-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 10px 20px;
|
||||
background: #e2e8f0;
|
||||
color: #475569;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.filters-panel {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding: 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-group select,
|
||||
.filter-group input {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.filter-group input[type=number] {
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cases-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.cases-table thead {
|
||||
background: #f8fafc;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.cases-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.cases-table td {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.cases-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.psychotype-badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
background: #dbeafe;
|
||||
color: #1e40af;
|
||||
border-radius: 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.error-state {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.cases-footer {
|
||||
margin-top: 20px;
|
||||
padding: 16px 20px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cases-footer p {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.cases-list {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cases-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.filters-panel {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.cases-table {
|
||||
min-width: 800px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import axios from 'axios';
|
||||
import './CasesList.css';
|
||||
|
||||
const CasesList = () => {
|
||||
const [cases, setCases] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [filters, setFilters] = useState({
|
||||
gender: '',
|
||||
ageFrom: '',
|
||||
ageTo: '',
|
||||
diagnosis: '',
|
||||
psychotype: ''
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchCases();
|
||||
}, [filters]);
|
||||
|
||||
const fetchCases = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.gender) params.append('gender', filters.gender);
|
||||
if (filters.ageFrom) params.append('age_from', filters.ageFrom);
|
||||
if (filters.ageTo) params.append('age_to', filters.ageTo);
|
||||
if (filters.diagnosis) params.append('diagnosis', filters.diagnosis);
|
||||
if (filters.psychotype) params.append('psychotype', filters.psychotype);
|
||||
|
||||
const response = await axios.get(`/api/v1/cases?${params}`);
|
||||
setCases(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки данных');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return 'Не указано';
|
||||
return new Date(dateString).toLocaleDateString('ru-RU');
|
||||
};
|
||||
|
||||
const getPsychotypeLabel = (psychotype) => {
|
||||
const labels = {
|
||||
'explorer': 'Исследователь',
|
||||
'cautious': 'Осторожный',
|
||||
'passive': 'Пассивный',
|
||||
'panicker': 'Паникёр'
|
||||
};
|
||||
return labels[psychotype] || psychotype;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cases-list">
|
||||
<div className="cases-header">
|
||||
<h1>Карточки пропавших</h1>
|
||||
<div className="header-actions">
|
||||
<button className="btn-primary" onClick={fetchCases}>
|
||||
🔄 Обновить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Фильтры */}
|
||||
<div className="filters-panel">
|
||||
<div className="filter-group">
|
||||
<label>Пол:</label>
|
||||
<select value={filters.gender} onChange={(e) => setFilters({...filters, gender: e.target.value})}>
|
||||
<option value="">Все</option>
|
||||
<option value="male">Мужской</option>
|
||||
<option value="female">Женский</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Возраст от:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.ageFrom}
|
||||
onChange={(e) => setFilters({...filters, ageFrom: e.target.value})}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>до:</label>
|
||||
<input
|
||||
type="number"
|
||||
value={filters.ageTo}
|
||||
onChange={(e) => setFilters({...filters, ageTo: e.target.value})}
|
||||
placeholder="100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="filter-group">
|
||||
<label>Диагноз:</label>
|
||||
<select value={filters.diagnosis} onChange={(e) => setFilters({...filters, diagnosis: e.target.value})}>
|
||||
<option value="">Все</option>
|
||||
<option value="РАС">РАС</option>
|
||||
<option value="эпилепсия">Эпилепсия</option>
|
||||
<option value="СДВГ">СДВГ</option>
|
||||
<option value="деменция">Деменция</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button className="btn-secondary" onClick={() => setFilters({
|
||||
gender: '', ageFrom: '', ageTo: '', diagnosis: '', psychotype: ''
|
||||
})}>
|
||||
Сбросить фильтры
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Таблица */}
|
||||
{loading ? (
|
||||
<div className="loading-state">Загрузка...</div>
|
||||
) : error ? (
|
||||
<div className="error-state">{error}</div>
|
||||
) : cases.length === 0 ? (
|
||||
<div className="empty-state">Карточки не найдены</div>
|
||||
) : (
|
||||
<div className="table-container">
|
||||
<table className="cases-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Возраст</th>
|
||||
<th>Пол</th>
|
||||
<th>Диагнозы</th>
|
||||
<th>Психотип</th>
|
||||
<th>Дата создания</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cases.map(caseItem => (
|
||||
<tr key={caseItem.id}>
|
||||
<td>{caseItem.id}</td>
|
||||
<td>{caseItem.age || '—'}</td>
|
||||
<td>{caseItem.gender === 'male' ? 'М' : caseItem.gender === 'female' ? 'Ж' : '—'}</td>
|
||||
<td>
|
||||
{caseItem.diagnosis_type && caseItem.diagnosis_type.length > 0
|
||||
? caseItem.diagnosis_type.join(', ')
|
||||
: '—'}
|
||||
</td>
|
||||
<td>
|
||||
{caseItem.psychotype_result
|
||||
? <span className="psychotype-badge">{getPsychotypeLabel(caseItem.psychotype_result)}</span>
|
||||
: '—'}
|
||||
</td>
|
||||
<td>{formatDate(caseItem.created_at)}</td>
|
||||
<td>
|
||||
<Link to={`/admin/cases/${caseItem.id}`} className="btn-link">
|
||||
Подробнее
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cases-footer">
|
||||
<p>Всего карточек: <strong>{cases.length}</strong></p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CasesList;
|
||||
@@ -0,0 +1,157 @@
|
||||
.heatmap-view {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', sans-serif;
|
||||
}
|
||||
|
||||
.heatmap-controls {
|
||||
width: 320px;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
border-right: 1px solid #ddd;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.heatmap-controls h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 1.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 500;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.button-group button {
|
||||
padding: 8px 16px;
|
||||
border: 1px solid #ccc;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.button-group button:hover {
|
||||
background: #e9e9e9;
|
||||
}
|
||||
|
||||
.button-group button.active {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border-color: #007bff;
|
||||
}
|
||||
|
||||
.control-group select {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.year-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.year-range input {
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.year-range span {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.control-group input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.stats-info {
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stats-info p {
|
||||
margin: 5px 0;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.stats-info strong {
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
.loading {
|
||||
color: #007bff;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #dc3545;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.legend {
|
||||
padding: 15px;
|
||||
background: white;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.legend h3 {
|
||||
margin: 0 0 10px 0;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.legend-gradient {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.gradient-bar {
|
||||
height: 20px;
|
||||
background: linear-gradient(to right, blue, lime, yellow, red);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.legend-label {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* Leaflet overrides */
|
||||
.leaflet-container {
|
||||
font-family: inherit;
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import L from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import 'leaflet.heat';
|
||||
import './HeatmapView.css';
|
||||
|
||||
const HeatmapView = () => {
|
||||
const mapRef = useRef(null);
|
||||
const mapInstanceRef = useRef(null);
|
||||
const heatLayerRef = useRef(null);
|
||||
|
||||
const [heatmapData, setHeatmapData] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
|
||||
// Filters
|
||||
const [filterType, setFilterType] = useState('all');
|
||||
const [ageGroup, setAgeGroup] = useState('');
|
||||
const [season, setSeason] = useState('');
|
||||
const [yearFrom, setYearFrom] = useState('');
|
||||
const [yearTo, setYearTo] = useState('');
|
||||
|
||||
// Layer visibility
|
||||
const [showHeatmap, setShowHeatmap] = useState(true);
|
||||
|
||||
// Initialize map
|
||||
useEffect(() => {
|
||||
if (!mapRef.current || mapInstanceRef.current) return;
|
||||
|
||||
// Create map centered on Russia
|
||||
const map = L.map(mapRef.current).setView([55.7558, 37.6173], 5);
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(map);
|
||||
|
||||
mapInstanceRef.current = map;
|
||||
|
||||
return () => {
|
||||
if (mapInstanceRef.current) {
|
||||
mapInstanceRef.current.remove();
|
||||
mapInstanceRef.current = null;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch heatmap data
|
||||
const fetchHeatmapData = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
type: filterType
|
||||
});
|
||||
|
||||
if (filterType === 'age' && ageGroup) {
|
||||
params.append('age_group', ageGroup);
|
||||
}
|
||||
if (filterType === 'season' && season) {
|
||||
params.append('season', season);
|
||||
}
|
||||
if (yearFrom) {
|
||||
params.append('year_from', yearFrom);
|
||||
}
|
||||
if (yearTo) {
|
||||
params.append('year_to', yearTo);
|
||||
}
|
||||
|
||||
const response = await fetch(`http://192.168.0.99:8000/api/v1/stats/heatmap?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch heatmap data');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
setHeatmapData(data.points);
|
||||
setTotal(data.total);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update heatmap layer
|
||||
useEffect(() => {
|
||||
if (!mapInstanceRef.current) return;
|
||||
|
||||
// Remove existing heat layer
|
||||
if (heatLayerRef.current) {
|
||||
mapInstanceRef.current.removeLayer(heatLayerRef.current);
|
||||
heatLayerRef.current = null;
|
||||
}
|
||||
|
||||
// Add new heat layer if visible and data exists
|
||||
if (showHeatmap && heatmapData.length > 0) {
|
||||
const heatData = heatmapData.map(point => [point.lat, point.lon, point.intensity]);
|
||||
|
||||
const heatLayer = L.heatLayer(heatData, {
|
||||
radius: 25,
|
||||
blur: 15,
|
||||
maxZoom: 10,
|
||||
max: 1.0,
|
||||
gradient: {
|
||||
0.0: 'blue',
|
||||
0.5: 'lime',
|
||||
0.7: 'yellow',
|
||||
1.0: 'red'
|
||||
}
|
||||
});
|
||||
|
||||
heatLayer.addTo(mapInstanceRef.current);
|
||||
heatLayerRef.current = heatLayer;
|
||||
}
|
||||
}, [heatmapData, showHeatmap]);
|
||||
|
||||
// Load data on mount and filter change
|
||||
useEffect(() => {
|
||||
fetchHeatmapData();
|
||||
}, [filterType, ageGroup, season, yearFrom, yearTo]);
|
||||
|
||||
const handleFilterTypeChange = (type) => {
|
||||
setFilterType(type);
|
||||
// Reset specific filters
|
||||
if (type !== 'age') setAgeGroup('');
|
||||
if (type !== 'season') setSeason('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="heatmap-view">
|
||||
<div className="heatmap-controls">
|
||||
<h2>Тепловая карта находок</h2>
|
||||
|
||||
<div className="control-group">
|
||||
<label>Тип фильтра:</label>
|
||||
<div className="button-group">
|
||||
<button
|
||||
className={filterType === 'all' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('all')}
|
||||
>
|
||||
Все
|
||||
</button>
|
||||
<button
|
||||
className={filterType === 'age' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('age')}
|
||||
>
|
||||
По возрасту
|
||||
</button>
|
||||
<button
|
||||
className={filterType === 'season' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('season')}
|
||||
>
|
||||
По сезону
|
||||
</button>
|
||||
<button
|
||||
className={filterType === 'outcome' ? 'active' : ''}
|
||||
onClick={() => handleFilterTypeChange('outcome')}
|
||||
>
|
||||
По исходу
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filterType === 'age' && (
|
||||
<div className="control-group">
|
||||
<label>Возрастная группа:</label>
|
||||
<select value={ageGroup} onChange={(e) => setAgeGroup(e.target.value)}>
|
||||
<option value="">Все</option>
|
||||
<option value="0-6">0-6 лет</option>
|
||||
<option value="7-10">7-10 лет</option>
|
||||
<option value="11-14">11-14 лет</option>
|
||||
<option value="15-17">15-17 лет</option>
|
||||
<option value="18+">18+ лет</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filterType === 'season' && (
|
||||
<div className="control-group">
|
||||
<label>Сезон:</label>
|
||||
<select value={season} onChange={(e) => setSeason(e.target.value)}>
|
||||
<option value="">Все</option>
|
||||
<option value="зима">Зима</option>
|
||||
<option value="весна">Весна</option>
|
||||
<option value="лето">Лето</option>
|
||||
<option value="осень">Осень</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="control-group">
|
||||
<label>Период:</label>
|
||||
<div className="year-range">
|
||||
<input
|
||||
type="number"
|
||||
placeholder="От года"
|
||||
value={yearFrom}
|
||||
onChange={(e) => setYearFrom(e.target.value)}
|
||||
min="2000"
|
||||
max="2100"
|
||||
/>
|
||||
<span>—</span>
|
||||
<input
|
||||
type="number"
|
||||
placeholder="До года"
|
||||
value={yearTo}
|
||||
onChange={(e) => setYearTo(e.target.value)}
|
||||
min="2000"
|
||||
max="2100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="control-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHeatmap}
|
||||
onChange={(e) => setShowHeatmap(e.target.checked)}
|
||||
/>
|
||||
Показать тепловую карту
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="stats-info">
|
||||
<p>Всего точек: <strong>{total}</strong></p>
|
||||
{loading && <p className="loading">Загрузка...</p>}
|
||||
{error && <p className="error">Ошибка: {error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="legend">
|
||||
<h3>Легенда</h3>
|
||||
<div className="legend-gradient">
|
||||
<span className="legend-label">Низкая плотность</span>
|
||||
<div className="gradient-bar"></div>
|
||||
<span className="legend-label">Высокая плотность</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-container">
|
||||
<div ref={mapRef} className="map"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HeatmapView;
|
||||
@@ -0,0 +1,98 @@
|
||||
.statistics {
|
||||
padding: 32px;
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.stats-header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 24px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.stat-card.wide {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-breakdown {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-size: 14px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.stat-item strong {
|
||||
font-size: 18px;
|
||||
color: #1e293b;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.statistics {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.stat-card.wide {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import './Statistics.css';
|
||||
|
||||
const Statistics = () => {
|
||||
const [stats, setStats] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStatistics();
|
||||
}, []);
|
||||
|
||||
const fetchStatistics = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await axios.get('/api/v1/stats/summary');
|
||||
setStats(response.data);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError('Ошибка загрузки статистики');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="loading-state">Загрузка...</div>;
|
||||
if (error) return <div className="error-state">{error}</div>;
|
||||
if (!stats) return <div className="error-state">Нет данных</div>;
|
||||
|
||||
return (
|
||||
<div className="statistics">
|
||||
<div className="stats-header">
|
||||
<h1>Статистика</h1>
|
||||
<button className="btn-primary" onClick={fetchStatistics}>
|
||||
🔄 Обновить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
{/* Общая статистика */}
|
||||
<div className="stat-card">
|
||||
<h3>Всего карточек</h3>
|
||||
<div className="stat-value">{stats.total_cases || 0}</div>
|
||||
</div>
|
||||
|
||||
{/* По полу */}
|
||||
<div className="stat-card">
|
||||
<h3>По полу</h3>
|
||||
<div className="stat-breakdown">
|
||||
<div className="stat-item">
|
||||
<span>Мужской:</span>
|
||||
<strong>{stats.by_gender?.male || 0}</strong>
|
||||
</div>
|
||||
<div className="stat-item">
|
||||
<span>Женский:</span>
|
||||
<strong>{stats.by_gender?.female || 0}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* По возрасту */}
|
||||
<div className="stat-card">
|
||||
<h3>По возрасту</h3>
|
||||
<div className="stat-breakdown">
|
||||
{stats.by_age && Object.entries(stats.by_age).map(([range, count]) => (
|
||||
<div key={range} className="stat-item">
|
||||
<span>{range}:</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* По психотипам */}
|
||||
<div className="stat-card">
|
||||
<h3>По психотипам</h3>
|
||||
<div className="stat-breakdown">
|
||||
{stats.by_psychotype && Object.entries(stats.by_psychotype).map(([type, count]) => (
|
||||
<div key={type} className="stat-item">
|
||||
<span>{type}:</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* По диагнозам */}
|
||||
<div className="stat-card wide">
|
||||
<h3>По диагнозам</h3>
|
||||
<div className="stat-breakdown">
|
||||
{stats.by_diagnosis && Object.entries(stats.by_diagnosis).map(([diagnosis, count]) => (
|
||||
<div key={diagnosis} className="stat-item">
|
||||
<span>{diagnosis}:</span>
|
||||
<strong>{count}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Statistics;
|
||||
Reference in New Issue
Block a user