UNPKG

create-ex-den-kit

Version:

Create exam projects with Ex-Den-Kit

453 lines (423 loc) 17.7 kB
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;