create-ex-den-kit
Version:
Create exam projects with Ex-Den-Kit
453 lines (423 loc) • 17.7 kB
JSX
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion, AnimatePresence } from 'framer-motion';
import axios from 'axios';
import toast from 'react-hot-toast';
import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import {
Shield,
LogOut,
Users,
FileText,
CheckCircle,
XCircle,
Clock,
Trash2,
MessageSquare,
Filter,
Search
} from 'lucide-react';
import { format } from 'date-fns';
import { ru } from 'date-fns/locale';
const AdminDashboard = () => {
const { logout } = useAuth();
const { config } = useTheme();
const navigate = useNavigate();
const [entries, setEntries] = useState([]);
const [filteredEntries, setFilteredEntries] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedStatus, setSelectedStatus] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [showRejectModal, setShowRejectModal] = useState(false);
const [selectedEntry, setSelectedEntry] = useState(null);
const [rejectionReason, setRejectionReason] = useState('');
useEffect(() => {
fetchEntries();
}, []);
useEffect(() => {
filterEntries();
}, [entries, selectedStatus, searchQuery]);
const fetchEntries = async () => {
try {
const response = await axios.get('/api/admin/entries');
setEntries(response.data);
} catch (error) {
toast.error('Ошибка при загрузке записей');
} finally {
setLoading(false);
}
};
const filterEntries = () => {
let filtered = entries;
if (selectedStatus !== 'all') {
filtered = filtered.filter(entry => entry.status === selectedStatus);
}
if (searchQuery) {
filtered = filtered.filter(entry =>
entry.user.fullName.toLowerCase().includes(searchQuery.toLowerCase()) ||
JSON.stringify(entry.data).toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredEntries(filtered);
};
const handleStatusChange = async (entryId, newStatus) => {
try {
await axios.put(`/api/admin/entries/${entryId}/status`, {
status: newStatus
});
toast.success('Статус обновлен');
fetchEntries();
} catch (error) {
toast.error('Ошибка при обновлении статуса');
}
};
const handleReject = async () => {
if (!rejectionReason.trim()) {
toast.error('Укажите причину отклонения');
return;
}
try {
await axios.put(`/api/admin/entries/${selectedEntry.id}/status`, {
status: 'rejected',
rejectionReason
});
toast.success('Запись отклонена');
setShowRejectModal(false);
setRejectionReason('');
fetchEntries();
} catch (error) {
toast.error('Ошибка при отклонении записи');
}
};
const handleDelete = async (id) => {
if (!window.confirm('Вы уверены, что хотите удалить эту запись?')) return;
try {
await axios.delete(`/api/admin/entries/${id}`);
toast.success('Запись удалена');
fetchEntries();
} catch (error) {
toast.error('Ошибка при удалении записи');
}
};
const handleLogout = () => {
logout();
navigate('/login');
};
const getStatusOptions = (currentStatus) => {
const allStatuses = config.statuses;
return allStatuses.filter(status => status !== currentStatus);
};
const getStatusLabel = (status) => {
const statusLabels = {
new: 'Новая',
approved: 'Одобрена',
rejected: 'Отклонена',
studying: 'Идет обучение',
completed: 'Обучение завершено',
visited: 'Посещение состоялось',
cancelled: 'Отменена',
in_work: 'В работе',
};
return statusLabels[status] || status;
};
const getStatusColor = (status) => {
const colors = {
new: 'bg-yellow-100 text-yellow-800',
approved: 'bg-green-100 text-green-800',
rejected: 'bg-red-100 text-red-800',
studying: 'bg-blue-100 text-blue-800',
completed: 'bg-purple-100 text-purple-800',
visited: 'bg-green-100 text-green-800',
cancelled: 'bg-gray-100 text-gray-800',
in_work: 'bg-orange-100 text-orange-800',
};
return colors[status] || 'bg-gray-100 text-gray-800';
};
const stats = {
total: entries.length,
new: entries.filter(e => e.status === 'new').length,
approved: entries.filter(e => ['approved', 'studying', 'visited', 'in_work'].includes(e.status)).length,
rejected: entries.filter(e => ['rejected', 'cancelled'].includes(e.status)).length,
};
if (loading) {
return (
<div className="min-h-screen flex items-center justify-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: 'linear' }}
className="w-16 h-16 border-4 border-red-600 border-t-transparent rounded-full"
/>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center space-x-4">
<Shield className="text-red-600" size={28} />
<h1 className="text-xl font-bold text-gray-900">
Панель администратора - {config.name}
</h1>
</div>
<button
onClick={handleLogout}
className="flex items-center space-x-2 px-4 py-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<LogOut size={20} />
<span>Выход</span>
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Статистика */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="bg-white rounded-lg shadow p-6"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Всего записей</p>
<p className="text-3xl font-bold text-gray-900">{stats.total}</p>
</div>
<FileText className="text-gray-400" size={32} />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="bg-white rounded-lg shadow p-6"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Новые</p>
<p className="text-3xl font-bold text-yellow-600">{stats.new}</p>
</div>
<Clock className="text-yellow-400" size={32} />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="bg-white rounded-lg shadow p-6"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Одобрено</p>
<p className="text-3xl font-bold text-green-600">{stats.approved}</p>
</div>
<CheckCircle className="text-green-400" size={32} />
</div>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
className="bg-white rounded-lg shadow p-6"
>
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-gray-600">Отклонено</p>
<p className="text-3xl font-bold text-red-600">{stats.rejected}</p>
</div>
<XCircle className="text-red-400" size={32} />
</div>
</motion.div>
</div>
{/* Фильтры */}
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex flex-col md:flex-row space-y-4 md:space-y-0 md:space-x-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" size={20} />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Поиск по имени или содержимому..."
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
</div>
<div className="flex items-center space-x-2">
<Filter className="text-gray-500" size={20} />
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<option value="all">Все статусы</option>
{config.statuses.map(status => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</div>
</div>
</div>
{/* Таблица записей */}
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Пользователь
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Данные
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Статус
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Дата
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Действия
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
<AnimatePresence>
{filteredEntries.map((entry) => (
<motion.tr
key={entry.id}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="hover:bg-gray-50"
>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<div className="text-sm font-medium text-gray-900">
{entry.user.fullName}
</div>
<div className="text-sm text-gray-500">
{entry.user.email}
</div>
<div className="text-sm text-gray-500">
{entry.user.phone}
</div>
</div>
</td>
<td className="px-6 py-4">
<div className="text-sm text-gray-900 max-w-xs">
{Object.entries(entry.data).map(([key, value]) => {
if (key === 'review' || key === 'reviewDate') return null;
const field = config.fields.find(f => f.name === key);
if (!field) return null;
return (
<div key={key} className="mb-1">
<span className="font-medium">{field.label}:</span> {value}
</div>
);
})}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<select
value={entry.status}
onChange={(e) => handleStatusChange(entry.id, e.target.value)}
className={`px-3 py-1 rounded-full text-sm font-medium ${getStatusColor(entry.status)} border-0 cursor-pointer`}
>
<option value={entry.status}>{getStatusLabel(entry.status)}</option>
{getStatusOptions(entry.status).map(status => (
<option key={status} value={status}>
{getStatusLabel(status)}
</option>
))}
</select>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{format(new Date(entry.createdAt), 'dd MMM yyyy', { locale: ru })}
<br />
{format(new Date(entry.createdAt), 'HH:mm')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex justify-end space-x-2">
{config.features.rejectionReason && entry.status === 'new' && (
<button
onClick={() => {
setSelectedEntry(entry);
setShowRejectModal(true);
}}
className="text-orange-600 hover:text-orange-900"
title="Отклонить с причиной"
>
<MessageSquare size={20} />
</button>
)}
<button
onClick={() => handleDelete(entry.id)}
className="text-red-600 hover:text-red-900"
title="Удалить"
>
<Trash2 size={20} />
</button>
</div>
</td>
</motion.tr>
))}
</AnimatePresence>
</tbody>
</table>
</div>
</div>
</div>
{/* Modal отклонения */}
<AnimatePresence>
{showRejectModal && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50"
onClick={() => setShowRejectModal(false)}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
className="bg-white rounded-xl p-6 max-w-md w-full"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-xl font-bold mb-4">Отклонить запись</h3>
<textarea
value={rejectionReason}
onChange={(e) => setRejectionReason(e.target.value)}
className="w-full p-3 border border-gray-300 rounded-lg resize-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
rows="4"
placeholder="Укажите причину отклонения..."
/>
<div className="flex space-x-3 mt-4">
<button
onClick={handleReject}
className="flex-1 bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors"
>
Отклонить
</button>
<button
onClick={() => setShowRejectModal(false)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Отмена
</button>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
export default AdminDashboard;