Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на 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
1280 lines
64 KiB
JavaScript
1280 lines
64 KiB
JavaScript
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>
|
||
);
|
||
}
|