import { useEffect, useRef, useState } from 'react'; import { readUrlState, writeUrlState } from '../../../admin/js/url-state.js'; import { trackUxEvent } from '../../../telemetry.js'; import { AsyncCombobox } from '../../shared/ui/AsyncCombobox.jsx'; import { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx'; import { Pagination } from '../../shared/ui/Pagination.jsx'; import { SUBJECT_SORT_OPTIONS, SUBJECT_TABLE_COLUMNS, archiveSubject, assignTeacher, createSubject, departmentLabel, departmentOptionLabel, fetchDepartments, fetchSubjectsPage, fetchTeacherAssignments, groupTeachersBySubject, initialSubjectsState, removeTeacherAssignment, subjectsEmptyMessage, subjectsUrlState, teachersSearchEndpoint } from '../subjects-service.js'; import { apiClient } from '../../shared/api-client.js'; const SEARCH_DEBOUNCE_MS = 320; export function SubjectsTab() { const [departments, setDepartments] = useState(null); const [referenceError, setReferenceError] = useState(''); const [state, setState] = useState(() => initialSubjectsState( typeof window === 'undefined' ? {} : readUrlState({ subjectQuery: '', subjectDepartment: '', subjectPage: '0', subjectSize: '25', subjectSort: 'name,asc' }) )); const [searchInput, setSearchInput] = useState(() => state.query); const [pageData, setPageData] = useState(null); const [assignments, setAssignments] = useState([]); const [tableError, setTableError] = useState(''); const [tableLoading, setTableLoading] = useState(true); const [createName, setCreateName] = useState(''); const [createCode, setCreateCode] = useState(''); const [createDepartmentId, setCreateDepartmentId] = useState(''); const [createAlert, setCreateAlert] = useState({ text: '', isError: false }); const [teachersModal, setTeachersModal] = useState(null); const [confirmState, setConfirmState] = useState(null); const [alertState, setAlertState] = useState(null); const searchTimer = useRef(null); const requestNumber = useRef(0); const stateRef = useRef(state); stateRef.current = state; useEffect(() => { let active = true; (async () => { try { const loaded = await fetchDepartments(); if (!active) return; setDepartments(loaded || []); await loadSubjects(stateRef.current); } catch (error) { if (active) setReferenceError(error.message || 'Не удалось загрузить справочники'); } })(); return () => { active = false; requestNumber.current += 1; }; }, []); useEffect(() => () => { window.clearTimeout(searchTimer.current); }, []); async function loadSubjects(nextState) { const currentRequest = requestNumber.current + 1; requestNumber.current = currentRequest; setTableLoading(true); setTableError(''); try { let page = await fetchSubjectsPage(nextState); if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) { nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) }; page = await fetchSubjectsPage(nextState); } const loadedAssignments = await fetchTeacherAssignments((page.items || []).map(subject => subject.id)); if (requestNumber.current !== currentRequest) return; setState(nextState); setPageData(page); setAssignments(loadedAssignments || []); setTableLoading(false); writeUrlState(subjectsUrlState(nextState)); trackUxEvent('реестр_дисциплин_загружен', { total: Number(page.totalItems || 0), page: nextState.page }); } catch (error) { if (requestNumber.current !== currentRequest) return; setState(nextState); setTableLoading(false); setTableError(error.message || 'Не удалось загрузить дисциплины'); } } function applyState(patch) { const nextState = { ...stateRef.current, ...patch }; void loadSubjects(nextState); } function handleSearchInput(value) { setSearchInput(value); window.clearTimeout(searchTimer.current); searchTimer.current = window.setTimeout(() => { applyState({ query: value.trim(), page: 0 }); }, SEARCH_DEBOUNCE_MS); } async function submitCreate(event) { event.preventDefault(); const name = createName.trim(); const code = createCode.trim(); if (!name || !code || !createDepartmentId) { setCreateAlert({ text: 'Заполните все поля', isError: true }); return; } try { const created = await createSubject({ name, code, departmentId: createDepartmentId }); setCreateAlert({ text: `Дисциплина «${created.name || name}» создана`, isError: false }); setCreateName(''); setCreateCode(''); await loadSubjects(stateRef.current); } catch (error) { setCreateAlert({ text: error.message || 'Ошибка создания', isError: true }); } } function confirmArchiveSubject(subject) { setConfirmState({ title: 'Архивировать дисциплину?', message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.', confirmLabel: 'Архивировать', danger: true, action: async () => { try { await archiveSubject(subject.id); await loadSubjects(stateRef.current); } catch (error) { setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' }); } } }); } function openTeachersModal(subject) { setTeachersModal({ subject, alert: { text: '', isError: false }, selectedTeacher: null }); } async function reloadAssignments() { const subjectIds = (pageData?.items || []).map(subject => subject.id); const loaded = await fetchTeacherAssignments(subjectIds); setAssignments(loaded || []); return loaded || []; } async function searchTeachers(query, selectedId) { const teachers = await apiClient.get(teachersSearchEndpoint(query, selectedId)); const modalSubjectId = teachersModal?.subject?.id; const assigned = new Set( (assignments || []) .filter(item => String(item.subjectId) === String(modalSubjectId)) .map(item => String(item.userId)) ); return (teachers || []).filter(teacher => !assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId)); } async function submitTeacherAssignment(event) { event.preventDefault(); const modal = teachersModal; if (!modal?.selectedTeacher) { setTeachersModal({ ...modal, alert: { text: 'Выберите преподавателя', isError: true } }); return; } try { await assignTeacher({ userId: modal.selectedTeacher.id, subjectId: modal.subject.id }); setTeachersModal(current => ( current ? { ...current, alert: { text: 'Преподаватель успешно привязан', isError: false }, selectedTeacher: null } : current )); await reloadAssignments(); } catch (error) { setTeachersModal({ ...modal, alert: { text: error.message || 'Ошибка привязки', isError: true } }); } } function confirmDeleteAssignment(assignment) { setConfirmState({ title: 'Удалить привязку преподавателя?', message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.', confirmLabel: 'Удалить привязку', danger: true, action: async () => { try { await removeTeacherAssignment({ userId: assignment.userId, subjectId: assignment.subjectId }); await reloadAssignments(); } catch (error) { setTeachersModal(current => ( current ? { ...current, alert: { text: error.message || 'Ошибка удаления привязки', isError: true } } : current )); } } }); } async function runConfirm() { const confirm = confirmState; setConfirmState(null); await confirm.action(); } const teachersBySubject = groupTeachersBySubject(assignments); const subjects = pageData?.items || []; return ( <>

Новая дисциплина

setCreateName(event.target.value)} />
setCreateCode(event.target.value)} />
{createAlert.text}

Все дисциплины

handleSearchInput(event.target.value)} />
{referenceError ? ( ) : tableError ? ( ) : tableLoading ? ( ) : subjects.length === 0 ? ( ) : subjects.map(subject => { const teachers = teachersBySubject.get(String(subject.id)) || []; return ( ); })}
ID Название Код предмета Кафедра Преподаватели Действия
Ошибка загрузки справочников: {referenceError}
{tableError}
{subjectsEmptyMessage(state)}
{subject.id} {subject.name} {subject.code || '-'} {departmentLabel(departments || [], subject.departmentId)}
{teachers.length === 0 ? ( Нет преподавателей ) : teachers.map(teacher => ( {teacher.fullName || teacher.username} ))}
{pageData && ( applyState({ page })} onSize={size => applyState({ size, page: 0 })} /> )}
{teachersModal && (
{ if (event.target === event.currentTarget) setTeachersModal(null); }} >

Преподаватели дисциплины

{teachersModal.subject.code ? `${teachersModal.subject.code} · ` : ''}{teachersModal.subject.name}

Добавить привязку

searchTeachers(query, selectedId)} optionLabel={teacher => teacher.fullName || teacher.username} optionDescription={teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · ')} onSelect={teacher => setTeachersModal(current => ( current ? { ...current, selectedTeacher: teacher } : current ))} selectedLabel={teachersModal.selectedTeacher ? (teachersModal.selectedTeacher.fullName || teachersModal.selectedTeacher.username) : ''} selectedValue={teachersModal.selectedTeacher ? String(teachersModal.selectedTeacher.id) : ''} />
{teachersModal.alert.text}
{(teachersBySubject.get(String(teachersModal.subject.id)) || []).length === 0 ? ( ) : (teachersBySubject.get(String(teachersModal.subject.id)) || []).map(assignment => ( ))}
Преподаватель Действия
Преподаватели не привязаны
{assignment.fullName || assignment.username}{' '} ({assignment.username})
)} {confirmState && ( void runConfirm()} onClose={() => setConfirmState(null)} /> )} {alertState && ( setAlertState(null)} /> )} ); }