import { useEffect, useRef, useState } from 'react'; import { readUrlState, readUrlValues, 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 { GROUP_SORT_OPTIONS, GROUP_TABLE_COLUMNS, SUBGROUP_COUNT_OPTIONS, archiveGroup, assignmentCalendarTitle, assignmentSubjectsGroups, calendarOptionDescription, calendarOptionLabel, createGroup, deleteCalendarAssignment, deleteSubgroup, departmentLabel, departmentOptionLabel, efFilterLabel, fetchAcademicYears, fetchCalendarAssignments, fetchCalendarsOptions, fetchDepartments, fetchEducationForms, fetchGroupsPage, fetchProfilesForSpecialties, fetchSpecialties, fetchSubgroups, groupsEmptyMessage, groupsUrlState, groupPayloadFromFields, initialGroupsState, restoreGroup, saveCalendarAssignment, sortSubgroups, specialityLabel, specialityOptionLabel, statusBadgeClass, statusLabel, subjectLabel, suggestedSubgroupCapacities, syncSubgroups, updateGroup, validateSubgroupCapacities } from '../groups-service.js'; const SEARCH_DEBOUNCE_MS = 320; export function GroupsTab() { const [educationForms, setEducationForms] = useState([]); const [departments, setDepartments] = useState([]); const [specialties, setSpecialties] = useState([]); const [academicYears, setAcademicYears] = useState([]); const [profilesBySpecialty, setProfilesBySpecialty] = useState(() => new Map()); const [referenceError, setReferenceError] = useState(''); const [state, setState] = useState(() => initialGroupsState( typeof window === 'undefined' ? {} : { ...readUrlState({ groupQuery: '', groupPage: '0', groupSize: '25', groupSort: 'name,asc' }), groupEducationForm: readUrlValues('groupEducationForm') } )); const [searchInput, setSearchInput] = useState(() => state.query); const [pageData, setPageData] = useState(null); const [tableError, setTableError] = useState(''); const [tableLoading, setTableLoading] = useState(true); const [createForm, setCreateForm] = useState({ name: '', groupSize: '', educationFormId: '', departmentId: '', yearStartStudy: '', specialtyId: '', specialtyProfileId: '' }); const [createAlert, setCreateAlert] = useState({ text: '', isError: false }); const [editModal, setEditModal] = useState(null); const [subgroupsModal, setSubgroupsModal] = useState(null); const [calendarModal, setCalendarModal] = 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; const groupsRef = useRef([]); groupsRef.current = pageData?.items || []; const capacityInputRefs = useRef(new Map()); useEffect(() => { let active = true; (async () => { try { const [forms, loadedDepartments, loadedSpecialties, years] = await Promise.all([ fetchEducationForms(), fetchDepartments(), fetchSpecialties(), fetchAcademicYears() ]); const profiles = await fetchProfilesForSpecialties(loadedSpecialties); if (!active) return; setEducationForms(forms || []); setDepartments(loadedDepartments || []); setSpecialties(loadedSpecialties || []); setAcademicYears(years || []); setProfilesBySpecialty(profiles); setCreateForm(current => ( current.educationFormId ? current : { ...current, educationFormId: String((forms || [])[0]?.id || '') } )); await loadGroups(stateRef.current); } catch (error) { if (active) setReferenceError(error.message || 'Не удалось загрузить справочники'); } })(); return () => { active = false; requestNumber.current += 1; }; }, []); useEffect(() => () => { window.clearTimeout(searchTimer.current); }, []); async function loadGroups(nextState) { const currentRequest = requestNumber.current + 1; requestNumber.current = currentRequest; setTableLoading(true); setTableError(''); try { let page = await fetchGroupsPage(nextState); if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) { nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) }; page = await fetchGroupsPage(nextState); } if (requestNumber.current !== currentRequest) return; setState(nextState); setPageData(page); setTableLoading(false); writeUrlState(groupsUrlState(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) { void loadGroups({ ...stateRef.current, ...patch }); } function handleSearchInput(value) { setSearchInput(value); window.clearTimeout(searchTimer.current); searchTimer.current = window.setTimeout(() => { applyState({ query: value.trim(), page: 0 }); }, SEARCH_DEBOUNCE_MS); } function resetFilters() { setSearchInput(''); void loadGroups({ ...stateRef.current, query: '', page: 0, sort: 'name,asc', educationFormIds: [] }); } function updateCreateForm(patch) { setCreateForm(current => ({ ...current, ...patch })); } async function submitCreate(event) { event.preventDefault(); const { payload, error } = groupPayloadFromFields(createForm); if (error) { setCreateAlert({ text: error, isError: true }); return; } try { const created = await createGroup(payload); setCreateAlert({ text: `Группа «${created.name || payload.name}» создана`, isError: false }); setCreateForm({ name: '', groupSize: '', educationFormId: String((educationForms || [])[0]?.id || ''), departmentId: '', yearStartStudy: '', specialtyId: '', specialtyProfileId: '' }); await loadGroups(stateRef.current); } catch (err) { setCreateAlert({ text: err.message || 'Ошибка создания', isError: true }); } } function openEditModal(group) { setEditModal({ group, form: { name: group.name || '', groupSize: group.groupSize ? String(group.groupSize) : '', educationFormId: group.educationFormId ? String(group.educationFormId) : '', departmentId: group.departmentId ? String(group.departmentId) : '', yearStartStudy: group.yearStartStudy ? String(group.yearStartStudy) : '', specialtyId: String(group.specialtyId || group.specialityCode || ''), specialtyProfileId: group.specialtyProfileId ? String(group.specialtyProfileId) : '' }, alert: { text: '', isError: false } }); } async function submitEdit(event) { event.preventDefault(); const modal = editModal; const { payload, error } = groupPayloadFromFields(modal.form); if (error) { setEditModal({ ...modal, alert: { text: error, isError: true } }); return; } try { const updated = await updateGroup(modal.group.id, payload); setEditModal(null); setCreateAlert({ text: `Группа «${updated.name || payload.name}» обновлена`, isError: false }); await loadGroups(stateRef.current); } catch (err) { setEditModal({ ...modal, alert: { text: err.message || 'Ошибка обновления', isError: true } }); } } function confirmArchiveGroup(group) { setConfirmState({ title: 'Архивировать группу?', message: 'Группа исчезнет из рабочих списков, но история расписания сохранится.', confirmLabel: 'Архивировать', danger: true, action: async () => { try { await archiveGroup(group.id); await loadGroups(stateRef.current); } catch (error) { setAlertState({ title: 'Не удалось архивировать группу', message: error.message || 'Ошибка архивирования' }); } } }); } async function restore(group) { try { await restoreGroup(group.id); await loadGroups(stateRef.current); } catch (error) { setAlertState({ title: 'Не удалось восстановить группу', message: error.message || 'Ошибка восстановления' }); } } // --- Подгруппы --- async function openSubgroupsModal(group) { setSubgroupsModal({ group, subgroups: null, count: '0', capacities: [], alert: { text: '', isError: false } }); try { const loaded = await fetchSubgroups(group.id); setSubgroupsModal(current => (current ? { ...current, ...subgroupFormState(loaded || [], group) } : current)); } catch (error) { setSubgroupsModal(current => ( current ? { ...current, alert: { text: error.message || 'Не удалось загрузить подгруппы', isError: true } } : current )); } } function subgroupFormState(subgroups, group) { const count = [2, 3].includes(subgroups.length) ? String(subgroups.length) : '0'; return { subgroups, count, capacities: capacityFields(Number(count), subgroups.map(item => item.studentCapacity), group), alert: { text: '', isError: false } }; } function capacityFields(count, existingCapacities, group) { if (!count) return []; const suggested = suggestedSubgroupCapacities(Number(group?.groupSize || 0), count); return Array.from({ length: count }, (_, index) => { const value = existingCapacities[index] ?? suggested[index] ?? ''; return value === '' || value === null ? '' : String(value); }); } function changeSubgroupCount(count) { setSubgroupsModal(current => { if (!current) return current; return { ...current, count, capacities: capacityFields(Number(count), [], current.group) }; }); } function changeSubgroupCapacity(index, value) { setSubgroupsModal(current => { if (!current) return current; const capacities = [...current.capacities]; capacities[index] = value; return { ...current, capacities }; }); } async function submitSubgroups(event) { event.preventDefault(); const modal = subgroupsModal; const group = modal.group; const existing = sortSubgroups(modal.subgroups || []); const count = Number(modal.count); if (count === 0) { try { for (const subgroup of existing) { await deleteSubgroup(group.id, subgroup.id); } const loaded = await fetchSubgroups(group.id); setSubgroupsModal(current => ( current ? { ...current, ...subgroupFormState(loaded || [], group), alert: { text: 'Деление на подгруппы отключено', isError: false } } : current )); } catch (error) { setSubgroupsModal(current => ( current ? { ...current, alert: { text: error.message || 'Ошибка отключения подгрупп', isError: true } } : current )); } return; } const capacities = modal.capacities.map(Number); const validationError = validateSubgroupCapacities(capacities, Number(group.groupSize), count); if (validationError) { setSubgroupsModal({ ...modal, alert: { text: validationError, isError: true } }); return; } try { await syncSubgroups(group.id, existing, capacities); const loaded = await fetchSubgroups(group.id); setSubgroupsModal(current => ( current ? { ...current, ...subgroupFormState(loaded || [], group), alert: { text: 'Подгруппы сохранены', isError: false } } : current )); } catch (error) { setSubgroupsModal(current => ( current ? { ...current, alert: { text: error.message || 'Ошибка сохранения подгруппы', isError: true } } : current )); } } function focusSubgroupInput(subgroupId) { const modal = subgroupsModal; if (!modal) return; const sorted = sortSubgroups(modal.subgroups || []); const index = sorted.findIndex(item => item.id === subgroupId); capacityInputRefs.current.get(index)?.focus(); } function confirmDeleteSubgroup(subgroup) { const modal = subgroupsModal; if (!modal) return; if (sortSubgroups(modal.subgroups || []).length !== 1) { setSubgroupsModal({ ...modal, alert: { text: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»', isError: true } }); return; } setConfirmState({ title: 'Удалить подгруппу?', message: 'Удаление возможно только если подгруппа не используется в расписании.', confirmLabel: 'Удалить', danger: true, action: async () => { try { await deleteSubgroup(modal.group.id, subgroup.id); const loaded = await fetchSubgroups(modal.group.id); setSubgroupsModal(current => ( current ? { ...current, ...subgroupFormState(loaded || [], current.group), alert: { text: 'Подгруппа удалена', isError: false } } : current )); } catch (error) { setSubgroupsModal(current => ( current ? { ...current, alert: { text: error.message || 'Ошибка удаления подгруппы', isError: true } } : current )); } } }); } // --- Календарные графики --- async function openCalendarModal(group) { setCalendarModal({ group, yearId: academicYears.length ? String(academicYears[0].id) : '', selectedCalendar: null, assignments: null, alert: { text: '', isError: false } }); await reloadAssignments(group.id); } async function reloadAssignments(groupId) { try { const assignments = await fetchCalendarAssignments(groupId); setCalendarModal(current => (current ? { ...current, assignments: assignments || [] } : current)); } catch (error) { setCalendarModal(current => ( current ? { ...current, assignments: [], alert: { text: error.message || 'Не удалось загрузить назначения графиков', isError: true } } : current )); } } function changeCalendarYear(yearId) { setCalendarModal(current => (current ? { ...current, yearId, selectedCalendar: null } : current)); } async function searchCalendars(query, selectedId) { const modal = calendarModal; if (!modal?.group || !modal.yearId) return []; const group = modal.group; return fetchCalendarsOptions({ query, academicYearId: modal.yearId, specialtyId: String(group.specialtyId || group.specialityCode), profileId: String(group.specialtyProfileId), studyFormId: String(group.educationFormId), selectedId }); } async function submitAssignment(event) { event.preventDefault(); const modal = calendarModal; if (!modal.yearId || !modal.selectedCalendar) { setCalendarModal({ ...modal, alert: { text: 'Выберите учебный год и график', isError: true } }); return; } try { await saveCalendarAssignment(modal.group.id, { academicYearId: modal.yearId, calendarId: modal.selectedCalendar.id }); setCalendarModal(current => ( current ? { ...current, alert: { text: 'Календарный график назначен', isError: false } } : current )); await reloadAssignments(modal.group.id); } catch (error) { setCalendarModal(current => ( current ? { ...current, alert: { text: error.message || 'Ошибка назначения графика', isError: true } } : current )); } } function confirmDeleteAssignment(assignment) { const modal = calendarModal; if (!modal) return; setConfirmState({ title: 'Удалить назначение графика?', message: 'Группа потеряет связь с этим графиком на выбранный учебный год.', confirmLabel: 'Удалить назначение', danger: true, action: async () => { try { await deleteCalendarAssignment(modal.group.id, assignment.id); setCalendarModal(current => ( current ? { ...current, alert: { text: 'Назначение удалено', isError: false } } : current )); await reloadAssignments(modal.group.id); } catch (error) { setCalendarModal(current => ( current ? { ...current, alert: { text: error.message || 'Ошибка удаления назначения', isError: true } } : current )); } } }); } async function runConfirm() { const confirm = confirmState; setConfirmState(null); await confirm.action(); } const groups = pageData?.items || []; const createProfileOptions = profilesBySpecialty.get(String(createForm.specialtyId)) || []; return ( <>

Новая группа

updateCreateForm({ name: event.target.value })} />
updateCreateForm({ groupSize: event.target.value })} />
updateCreateForm({ yearStartStudy: event.target.value })} />
{createAlert.text}

Все группы

applyState({ educationFormIds: ids, page: 0 })} />
handleSearchInput(event.target.value)} />
{referenceError ? ( ) : tableError ? ( ) : tableLoading ? ( ) : groups.length === 0 ? ( ) : groups.map(group => ( ))}
ID Название Численность (чел.) Форма обучения Кафедра Курс Специальность Профиль Статус Действия
Ошибка загрузки данных: {referenceError}
{tableError}
{groupsEmptyMessage(state)}
{group.id} {group.name} {group.groupSize} {group.educationFormName} {departmentLabel(departments, group.departmentId)} {group.course || '-'} {specialityLabel(specialties, group.specialtyId || group.specialityCode)} {group.specialtyProfileName || '-'} {statusLabel(group)}
{group.status === 'ARCHIVED' ? ( ) : ( )}
{pageData && ( applyState({ page })} onSize={size => applyState({ size, page: 0 })} /> )}
{editModal && (
{ if (event.target === event.currentTarget) setEditModal(null); }} >

Редактировать группу

setEditModal(current => ( current ? { ...current, form: { ...current.form, name: event.target.value } } : current ))} />
setEditModal(current => ( current ? { ...current, form: { ...current.form, groupSize: event.target.value } } : current ))} />
setEditModal(current => ( current ? { ...current, form: { ...current.form, yearStartStudy: event.target.value } } : current ))} />
{editModal.alert.text}
)} {subgroupsModal && (
{ if (event.target === event.currentTarget) setSubgroupsModal(null); }} >

Подгруппы группы

Группа: {subgroupsModal.group.name} ({subgroupsModal.group.groupSize} чел.)

Настройка подгрупп

{subgroupsModal.capacities.map((value, index) => (
{ if (element) capacityInputRefs.current.set(index, element); else capacityInputRefs.current.delete(index); }} onChange={event => changeSubgroupCapacity(index, event.target.value)} />
))}
{subgroupsModal.alert.text}
{subgroupsModal.subgroups === null ? ( ) : sortSubgroups(subgroupsModal.subgroups).length === 0 ? ( ) : (() => { const list = sortSubgroups(subgroupsModal.subgroups); const canDeleteSingleSubgroup = list.length === 1; const deleteTitle = canDeleteSingleSubgroup ? 'Удалить подгруппу' : 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»'; return list.map(sub => ( )); })()}
Подгруппа Численность Действия
Загрузка подгрупп...
Подгруппы не созданы
{sub.name} {sub.studentCapacity ?? '-'} чел.
)} {calendarModal && (
{ if (event.target === event.currentTarget) setCalendarModal(null); }} >

Календарные графики группы

Группа: {calendarModal.group.name} ({specialityLabel(specialties, calendarModal.group.specialtyId || calendarModal.group.specialityCode)})

Назначить график

searchCalendars(query, selectedId)} optionLabel={calendarOptionLabel} optionDescription={calendarOptionDescription} onSelect={calendar => setCalendarModal(current => ( current ? { ...current, selectedCalendar: calendar } : current ))} selectedLabel={calendarModal.selectedCalendar ? calendarOptionLabel(calendarModal.selectedCalendar) : ''} selectedValue={calendarModal.selectedCalendar ? String(calendarModal.selectedCalendar.id) : ''} />
{calendarModal.alert.text}
{calendarModal.assignments === null ? ( ) : calendarModal.assignments.length === 0 ? ( ) : calendarModal.assignments.map(assignment => ( ))}
Учебный год График Дисциплины Действия
Загрузка...
Календарные графики не назначены
{assignment.academicYearTitle} {assignmentCalendarTitle(assignment)} {!(assignment.subjects || []).length ? ( Не привязаны ) : (
{assignmentSubjectsGroups(assignment.subjects).map(({ semesterNumber, items }) => (
{semesterNumber} семестр {items.map(subject => subjectLabel(subject)).join(', ')}
))}
)}
)} {confirmState && ( void runConfirm()} onClose={() => setConfirmState(null)} /> )} {alertState && ( setAlertState(null)} /> )} ); } function EducationFormFilter({ educationForms, selectedIds, onChange }) { const rootRef = useRef(null); const [open, setOpen] = useState(false); useEffect(() => { if (!open) return undefined; function handlePointerDown(event) { if (rootRef.current && !rootRef.current.contains(event.target)) { setOpen(false); } } document.addEventListener('mousedown', handlePointerDown); return () => document.removeEventListener('mousedown', handlePointerDown); }, [open]); const selected = new Set(selectedIds || []); const allChecked = selected.size === 0; function toggleAll() { onChange([]); } function toggleForm(id, checked) { const next = new Set(selected); if (checked) { next.add(String(id)); } else { next.delete(String(id)); } onChange([...next]); } return (
{open && (
{(educationForms || []).map(form => { const id = String(form.id); return ( ); })}
)}
); }