Import Vector lab project

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