import React from 'react' import { GetServerSideProps } from 'next' import { Button } from '@/shadcn/ui/button' import { Input } from '@/shadcn/ui/input' import { Label } from '@/shadcn/ui/label' import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/shadcn/ui/card' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/shadcn/ui/dialog' import { loadGroups, GroupsData } from '@/shared/data/groups-loader' import { loadSettings, AppSettings } from '@/shared/data/settings-loader' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shadcn/ui/select' import { ToastContainer, Toast } from '@/shared/ui/toast' import Head from 'next/head' import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from '@/shadcn/ui/accordion' import { SCHED_MODE } from '@/shared/constants/urls' type AdminPageProps = { groups: GroupsData settings: AppSettings isDefaultPassword: boolean isKspsutiMode: boolean } // Компонент Toggle Switch function ToggleSwitch({ checked, onChange, disabled }: { checked: boolean onChange: (checked: boolean) => void disabled?: boolean }) { return ( ) } // Компонент выбора курса function CourseSelect({ value, onChange, id }: { value: string onChange: (value: string) => void id: string }) { return ( ) } // Компонент для DialogFooter с кнопками function DialogFooterButtons({ onCancel, onSubmit, submitLabel, loading, submitVariant = 'default' }: { onCancel: () => void onSubmit?: () => void submitLabel: string loading?: boolean submitVariant?: 'default' | 'destructive' }) { return ( {onSubmit && ( )} ) } export default function AdminPage({ groups: initialGroups, settings: initialSettings, isDefaultPassword: initialIsDefaultPassword, isKspsutiMode }: AdminPageProps) { const [authenticated, setAuthenticated] = React.useState(null) const [password, setPassword] = React.useState('') const [loading, setLoading] = React.useState(false) const [error, setError] = React.useState(null) const [groups, setGroups] = React.useState(initialGroups) const [settings, setSettings] = React.useState(initialSettings) const [editingGroup, setEditingGroup] = React.useState<{ id: string; parseId: number; name: string; course: number } | null>(null) const [showAddDialog, setShowAddDialog] = React.useState(false) const [showEditDialog, setShowEditDialog] = React.useState(false) const [showDeleteDialog, setShowDeleteDialog] = React.useState(false) const [showLogsDialog, setShowLogsDialog] = React.useState(false) const [logs, setLogs] = React.useState('') const [logsLoading, setLogsLoading] = React.useState(false) const [groupToDelete, setGroupToDelete] = React.useState(null) const [toasts, setToasts] = React.useState([]) const [showChangePasswordDialog, setShowChangePasswordDialog] = React.useState(false) const [isDefaultPassword, setIsDefaultPassword] = React.useState(initialIsDefaultPassword) const [passwordFormData, setPasswordFormData] = React.useState({ oldPassword: '', newPassword: '', confirmPassword: '' }) const [showVacationModeEditDialog, setShowVacationModeEditDialog] = React.useState(false) const [vacationModeContent, setVacationModeContent] = React.useState(settings.vacationModeContent || '') const showToast = (message: string, type: 'success' | 'error' = 'success') => { const id = Date.now().toString() setToasts((prev) => [...prev, { id, message, type }]) } const removeToast = (id: string) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)) } // Форма добавления/редактирования const [formData, setFormData] = React.useState({ id: '', parseId: '', name: '', course: '1' }) // Проверка авторизации при загрузке React.useEffect(() => { checkAuth() }, []) const checkAuth = async () => { try { const res = await fetch('/api/admin/check-auth') const data = await res.json() setAuthenticated(data.authenticated) } catch (err) { setAuthenticated(false) } } const handleLogin = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setError(null) try { const res = await fetch('/api/admin/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }) }) const data = await res.json() if (res.ok && data.success) { setAuthenticated(true) setPassword('') // Обновляем список групп и настроек после авторизации await loadGroupsList() await loadSettingsList() } else { setError(data.error || 'Ошибка авторизации') } } catch (err) { setError('Ошибка соединения с сервером') } finally { setLoading(false) } } const loadData = async (endpoint: string, setter: (data: T) => void) => { try { const res = await fetch(endpoint) const data = await res.json() if (data.groups) { setter(data.groups as T) } else if (data.settings) { setter(data.settings as T) } } catch (err) { console.error(`Error loading data from ${endpoint}:`, err) } } const loadGroupsList = () => loadData('/api/admin/groups', setGroups) const loadSettingsList = () => loadData('/api/admin/settings', setSettings) const handleUpdateSettings = async (newSettings: AppSettings) => { // Сохраняем предыдущее состояние для отката const previousSettings = settings // Оптимистичное обновление UI setSettings(newSettings) setError(null) try { const res = await fetch('/api/admin/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(newSettings) }) const data = await res.json() if (res.ok && data.success) { // Обновляем состояние из ответа сервера setSettings(data.settings) showToast('Настройки успешно обновлены', 'success') } else { // Откат изменений при ошибке setSettings(previousSettings) const errorMessage = data.error || 'Ошибка при обновлении настроек' setError(errorMessage) showToast(errorMessage, 'error') } } catch (err) { // Откат изменений при ошибке setSettings(previousSettings) const errorMessage = 'Ошибка соединения с сервером' setError(errorMessage) showToast(errorMessage, 'error') } } const handleAddGroup = async (e: React.FormEvent) => { e.preventDefault() setLoading(true) setError(null) try { const res = await fetch('/api/admin/groups', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: formData.id, parseId: parseInt(formData.parseId, 10), name: formData.name, course: parseInt(formData.course, 10) }) }) const data = await res.json() if (res.ok && data.success) { setGroups(data.groups) setShowAddDialog(false) setFormData({ id: '', parseId: '', name: '', course: '1' }) showToast('Группа успешно добавлена', 'success') } else { const errorMessage = data.error || 'Ошибка при добавлении группы' setError(errorMessage) showToast(errorMessage, 'error') } } catch (err) { const errorMessage = 'Ошибка соединения с сервером' setError(errorMessage) showToast(errorMessage, 'error') } finally { setLoading(false) } } const handleEditGroup = async (e: React.FormEvent) => { e.preventDefault() if (!editingGroup) return setLoading(true) setError(null) try { const res = await fetch(`/api/admin/groups/${editingGroup.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ parseId: parseInt(formData.parseId, 10), name: formData.name, course: parseInt(formData.course, 10) }) }) const data = await res.json() if (res.ok && data.success) { setGroups(data.groups) setShowEditDialog(false) setEditingGroup(null) setFormData({ id: '', parseId: '', name: '', course: '1' }) showToast('Группа успешно обновлена', 'success') } else { const errorMessage = data.error || 'Ошибка при редактировании группы' setError(errorMessage) showToast(errorMessage, 'error') } } catch (err) { const errorMessage = 'Ошибка соединения с сервером' setError(errorMessage) showToast(errorMessage, 'error') } finally { setLoading(false) } } const handleDeleteGroup = async () => { if (!groupToDelete) return setLoading(true) setError(null) try { const res = await fetch(`/api/admin/groups/${groupToDelete}`, { method: 'DELETE' }) const data = await res.json() if (res.ok && data.success) { setGroups(data.groups) setShowDeleteDialog(false) setGroupToDelete(null) showToast('Группа успешно удалена', 'success') } else { const errorMessage = data.error || 'Ошибка при удалении группы' setError(errorMessage) showToast(errorMessage, 'error') } } catch (err) { const errorMessage = 'Ошибка соединения с сервером' setError(errorMessage) showToast(errorMessage, 'error') } finally { setLoading(false) } } const openEditDialog = (id: string) => { const group = groups[id] if (group) { setEditingGroup({ id, parseId: group.parseId, name: group.name, course: group.course }) setFormData({ id, parseId: group.parseId.toString(), name: group.name, course: group.course.toString() }) setShowEditDialog(true) } } const openDeleteDialog = (id: string) => { setGroupToDelete(id) setShowDeleteDialog(true) } const loadLogs = async () => { setLogsLoading(true) try { const res = await fetch('/api/admin/logs') const data = await res.json() if (data.success) { setLogs(data.logs ?? '') } else { setLogs(data.error || 'Не удалось загрузить логи') } } catch (err) { setLogs('Ошибка при загрузке логов') console.error('Error loading logs:', err) } finally { setLogsLoading(false) } } const handleOpenLogsDialog = () => { setShowLogsDialog(true) loadLogs() } if (authenticated === null) { return (
Загрузка...
) } if (!authenticated) { return ( <> Админ-панель — Авторизация
Авторизация Введите пароль для доступа к админ-панели
{error && (
{error}
)}
setPassword(e.target.value)} disabled={loading} required autoFocus />
) } return ( <> Админ-панель — Управление группами

Админ-панель

{error && (
{error}
)} {isDefaultPassword && ( Внимание: используется стандартный пароль Для безопасности рекомендуется сменить пароль на более надежный )} Безопасность Управление паролем администратора Настройки Управление настройками приложения
Навигация по неделям
Включить или выключить навигацию по неделям в расписании
handleUpdateSettings({ ...settings, weekNavigationEnabled: checked })} disabled={loading} />
{!isKspsutiMode && (
Кнопка "Добавить группу"
Отображать кнопку "Добавить группу" на главной странице
handleUpdateSettings({ ...settings, showAddGroupButton: checked })} disabled={loading} />
)}
Кнопка "Преподаватели"
Отображать кнопку перехода к расписанию преподавателей на главной странице
handleUpdateSettings({ ...settings, showTeachersButton: checked })} disabled={loading} />
Режим "Каникулы"
Включить режим каникул (заменяет главную страницу)
handleUpdateSettings({ ...settings, vacationModeEnabled: checked })} disabled={loading} />
Редактировать текст каникул
Настроить текст, отображаемый в режиме каникул
Группы Управление группами для расписания
{!isKspsutiMode && ( )}
{Object.keys(groups).length === 0 ? (

Группы не найдены

) : (
{Object.entries(groups).map(([id, group]) => (
{group.name}
ID: {id} | Parse ID: {group.parseId} | Курс: {group.course}
{isKspsutiMode && (
Группа получена автоматически с lk.ks.psuti.ru. Редактирование отключено.
)}
{!isKspsutiMode && (
)}
))}
)}
{process.env.NODE_ENV === 'development' && ( Debug опции
Принудительно использовать кэш
Принудительно использовать кэш, даже если он свежий (симулирует ошибку парсинга)
handleUpdateSettings({ ...settings, debug: { ...settings.debug, forceCache: checked } })} disabled={loading} />
Принудительно показать пустое расписание
Показать пустое расписание независимо от реальных данных
handleUpdateSettings({ ...settings, debug: { ...settings.debug, forceEmpty: checked } })} disabled={loading} />
Принудительно показать ошибку
Показать страницу ошибки независимо от реальных данных
handleUpdateSettings({ ...settings, debug: { ...settings.debug, forceError: checked } })} disabled={loading} />
Принудительно симулировать таймаут
Симулировать таймаут при загрузке расписания
handleUpdateSettings({ ...settings, debug: { ...settings.debug, forceTimeout: checked } })} disabled={loading} />
Показать информацию о кэше
Показать дополнительную информацию о кэше в интерфейсе
handleUpdateSettings({ ...settings, debug: { ...settings.debug, showCacheInfo: checked } })} disabled={loading} />
)}
{/* Диалог добавления группы */} Добавить группу Заполните данные для новой группы
setFormData({ ...formData, id: e.target.value })} placeholder="ib4k" required pattern="[a-z0-9_-]+" />

Только строчные буквы, цифры, дефисы и подчеркивания

setFormData({ ...formData, parseId: e.target.value })} placeholder="138" required />
setFormData({ ...formData, name: e.target.value })} placeholder="ИБ-4к" required />
setFormData({ ...formData, course: value })} id="add-course" />
{/* Диалог редактирования группы */} Редактировать группу Измените данные группы

ID группы нельзя изменить

setFormData({ ...formData, parseId: e.target.value })} required />
setFormData({ ...formData, name: e.target.value })} required />
setFormData({ ...formData, course: value })} id="edit-course" />
{/* Диалог удаления группы */} Удалить группу? Вы уверены, что хотите удалить группу "{groupToDelete && groups[groupToDelete]?.name}"? Это действие нельзя отменить. setShowDeleteDialog(false)} onSubmit={handleDeleteGroup} submitLabel="Удалить" loading={loading} submitVariant="destructive" /> {/* Диалог просмотра логов */} Логи ошибок Ошибки парсинга записываются в error.log. Если записей пока нет — здесь будет пусто.
{logsLoading ? (
Загрузка логов...
) : (
                  {logs || 'Логи пусты'}
                
)}
{/* Диалог смены пароля */} Сменить пароль Введите старый пароль и новый пароль (минимум 8 символов)
{ e.preventDefault() setLoading(true) setError(null) // Валидация на клиенте if (passwordFormData.newPassword.length < 8) { setError('Новый пароль должен содержать минимум 8 символов') setLoading(false) return } if (passwordFormData.newPassword !== passwordFormData.confirmPassword) { setError('Новые пароли не совпадают') setLoading(false) return } try { const res = await fetch('/api/admin/change-password', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ oldPassword: passwordFormData.oldPassword, newPassword: passwordFormData.newPassword }) }) const data = await res.json() if (res.ok && data.success) { setShowChangePasswordDialog(false) setPasswordFormData({ oldPassword: '', newPassword: '', confirmPassword: '' }) setIsDefaultPassword(false) // После смены пароля он больше не дефолтный showToast('Пароль успешно изменен', 'success') } else { const errorMessage = data.error || 'Ошибка при смене пароля' setError(errorMessage) showToast(errorMessage, 'error') } } catch (err) { const errorMessage = 'Ошибка соединения с сервером' setError(errorMessage) showToast(errorMessage, 'error') } finally { setLoading(false) } }} >
setPasswordFormData({ ...passwordFormData, oldPassword: e.target.value })} required autoFocus />
setPasswordFormData({ ...passwordFormData, newPassword: e.target.value })} required minLength={8} />

Минимум 8 символов

setPasswordFormData({ ...passwordFormData, confirmPassword: e.target.value })} required minLength={8} />
{/* Диалог редактирования текста каникул */} Редактирование текста режима Каникулы Отредактируйте текст, который будет отображаться в режиме каникул. Поддерживается форматирование Markdown.