Files
magistr/frontend/react/admin/tabs/UniversityStructureTab.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

1095 lines
56 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 { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import {
DEPARTMENT_TABLE_COLUMNS,
PROFILE_TABLE_COLUMNS,
SPECIALTY_TABLE_COLUMNS,
clampPage,
createDepartment,
createProfile,
createSpecialty,
deleteDepartment,
deleteProfile,
deleteSpecialty,
departmentCode,
departmentFields,
departmentName,
fetchAllProfiles,
fetchDepartments,
fetchProfiles,
fetchSpecialties,
filterListItems,
pageSlice,
profileFields,
profileSpecialtyLabel,
specialtyCode,
specialtyFields,
specialtyName,
specialtyOptionLabel,
updateDepartment,
updateProfile,
updateSpecialty
} from '../university-structure-service.js';
const SEARCH_DEBOUNCE_MS = 250;
const SECTIONS = ['departments', 'specialties', 'profiles'];
const SECTION_TITLES = {
departments: 'Кафедры',
specialties: 'Специальности',
profiles: 'Профили'
};
function initialListStates() {
return {
departments: { query: '', page: 0, size: 25 },
specialties: { query: '', page: 0, size: 25 },
profiles: { query: '', page: 0, size: 25 }
};
}
export function UniversityStructureTab() {
const [activeSection, setActiveSection] = useState('departments');
const [departments, setDepartments] = useState(null);
const [specialties, setSpecialties] = useState(null);
const [allProfiles, setAllProfiles] = useState(null);
const [loadErrors, setLoadErrors] = useState({});
const [listStates, setListStates] = useState(initialListStates);
const [searchInputs, setSearchInputs] = useState({ departments: '', specialties: '', profiles: '' });
const [createDept, setCreateDept] = useState({ name: '', code: '' });
const [createDeptAlert, setCreateDeptAlert] = useState({ text: '', isError: false });
const [createSpec, setCreateSpec] = useState({ name: '', code: '' });
const [createSpecAlert, setCreateSpecAlert] = useState({ text: '', isError: false });
const [profileForm, setProfileForm] = useState({
profileId: '',
specId: '',
name: '',
description: '',
editing: false
});
const [profileAlert, setProfileAlert] = useState({ text: '', isError: false });
const [editDeptModal, setEditDeptModal] = useState(null);
const [editSpecModal, setEditSpecModal] = useState(null);
const [profilesModal, setProfilesModal] = useState(null);
const [confirmState, setConfirmState] = useState(null);
const [alertState, setAlertState] = useState(null);
const searchTimers = useRef({});
const profileFormRef = useRef(null);
useEffect(() => {
void loadData();
return () => {
Object.values(searchTimers.current).forEach(timer => window.clearTimeout(timer));
};
}, []);
async function loadData() {
const [deptResult, specResult, profilesResult] = await Promise.allSettled([
fetchDepartments(),
fetchSpecialties(),
fetchAllProfiles()
]);
setDepartments(deptResult.status === 'fulfilled' ? deptResult.value || [] : null);
setSpecialties(specResult.status === 'fulfilled' ? specResult.value || [] : null);
setAllProfiles(profilesResult.status === 'fulfilled' ? profilesResult.value || [] : null);
setLoadErrors({
departments: deptResult.status === 'rejected',
specialties: specResult.status === 'rejected',
profiles: profilesResult.status === 'rejected'
});
}
function handleSearchInput(section, value) {
setSearchInputs(current => ({ ...current, [section]: value }));
window.clearTimeout(searchTimers.current[section]);
searchTimers.current[section] = window.setTimeout(() => {
setListStates(current => ({ ...current, [section]: { ...current[section], query: value.trim(), page: 0 } }));
}, SEARCH_DEBOUNCE_MS);
}
function applyListState(section, patch) {
setListStates(current => ({ ...current, [section]: { ...current[section], ...patch } }));
}
function visibleItems(section, items, fields) {
const listState = listStates[section];
const filtered = filterListItems(items, listState.query, fields);
const totalPages = Math.ceil(filtered.length / listState.size);
const page = clampPage(listState.page, totalPages);
return {
filtered,
page,
size: listState.size,
totalPages,
items: pageSlice(filtered, page, listState.size)
};
}
// --- Кафедры ---
async function submitCreateDepartment(event) {
event.preventDefault();
const name = createDept.name.trim();
const code = createDept.code.trim();
if (!name || !code) {
setCreateDeptAlert({ text: 'Заполните все поля', isError: true });
return;
}
try {
await createDepartment({ name, code });
setCreateDeptAlert({ text: `Кафедра «${name}» создана`, isError: false });
setCreateDept({ name: '', code: '' });
await loadData();
} catch (error) {
setCreateDeptAlert({ text: error.message || 'Ошибка создания кафедры', isError: true });
}
}
function openEditDepartmentModal(department) {
setEditDeptModal({
department,
name: departmentName(department) || '',
code: departmentCode(department) === null || departmentCode(department) === undefined
? ''
: String(departmentCode(department)),
alert: { text: '', isError: false }
});
}
async function submitEditDepartment(event) {
event.preventDefault();
const modal = editDeptModal;
const name = modal.name.trim();
const code = modal.code.trim();
if (!name || !code) {
setEditDeptModal({ ...modal, alert: { text: 'Заполните все поля', isError: true } });
return;
}
try {
await updateDepartment(modal.department.id, { name, code });
setEditDeptModal(null);
setCreateDeptAlert({ text: `Кафедра «${name}» обновлена`, isError: false });
await loadData();
} catch (error) {
setEditDeptModal({ ...modal, alert: { text: error.message || 'Ошибка обновления кафедры', isError: true } });
}
}
function confirmDeleteDepartment(department) {
setConfirmState({
title: 'Удаление кафедры',
message: 'Удалить кафедру?',
confirmLabel: 'Удалить',
danger: true,
action: async () => {
try {
await deleteDepartment(department.id);
setCreateDeptAlert({ text: 'Кафедра удалена', isError: false });
await loadData();
} catch (error) {
setCreateDeptAlert({ text: error.message || 'Ошибка удаления кафедры', isError: true });
}
}
});
}
// --- Специальности ---
async function submitCreateSpecialty(event) {
event.preventDefault();
const name = createSpec.name.trim();
const code = createSpec.code.trim();
if (!name || !code) {
setCreateSpecAlert({ text: 'Заполните все поля', isError: true });
return;
}
try {
await createSpecialty({ name, code });
setCreateSpecAlert({ text: `Специальность «${name}» создана`, isError: false });
setCreateSpec({ name: '', code: '' });
await loadData();
} catch (error) {
setCreateSpecAlert({ text: error.message || 'Ошибка создания специальности', isError: true });
}
}
function openEditSpecialtyModal(speciality) {
setEditSpecModal({
speciality,
name: specialtyName(speciality) || '',
code: specialtyCode(speciality) || '',
alert: { text: '', isError: false }
});
}
async function submitEditSpecialty(event) {
event.preventDefault();
const modal = editSpecModal;
const name = modal.name.trim();
const code = modal.code.trim();
if (!name || !code) {
setEditSpecModal({ ...modal, alert: { text: 'Заполните все поля', isError: true } });
return;
}
try {
await updateSpecialty(modal.speciality.id, { name, code });
setEditSpecModal(null);
setCreateSpecAlert({ text: `Специальность «${name}» обновлена`, isError: false });
await loadData();
} catch (error) {
setEditSpecModal({ ...modal, alert: { text: error.message || 'Ошибка обновления специальности', isError: true } });
}
}
function confirmDeleteSpecialty(speciality) {
setConfirmState({
title: 'Удаление специальности',
message: 'Удалить специальность?',
confirmLabel: 'Удалить',
danger: true,
action: async () => {
try {
await deleteSpecialty(speciality.id);
setCreateSpecAlert({ text: 'Специальность удалена', isError: false });
await loadData();
} catch (error) {
setCreateSpecAlert({ text: error.message || 'Ошибка удаления специальности', isError: true });
}
}
});
}
// --- Профили: вкладка ---
function resetProfileForm() {
setProfileForm({ profileId: '', specId: '', name: '', description: '', editing: false });
}
async function submitProfileForm(event) {
event.preventDefault();
const { profileId, specId, name, description } = profileForm;
const trimmedName = name.trim();
const trimmedDescription = description.trim();
if (!specId) {
setProfileAlert({ text: 'Выберите специальность', isError: true });
return;
}
if (!trimmedName) {
setProfileAlert({ text: 'Введите название профиля', isError: true });
return;
}
try {
if (profileId) {
await updateProfile(specId, profileId, { name: trimmedName, description: trimmedDescription });
setProfileAlert({ text: `Профиль «${trimmedName}» обновлен`, isError: false });
} else {
await createProfile(specId, { name: trimmedName, description: trimmedDescription });
setProfileAlert({ text: `Профиль «${trimmedName}» создан`, isError: false });
}
resetProfileForm();
await loadData();
} catch (error) {
setProfileAlert({ text: error.message || 'Ошибка сохранения профиля', isError: true });
}
}
function editTabProfile(profile) {
setProfileForm({
profileId: String(profile.id),
specId: String(profile.specialityId),
name: profile.name || '',
description: profile.description || '',
editing: true
});
profileFormRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
function confirmDeleteTabProfile(profile) {
setConfirmState({
title: 'Удаление профиля',
message: 'Удалить профиль обучения?',
confirmLabel: 'Удалить',
danger: true,
action: async () => {
try {
await deleteProfile(profile.specialityId, profile.id);
setProfileAlert({ text: 'Профиль удален', isError: false });
await loadData();
} catch (error) {
setProfileAlert({ text: error.message || 'Ошибка удаления профиля', isError: true });
}
}
});
}
// --- Профили: модальное окно специальности ---
async function openProfilesModal(speciality) {
setProfilesModal({
speciality,
profiles: null,
form: { profileId: '', name: '', description: '', editing: false },
alert: { text: '', isError: false }
});
try {
const profiles = await fetchProfiles(speciality.id);
setProfilesModal(current => (current ? { ...current, profiles: profiles || [] } : current));
} catch (error) {
setProfilesModal(current => (
current ? { ...current, profiles: [], alert: { text: error.message || 'Ошибка загрузки', isError: true } } : current
));
}
}
function resetModalProfileForm() {
setProfilesModal(current => (
current ? { ...current, form: { profileId: '', name: '', description: '', editing: false } } : current
));
}
async function submitModalProfile(event) {
event.preventDefault();
const modal = profilesModal;
const { profileId, name, description } = modal.form;
const trimmedName = name.trim();
const trimmedDescription = description.trim();
if (!trimmedName) {
setProfilesModal({ ...modal, alert: { text: 'Введите название профиля', isError: true } });
return;
}
try {
if (profileId) {
await updateProfile(modal.speciality.id, profileId, { name: trimmedName, description: trimmedDescription });
setProfilesModal(current => (
current ? { ...current, alert: { text: `Профиль «${trimmedName}» обновлен`, isError: false } } : current
));
} else {
await createProfile(modal.speciality.id, { name: trimmedName, description: trimmedDescription });
setProfilesModal(current => (
current ? { ...current, alert: { text: `Профиль «${trimmedName}» создан`, isError: false } } : current
));
}
resetModalProfileForm();
const profiles = await fetchProfiles(modal.speciality.id);
setProfilesModal(current => (current ? { ...current, profiles: profiles || [] } : current));
} catch (error) {
setProfilesModal(current => (
current ? { ...current, alert: { text: error.message || 'Ошибка сохранения профиля', isError: true } } : current
));
}
}
function editModalProfile(profile) {
setProfilesModal(current => (
current
? {
...current,
form: {
profileId: String(profile.id),
name: profile.name || '',
description: profile.description || '',
editing: true
}
}
: current
));
}
function confirmDeleteModalProfile(profile) {
const modal = profilesModal;
if (!modal) return;
setConfirmState({
title: 'Удаление профиля',
message: 'Удалить профиль обучения?',
confirmLabel: 'Удалить',
danger: true,
action: async () => {
try {
await deleteProfile(modal.speciality.id, profile.id);
setProfilesModal(current => (
current ? { ...current, alert: { text: 'Профиль удален', isError: false } } : current
));
const profiles = await fetchProfiles(modal.speciality.id);
setProfilesModal(current => (current ? { ...current, profiles: profiles || [] } : current));
} catch (error) {
setProfilesModal(current => (
current ? { ...current, alert: { text: error.message || 'Ошибка удаления профиля', isError: true } } : current
));
}
}
});
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
const departmentsView = visibleItems('departments', departments || [], departmentFields);
const specialtiesView = visibleItems('specialties', specialties || [], specialtyFields);
const profilesView = visibleItems('profiles', allProfiles || [], profileFields);
return (
<>
<div className="tab-container">
<div className="university-structure-tabs" role="tablist" aria-label="Разделы структуры вуза">
{SECTIONS.map(section => (
<button
type="button"
key={section}
className={`university-structure-tab${activeSection === section ? ' active' : ''}`}
role="tab"
aria-selected={activeSection === section}
aria-controls={`tab-content-${section}`}
onClick={() => setActiveSection(section)}
>
{SECTION_TITLES[section]}
</button>
))}
</div>
{/* Раздел Кафедр */}
<div
id="tab-content-departments"
className={`university-structure-tab-panel${activeSection === 'departments' ? ' active' : ''}`}
role="tabpanel"
hidden={activeSection !== 'departments'}
>
<div className="card create-card">
<h2>Создание кафедры</h2>
<form onSubmit={submitCreateDepartment}>
<div className="form-row">
<div className="form-group">
<label htmlFor="dept-name">Название кафедры</label>
<input
type="text"
id="dept-name"
placeholder="Например: Кафедра ИБ"
required
value={createDept.name}
onChange={event => setCreateDept(current => ({ ...current, name: event.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="dept-code">Код кафедры</label>
<input
type="number"
id="dept-code"
placeholder="Например: 1"
required
value={createDept.code}
onChange={event => setCreateDept(current => ({ ...current, code: event.target.value }))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Создать</button>
</div>
<div
className={`form-alert${createDeptAlert.text ? (createDeptAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{createDeptAlert.text}
</div>
</form>
</div>
<div className="card">
<div className="card-header-row">
<h2>Кафедры</h2>
</div>
<div className="form-group">
<label htmlFor="departments-search">Поиск по названию или коду кафедры</label>
<input
type="search"
id="departments-search"
placeholder="Введите название или код"
value={searchInputs.departments}
onChange={event => handleSearchInput('departments', event.target.value)}
/>
</div>
<div className="table-wrap">
<table id="departments-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{loadErrors.departments ? (
<tr><td colSpan={DEPARTMENT_TABLE_COLUMNS} className="loading-row">Ошибка загрузки</td></tr>
) : departments === null ? (
<tr><td colSpan={DEPARTMENT_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка...</td></tr>
) : departmentsView.items.length === 0 ? (
<tr><td colSpan={DEPARTMENT_TABLE_COLUMNS} className="loading-row">Ничего не найдено</td></tr>
) : departmentsView.items.map(department => (
<tr key={department.id}>
<td>{department.id}</td>
<td>{departmentName(department)}</td>
<td>{departmentCode(department)}</td>
<td className="table-cell-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-department"
onClick={() => openEditDepartmentModal(department)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-department"
onClick={() => confirmDeleteDepartment(department)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{departments !== null && (
<Pagination
page={departmentsView.page}
size={departmentsView.size}
totalItems={departmentsView.filtered.length}
totalPages={departmentsView.totalPages}
label="кафедр"
onPage={page => applyListState('departments', { page })}
onSize={size => applyListState('departments', { size, page: 0 })}
/>
)}
</div>
</div>
{/* Раздел Специальностей */}
<div
id="tab-content-specialties"
className={`university-structure-tab-panel${activeSection === 'specialties' ? ' active' : ''}`}
role="tabpanel"
hidden={activeSection !== 'specialties'}
>
<div className="card create-card">
<h2>Создание специальности</h2>
<form onSubmit={submitCreateSpecialty}>
<div className="form-row">
<div className="form-group">
<label htmlFor="spec-name">Название специальности</label>
<input
type="text"
id="spec-name"
placeholder="Например: Программная инженерия"
required
value={createSpec.name}
onChange={event => setCreateSpec(current => ({ ...current, name: event.target.value }))}
/>
</div>
<div className="form-group">
<label htmlFor="spec-code">Код специальности</label>
<input
type="text"
id="spec-code"
placeholder="Например: 09.03.04"
required
value={createSpec.code}
onChange={event => setCreateSpec(current => ({ ...current, code: event.target.value }))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Создать</button>
</div>
<div
className={`form-alert${createSpecAlert.text ? (createSpecAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{createSpecAlert.text}
</div>
</form>
</div>
<div className="card">
<div className="card-header-row">
<h2>Специальности</h2>
</div>
<div className="form-group">
<label htmlFor="specialties-search">Поиск по названию или коду специальности</label>
<input
type="search"
id="specialties-search"
placeholder="Введите название или код"
value={searchInputs.specialties}
onChange={event => handleSearchInput('specialties', event.target.value)}
/>
</div>
<div className="table-wrap">
<table id="specialties-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Код специальности</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{loadErrors.specialties ? (
<tr><td colSpan={SPECIALTY_TABLE_COLUMNS} className="loading-row">Ошибка загрузки</td></tr>
) : specialties === null ? (
<tr><td colSpan={SPECIALTY_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка...</td></tr>
) : specialtiesView.items.length === 0 ? (
<tr><td colSpan={SPECIALTY_TABLE_COLUMNS} className="loading-row">Ничего не найдено</td></tr>
) : specialtiesView.items.map(speciality => (
<tr key={speciality.id}>
<td>{speciality.id}</td>
<td>{specialtyName(speciality)}</td>
<td>{specialtyCode(speciality)}</td>
<td className="table-cell-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-specialty"
onClick={() => openEditSpecialtyModal(speciality)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-secondary btn-manage-profiles"
onClick={() => void openProfilesModal(speciality)}
>
Профили
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-specialty"
onClick={() => confirmDeleteSpecialty(speciality)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{specialties !== null && (
<Pagination
page={specialtiesView.page}
size={specialtiesView.size}
totalItems={specialtiesView.filtered.length}
totalPages={specialtiesView.totalPages}
label="специальностей"
onPage={page => applyListState('specialties', { page })}
onSize={size => applyListState('specialties', { size, page: 0 })}
/>
)}
</div>
</div>
{/* Раздел Профилей */}
<div
id="tab-content-profiles"
className={`university-structure-tab-panel${activeSection === 'profiles' ? ' active' : ''}`}
role="tabpanel"
hidden={activeSection !== 'profiles'}
>
<div className="card create-card" ref={profileFormRef}>
<h2>{profileForm.editing ? 'Редактирование профиля' : 'Создание профиля'}</h2>
<form onSubmit={submitProfileForm}>
<div className="form-row form-row--bottom">
<div className="form-group profile-form-spec">
<label htmlFor="tab-profile-spec">Специальность</label>
<select
id="tab-profile-spec"
required
disabled={profileForm.editing}
value={profileForm.specId}
onChange={event => setProfileForm(current => ({ ...current, specId: event.target.value }))}
>
<option value="">Выберите специальность</option>
{(specialties || []).map(speciality => (
<option key={speciality.id} value={speciality.id}>
{specialtyOptionLabel(speciality)}
</option>
))}
</select>
</div>
<div className="form-group profile-form-name">
<label htmlFor="tab-profile-name">Название профиля</label>
<input
type="text"
id="tab-profile-name"
placeholder="Например: Искусственный интеллект"
required
value={profileForm.name}
onChange={event => setProfileForm(current => ({ ...current, name: event.target.value }))}
/>
</div>
<div className="form-group profile-form-description">
<label htmlFor="tab-profile-description">Описание</label>
<input
type="text"
id="tab-profile-description"
placeholder="Необязательно"
value={profileForm.description}
onChange={event => setProfileForm(current => ({ ...current, description: event.target.value }))}
/>
</div>
<div className="form-actions-row">
<button type="submit" className="btn btn-md btn-primary">
{profileForm.editing ? 'Сохранить' : 'Создать'}
</button>
{profileForm.editing && (
<button type="button" className="btn btn-md btn-ghost" onClick={resetProfileForm}>
Отмена
</button>
)}
</div>
</div>
<div
className={`form-alert${profileAlert.text ? (profileAlert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{profileAlert.text}
</div>
</form>
</div>
<div className="card">
<div className="card-header-row">
<h2>Профили обучения</h2>
</div>
<div className="form-group">
<label htmlFor="profiles-search">Поиск по профилю, специальности или описанию</label>
<input
type="search"
id="profiles-search"
placeholder="Введите название или код"
value={searchInputs.profiles}
onChange={event => handleSearchInput('profiles', event.target.value)}
/>
</div>
<div className="table-wrap">
<table id="profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Специальность</th>
<th>Название профиля</th>
<th>Описание</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{loadErrors.profiles ? (
<tr><td colSpan={PROFILE_TABLE_COLUMNS} className="loading-row">Ошибка загрузки</td></tr>
) : allProfiles === null ? (
<tr><td colSpan={PROFILE_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка...</td></tr>
) : profilesView.items.length === 0 ? (
<tr><td colSpan={PROFILE_TABLE_COLUMNS} className="loading-row">Ничего не найдено</td></tr>
) : profilesView.items.map(profile => (
<tr key={profile.id}>
<td>{profile.id}</td>
<td>{profileSpecialtyLabel(profile)}</td>
<td>{profile.name}</td>
<td>{profile.description || '-'}</td>
<td className="table-cell-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-tab-profile"
onClick={() => editTabProfile(profile)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-tab-profile"
onClick={() => confirmDeleteTabProfile(profile)}
>
Удалить
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{allProfiles !== null && (
<Pagination
page={profilesView.page}
size={profilesView.size}
totalItems={profilesView.filtered.length}
totalPages={profilesView.totalPages}
label="профилей"
onPage={page => applyListState('profiles', { page })}
onSize={size => applyListState('profiles', { size, page: 0 })}
/>
)}
</div>
</div>
</div>
{editDeptModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setEditDeptModal(null);
}}
>
<div className="modal-content card">
<h2>Редактирование кафедры</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close"
aria-label="Закрыть окно"
onClick={() => setEditDeptModal(null)}
>
×
</button>
<form onSubmit={submitEditDepartment}>
<div className="form-row">
<div className="form-group">
<label htmlFor="edit-dept-name">Название кафедры</label>
<input
type="text"
id="edit-dept-name"
required
value={editDeptModal.name}
onChange={event => setEditDeptModal(current => (
current ? { ...current, name: event.target.value } : current
))}
/>
</div>
<div className="form-group">
<label htmlFor="edit-dept-code">Код кафедры</label>
<input
type="number"
id="edit-dept-code"
required
value={editDeptModal.code}
onChange={event => setEditDeptModal(current => (
current ? { ...current, code: event.target.value } : current
))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
</div>
<div
className={`form-alert${editDeptModal.alert.text ? (editDeptModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{editDeptModal.alert.text}
</div>
</form>
</div>
</div>
)}
{editSpecModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setEditSpecModal(null);
}}
>
<div className="modal-content card">
<h2>Редактирование специальности</h2>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close"
aria-label="Закрыть окно"
onClick={() => setEditSpecModal(null)}
>
×
</button>
<form onSubmit={submitEditSpecialty}>
<div className="form-row">
<div className="form-group">
<label htmlFor="edit-spec-name">Название специальности</label>
<input
type="text"
id="edit-spec-name"
required
value={editSpecModal.name}
onChange={event => setEditSpecModal(current => (
current ? { ...current, name: event.target.value } : current
))}
/>
</div>
<div className="form-group">
<label htmlFor="edit-spec-code">Код специальности</label>
<input
type="text"
id="edit-spec-code"
required
value={editSpecModal.code}
onChange={event => setEditSpecModal(current => (
current ? { ...current, code: event.target.value } : current
))}
/>
</div>
<button type="submit" className="btn btn-md btn-primary">Сохранить</button>
</div>
<div
className={`form-alert${editSpecModal.alert.text ? (editSpecModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{editSpecModal.alert.text}
</div>
</form>
</div>
</div>
)}
{profilesModal && (
<div
className="modal-overlay open"
onClick={event => {
if (event.target === event.currentTarget) setProfilesModal(null);
}}
>
<div className="modal-content card modal-content--xwide">
<div className="modal-header-row">
<div>
<h2>Профили обучения</h2>
<div className="modal-subtitle">
{specialtyCode(profilesModal.speciality)} · {specialtyName(profilesModal.speciality)}
</div>
</div>
<button
type="button"
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
aria-label="Закрыть окно"
onClick={() => setProfilesModal(null)}
>
×
</button>
</div>
<div className="card create-card modal-subcard">
<h3>{profilesModal.form.editing ? 'Редактирование профиля' : 'Создание профиля'}</h3>
<form onSubmit={submitModalProfile}>
<div className="form-row form-row--bottom">
<div className="form-group form-group--flush">
<label htmlFor="manage-profile-name">Название профиля</label>
<input
type="text"
id="manage-profile-name"
placeholder="Например: Искусственный интеллект"
required
value={profilesModal.form.name}
onChange={event => setProfilesModal(current => (
current
? { ...current, form: { ...current.form, name: event.target.value } }
: current
))}
/>
</div>
<div className="form-group form-group--flush form-group--wide">
<label htmlFor="manage-profile-description">Описание</label>
<input
type="text"
id="manage-profile-description"
placeholder="Необязательно"
value={profilesModal.form.description}
onChange={event => setProfilesModal(current => (
current
? { ...current, form: { ...current.form, description: event.target.value } }
: current
))}
/>
</div>
<div className="form-actions-row">
<button type="submit" className="btn btn-md btn-primary">
{profilesModal.form.editing ? 'Сохранить' : 'Создать'}
</button>
{profilesModal.form.editing && (
<button type="button" className="btn btn-md btn-ghost" onClick={resetModalProfileForm}>
Отмена
</button>
)}
</div>
</div>
<div
className={`form-alert${profilesModal.alert.text ? (profilesModal.alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{profilesModal.alert.text}
</div>
</form>
</div>
<div className="table-wrap">
<table id="manage-profiles-table">
<thead>
<tr>
<th>ID</th>
<th>Название</th>
<th>Описание</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{profilesModal.profiles === null ? (
<tr><td colSpan="4" className="loading-row">Загрузка...</td></tr>
) : profilesModal.profiles.length === 0 ? (
<tr><td colSpan="4" className="loading-row">Профили не созданы</td></tr>
) : profilesModal.profiles.map(profile => (
<tr key={profile.id}>
<td>{profile.id}</td>
<td>{profile.name}</td>
<td>{profile.description || '-'}</td>
<td className="table-cell-actions">
<button
type="button"
className="btn btn-sm btn-secondary btn-edit-profile"
onClick={() => editModalProfile(profile)}
>
Изменить
</button>
<button
type="button"
className="btn btn-sm btn-danger btn-delete-profile"
onClick={() => confirmDeleteModalProfile(profile)}
>
Удалить
</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)}
/>
)}
</>
);
}