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