Files
magistr/frontend/react/admin/tabs/GroupsTab.jsx
dipatrik10 97bf3cc50a feat: завершить миграцию фронтенда на React (этапы 1-5)
Админка, настройки, логин, кабинеты преподавателя и студента
полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md).

- 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой
- Удалены legacy-views и переходный слой LegacyTabView (45 файлов)
- Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки
- CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf)
- Фиксы: lazy-импорт именованных экспортов (краш после авторизации),
  растяжение #admin-root/#settings-root на всю ширину,
  возврат subjects/lessonTypes из справочников (краш просмотра расписаний),
  отказ от заведомо широкого поиска расписания без выбранной цели,
  центрирование карточки логина
- Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину
- Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
2026-09-28 23:15:04 +03:00

1280 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<>
<div className="card create-card">
<h2>Новая группа</h2>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-group-name">Название группы</label>
<input
type="text"
id="new-group-name"
placeholder="ИВТ-21-1"
required
value={createForm.name}
onChange={event => updateCreateForm({ name: event.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="new-group-size">Численность группы</label>
<input
type="number"
id="new-group-size"
min="1"
placeholder="20"
required
value={createForm.groupSize}
onChange={event => updateCreateForm({ groupSize: event.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="new-group-ef">Форма обучения</label>
<select
id="new-group-ef"
value={createForm.educationFormId}
onChange={event => updateCreateForm({ educationFormId: event.target.value })}
>
{(educationForms || []).map(form => (
<option key={form.id} value={form.id}>{form.name}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="new-group-department">Кафедра</label>
<select
id="new-group-department"
value={createForm.departmentId}
onChange={event => updateCreateForm({ departmentId: event.target.value })}
>
<option value="">Выберите кафедру</option>
{(departments || []).map(department => (
<option key={department.id} value={department.id}>
{departmentOptionLabel(department)}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="new-group-yearStartStudy">Год начала обучения</label>
<input
type="number"
id="new-group-yearStartStudy"
min="2000"
max="2100"
placeholder="2026"
required
value={createForm.yearStartStudy}
onChange={event => updateCreateForm({ yearStartStudy: event.target.value })}
/>
</div>
<div className="form-group">
<label htmlFor="new-group-speciality-code">Специальность</label>
<select
id="new-group-speciality-code"
value={createForm.specialtyId}
onChange={event => updateCreateForm({ specialtyId: event.target.value, specialtyProfileId: '' })}
>
<option value="">Выберите специальность</option>
{(specialties || []).map(speciality => (
<option key={speciality.id} value={speciality.id}>
{specialityLabel(specialties, speciality.id)}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="new-group-profile">Профиль обучения</label>
<select
id="new-group-profile"
value={createForm.specialtyProfileId}
onChange={event => updateCreateForm({ specialtyProfileId: event.target.value })}
>
{createProfileOptions.length ? (
<>
<option value="">Выберите профиль</option>
{createProfileOptions.map(profile => (
<option key={profile.id} value={profile.id}>{profile.name}</option>
))}
</>
) : (
<option value="">Нет профилей</option>
)}
</select>
</div>
<button type="submit" className="btn btn-md btn-primary">Создать</button>
</div>
<div
className={`form-alert${createAlert.text ? (createAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{createAlert.text}
</div>
</form>
</div>
<div className="card">
<div className="card-header-row">
<h2>Все группы</h2>
<div className="filter-row">
<label htmlFor="filter-ef-box">Фильтр:</label>
<EducationFormFilter
educationForms={educationForms}
selectedIds={state.educationFormIds}
onChange={ids => applyState({ educationFormIds: ids, page: 0 })}
/>
</div>
</div>
<div className="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="groups-search">Поиск</label>
<input
type="search"
id="groups-search"
placeholder="Название, код специальности, профиль"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="groups-sort">Сортировка</label>
<select
id="groups-sort"
value={state.sort}
onChange={event => applyState({ sort: event.target.value, page: 0 })}
>
{GROUP_SORT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<button type="button" className="btn btn-md btn-ghost" onClick={resetFilters}>Сбросить фильтры</button>
</div>
<div className="table-wrap">
<table id="groups-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Численность (чел.)</th>
<th>Форма обучения</th>
<th>Кафедра</th>
<th>Курс</th>
<th>Специальность</th>
<th>Профиль</th>
<th>Статус</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{referenceError ? (
<tr><td colSpan={GROUP_TABLE_COLUMNS} className="loading-row">Ошибка загрузки данных: {referenceError}</td></tr>
) : tableError ? (
<tr>
<td colSpan={GROUP_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadGroups(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr><td colSpan={GROUP_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка групп...</td></tr>
) : groups.length === 0 ? (
<tr><td colSpan={GROUP_TABLE_COLUMNS} className="loading-row view-state-empty">{groupsEmptyMessage(state)}</td></tr>
) : groups.map(group => (
<tr key={group.id}>
<td>{group.id}</td>
<td><strong>{group.name}</strong></td>
<td>{group.groupSize}</td>
<td><span className="badge badge-ef">{group.educationFormName}</span></td>
<td>{departmentLabel(departments, group.departmentId)}</td>
<td>{group.course || '-'}</td>
<td>{specialityLabel(specialties, group.specialtyId || group.specialityCode)}</td>
<td>{group.specialtyProfileName || '-'}</td>
<td><span className={`badge ${statusBadgeClass(group)}`}>{statusLabel(group)}</span></td>
<td className="group-actions-cell">
<div className="group-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-group group-action-btn"
onClick={() => openEditModal(group)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn"
onClick={() => void openSubgroupsModal(group)}
>
Подгруппы
</button>
<button
type="button"
className="btn btn-sm btn-secondary btn-manage-calendar group-action-btn"
onClick={() => void openCalendarModal(group)}
>
Календарь
</button>
{group.status === 'ARCHIVED' ? (
<button
type="button"
className="btn btn-sm btn-secondary btn-restore-group group-action-btn"
onClick={() => void restore(group)}
>
Восстановить
</button>
) : (
<button
type="button"
className="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn"
onClick={() => confirmArchiveGroup(group)}
>
Архив
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{pageData && (
<Pagination
page={pageData.page}
size={pageData.size}
totalItems={pageData.totalItems}
totalPages={pageData.totalPages}
label="групп"
onPage={page => applyState({ page })}
onSize={size => applyState({ size, page: 0 })}
/>
)}
</div>
{editModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setEditModal(null);
}}
>
<div className="modal-content card edit-group-modal">
<h2>Редактировать группу</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close"
aria-label="Закрыть окно"
onClick={() => setEditModal(null)}
>
×
</button>
<form onSubmit={submitEdit}>
<div className="form-row edit-group-form-row edit-group-form-row-main">
<div className="form-group">
<label htmlFor="edit-group-name">Название группы</label>
<input
type="text"
id="edit-group-name"
required
value={editModal.form.name}
onChange={event => setEditModal(current => (
current ? { ...current, form: { ...current.form, name: event.target.value } } : current
))}
/>
</div>
<div className="form-group">
<label htmlFor="edit-group-size">Численность группы</label>
<input
type="number"
id="edit-group-size"
min="1"
required
value={editModal.form.groupSize}
onChange={event => setEditModal(current => (
current ? { ...current, form: { ...current.form, groupSize: event.target.value } } : current
))}
/>
</div>
<div className="form-group">
<label htmlFor="edit-group-ef">Форма обучения</label>
<select
id="edit-group-ef"
value={editModal.form.educationFormId}
onChange={event => setEditModal(current => (
current ? { ...current, form: { ...current.form, educationFormId: event.target.value } } : current
))}
>
{(educationForms || []).map(form => (
<option key={form.id} value={form.id}>{form.name}</option>
))}
</select>
</div>
</div>
<div className="form-row edit-group-form-row edit-group-form-row-details">
<div className="form-group">
<label htmlFor="edit-group-department">Кафедра</label>
<select
id="edit-group-department"
value={editModal.form.departmentId}
onChange={event => setEditModal(current => (
current ? { ...current, form: { ...current.form, departmentId: event.target.value } } : current
))}
>
<option value="">Выберите кафедру</option>
{(departments || []).map(department => (
<option key={department.id} value={department.id}>
{departmentOptionLabel(department)}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="edit-group-yearStartStudy">Год начала обучения</label>
<input
type="number"
id="edit-group-yearStartStudy"
min="2000"
max="2100"
required
value={editModal.form.yearStartStudy}
onChange={event => setEditModal(current => (
current ? { ...current, form: { ...current.form, yearStartStudy: event.target.value } } : current
))}
/>
</div>
<div className="form-group">
<label htmlFor="edit-group-speciality-code">Специальность</label>
<select
id="edit-group-speciality-code"
value={editModal.form.specialtyId}
onChange={event => setEditModal(current => (
current
? { ...current, form: { ...current.form, specialtyId: event.target.value, specialtyProfileId: '' } }
: current
))}
>
<option value="">Выберите специальность</option>
{(specialties || []).map(speciality => (
<option key={speciality.id} value={speciality.id}>
{specialityLabel(specialties, speciality.id)}
</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="edit-group-profile">Профиль обучения</label>
<select
id="edit-group-profile"
value={editModal.form.specialtyProfileId}
onChange={event => setEditModal(current => (
current ? { ...current, form: { ...current.form, specialtyProfileId: event.target.value } } : current
))}
>
{(profilesBySpecialty.get(String(editModal.form.specialtyId)) || []).length ? (
<>
<option value="">Выберите профиль</option>
{(profilesBySpecialty.get(String(editModal.form.specialtyId)) || []).map(profile => (
<option key={profile.id} value={profile.id}>{profile.name}</option>
))}
</>
) : (
<option value="">Нет профилей</option>
)}
</select>
</div>
<div className="form-group edit-group-submit">
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
</div>
</div>
<div
className={`form-alert${editModal.alert.text ? (editModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{editModal.alert.text}
</div>
</form>
</div>
</div>
)}
{subgroupsModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setSubgroupsModal(null);
}}
>
<div className="modal-content card modal-content--wide">
<div className="modal-header-row">
<div>
<h2>Подгруппы группы</h2>
<div className="modal-subtitle">
Группа: {subgroupsModal.group.name} ({subgroupsModal.group.groupSize} чел.)
</div>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
aria-label="Закрыть окно"
onClick={() => setSubgroupsModal(null)}
>
×
</button>
</div>
<div className="card create-card modal-subcard">
<h3>Настройка подгрупп</h3>
<form onSubmit={submitSubgroups}>
<div className="subgroup-config-grid">
<div className="form-group subgroup-count-field">
<label htmlFor="manage-subgroup-count">Количество подгрупп</label>
<select
id="manage-subgroup-count"
value={subgroupsModal.count}
onChange={event => changeSubgroupCount(event.target.value)}
>
{SUBGROUP_COUNT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className={`subgroup-capacity-fields subgroup-capacity-fields-${subgroupsModal.count}`}>
{subgroupsModal.capacities.map((value, index) => (
<div className="form-group subgroup-capacity-field" key={index}>
<label htmlFor={`manage-subgroup-capacity-${index + 1}`}>Численность {index + 1}</label>
<input
type="number"
id={`manage-subgroup-capacity-${index + 1}`}
className="manage-subgroup-capacity"
min="1"
max={subgroupsModal.group.groupSize || undefined}
required
value={value}
ref={element => {
if (element) capacityInputRefs.current.set(index, element);
else capacityInputRefs.current.delete(index);
}}
onChange={event => changeSubgroupCapacity(index, event.target.value)}
/>
</div>
))}
</div>
<div className="subgroup-actions">
<button type="submit" className="btn btn-md btn-primary">Применить</button>
</div>
</div>
<div
className={`form-alert${subgroupsModal.alert.text ? (subgroupsModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{subgroupsModal.alert.text}
</div>
</form>
</div>
<div className="table-wrap">
<table id="manage-subgroups-table">
<thead>
<tr>
<th>Подгруппа</th>
<th>Численность</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{subgroupsModal.subgroups === null ? (
<tr><td colSpan="3" className="loading-row">Загрузка подгрупп...</td></tr>
) : sortSubgroups(subgroupsModal.subgroups).length === 0 ? (
<tr><td colSpan="3" className="loading-row">Подгруппы не созданы</td></tr>
) : (() => {
const list = sortSubgroups(subgroupsModal.subgroups);
const canDeleteSingleSubgroup = list.length === 1;
const deleteTitle = canDeleteSingleSubgroup
? 'Удалить подгруппу'
: 'Чтобы изменить количество подгрупп, выберите нужный режим выше и нажмите «Применить»';
return list.map(sub => (
<tr key={sub.id}>
<td>{sub.name}</td>
<td>{sub.studentCapacity ?? '-'} чел.</td>
<td>
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-subgroup"
onClick={() => focusSubgroupInput(sub.id)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-subgroup"
disabled={!canDeleteSingleSubgroup}
title={deleteTitle}
onClick={() => confirmDeleteSubgroup(sub)}
>
{canDeleteSingleSubgroup ? 'Удалить' : 'Изменить выше'}
</button>
</td>
</tr>
));
})()}
</tbody>
</table>
</div>
</div>
</div>
)}
{calendarModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setCalendarModal(null);
}}
>
<div className="modal-content card modal-content--full">
<div className="modal-header-row">
<div>
<h2>Календарные графики группы</h2>
<div className="modal-subtitle">
Группа: {calendarModal.group.name} ({specialityLabel(specialties, calendarModal.group.specialtyId || calendarModal.group.specialityCode)})
</div>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
aria-label="Закрыть окно"
onClick={() => setCalendarModal(null)}
>
×
</button>
</div>
<div className="card create-card modal-subcard">
<h3>Назначить график</h3>
<form onSubmit={submitAssignment}>
<div className="form-row form-row--bottom">
<div className="form-group form-group--flush">
<label htmlFor="manage-calendar-year">Учебный год</label>
<select
id="manage-calendar-year"
required
value={calendarModal.yearId}
onChange={event => changeCalendarYear(event.target.value)}
>
<option value="">Выберите учебный год</option>
{(academicYears || []).map(year => (
<option key={year.id} value={year.id}>{year.title}</option>
))}
</select>
</div>
<div className="form-group form-group--flush form-group--wide">
<label htmlFor="manage-calendar-id">Календарный график</label>
<AsyncCombobox
ariaLabel="Поиск календарного графика"
placeholder="Найдите совместимый график"
emptyLabel="Совместимых графиков не найдено"
load={(query, selectedId) => 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)
: ''}
/>
</div>
<button type="submit" className="btn btn-md btn-primary btn-inline">Назначить</button>
</div>
<div
className={`form-alert${calendarModal.alert.text ? (calendarModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{calendarModal.alert.text}
</div>
</form>
</div>
<div className="table-wrap">
<table id="manage-calendar-table">
<thead>
<tr>
<th>Учебный год</th>
<th>График</th>
<th>Дисциплины</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{calendarModal.assignments === null ? (
<tr><td colSpan="4" className="loading-row">Загрузка...</td></tr>
) : calendarModal.assignments.length === 0 ? (
<tr><td colSpan="4" className="loading-row">Календарные графики не назначены</td></tr>
) : calendarModal.assignments.map(assignment => (
<tr key={assignment.id}>
<td>{assignment.academicYearTitle}</td>
<td>{assignmentCalendarTitle(assignment)}</td>
<td>
{!(assignment.subjects || []).length ? (
<span className="text-muted">Не привязаны</span>
) : (
<div className="assignment-subjects">
{assignmentSubjectsGroups(assignment.subjects).map(({ semesterNumber, items }) => (
<div className="assignment-subject-semester" key={semesterNumber}>
<strong>{semesterNumber} семестр</strong>
<span>{items.map(subject => subjectLabel(subject)).join(', ')}</span>
</div>
))}
</div>
)}
</td>
<td>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-assignment"
onClick={() => confirmDeleteAssignment(assignment)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
)}
{confirmState && (
<ConfirmDialog
title={confirmState.title}
message={confirmState.message}
confirmLabel={confirmState.confirmLabel}
danger={confirmState.danger}
onConfirm={() => void runConfirm()}
onClose={() => setConfirmState(null)}
/>
)}
{alertState && (
<AlertDialog
title={alertState.title}
message={alertState.message}
onClose={() => 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 (
<div className="custom-multi-select" ref={rootRef}>
<button
type="button"
id="filter-ef-box"
className={`select-box${open ? ' active' : ''}`}
aria-expanded={open}
onClick={() => setOpen(current => !current)}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setOpen(current => !current);
}
}}
>
<span className="select-text" id="filter-ef-text">
{allChecked ? 'Все' : efFilterLabel(educationForms, selectedIds)}
</span>
<svg className="dropdown-icon" width="12" height="8" viewBox="0 0 12 8" fill="none" aria-hidden="true">
<path d="M1 1.5L6 6.5L11 1.5" stroke="#9ca3af" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div className="dropdown-menu" id="filter-ef-menu">
<div className="checkbox-group-vertical" id="filter-ef-checkboxes">
<label className="checkbox-item">
<input
type="checkbox"
value="all"
checked={allChecked}
onChange={() => toggleAll()}
/>
<span className="checkmark"></span>
<span>Все</span>
</label>
{(educationForms || []).map(form => {
const id = String(form.id);
return (
<label className="checkbox-item" key={id}>
<input
type="checkbox"
value={id}
checked={selected.has(id)}
onChange={event => toggleForm(id, event.target.checked)}
/>
<span className="checkmark"></span>
<span>{form.name}</span>
</label>
);
})}
</div>
</div>
)}
</div>
);
}