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
This commit is contained in:
535
frontend/react/admin/tabs/SubjectsTab.jsx
Normal file
535
frontend/react/admin/tabs/SubjectsTab.jsx
Normal file
@@ -0,0 +1,535 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { readUrlState, 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 {
|
||||
SUBJECT_SORT_OPTIONS,
|
||||
SUBJECT_TABLE_COLUMNS,
|
||||
archiveSubject,
|
||||
assignTeacher,
|
||||
createSubject,
|
||||
departmentLabel,
|
||||
departmentOptionLabel,
|
||||
fetchDepartments,
|
||||
fetchSubjectsPage,
|
||||
fetchTeacherAssignments,
|
||||
groupTeachersBySubject,
|
||||
initialSubjectsState,
|
||||
removeTeacherAssignment,
|
||||
subjectsEmptyMessage,
|
||||
subjectsUrlState,
|
||||
teachersSearchEndpoint
|
||||
} from '../subjects-service.js';
|
||||
import { apiClient } from '../../shared/api-client.js';
|
||||
|
||||
const SEARCH_DEBOUNCE_MS = 320;
|
||||
|
||||
export function SubjectsTab() {
|
||||
const [departments, setDepartments] = useState(null);
|
||||
const [referenceError, setReferenceError] = useState('');
|
||||
|
||||
const [state, setState] = useState(() => initialSubjectsState(
|
||||
typeof window === 'undefined' ? {} : readUrlState({
|
||||
subjectQuery: '',
|
||||
subjectDepartment: '',
|
||||
subjectPage: '0',
|
||||
subjectSize: '25',
|
||||
subjectSort: 'name,asc'
|
||||
})
|
||||
));
|
||||
const [searchInput, setSearchInput] = useState(() => state.query);
|
||||
const [pageData, setPageData] = useState(null);
|
||||
const [assignments, setAssignments] = useState([]);
|
||||
const [tableError, setTableError] = useState('');
|
||||
const [tableLoading, setTableLoading] = useState(true);
|
||||
|
||||
const [createName, setCreateName] = useState('');
|
||||
const [createCode, setCreateCode] = useState('');
|
||||
const [createDepartmentId, setCreateDepartmentId] = useState('');
|
||||
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
|
||||
|
||||
const [teachersModal, setTeachersModal] = 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;
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
(async () => {
|
||||
try {
|
||||
const loaded = await fetchDepartments();
|
||||
if (!active) return;
|
||||
setDepartments(loaded || []);
|
||||
await loadSubjects(stateRef.current);
|
||||
} catch (error) {
|
||||
if (active) setReferenceError(error.message || 'Не удалось загрузить справочники');
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
active = false;
|
||||
requestNumber.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => {
|
||||
window.clearTimeout(searchTimer.current);
|
||||
}, []);
|
||||
|
||||
async function loadSubjects(nextState) {
|
||||
const currentRequest = requestNumber.current + 1;
|
||||
requestNumber.current = currentRequest;
|
||||
setTableLoading(true);
|
||||
setTableError('');
|
||||
try {
|
||||
let page = await fetchSubjectsPage(nextState);
|
||||
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
|
||||
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
|
||||
page = await fetchSubjectsPage(nextState);
|
||||
}
|
||||
const loadedAssignments = await fetchTeacherAssignments((page.items || []).map(subject => subject.id));
|
||||
if (requestNumber.current !== currentRequest) return;
|
||||
setState(nextState);
|
||||
setPageData(page);
|
||||
setAssignments(loadedAssignments || []);
|
||||
setTableLoading(false);
|
||||
writeUrlState(subjectsUrlState(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) {
|
||||
const nextState = { ...stateRef.current, ...patch };
|
||||
void loadSubjects(nextState);
|
||||
}
|
||||
|
||||
function handleSearchInput(value) {
|
||||
setSearchInput(value);
|
||||
window.clearTimeout(searchTimer.current);
|
||||
searchTimer.current = window.setTimeout(() => {
|
||||
applyState({ query: value.trim(), page: 0 });
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
async function submitCreate(event) {
|
||||
event.preventDefault();
|
||||
const name = createName.trim();
|
||||
const code = createCode.trim();
|
||||
if (!name || !code || !createDepartmentId) {
|
||||
setCreateAlert({ text: 'Заполните все поля', isError: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const created = await createSubject({ name, code, departmentId: createDepartmentId });
|
||||
setCreateAlert({ text: `Дисциплина «${created.name || name}» создана`, isError: false });
|
||||
setCreateName('');
|
||||
setCreateCode('');
|
||||
await loadSubjects(stateRef.current);
|
||||
} catch (error) {
|
||||
setCreateAlert({ text: error.message || 'Ошибка создания', isError: true });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmArchiveSubject(subject) {
|
||||
setConfirmState({
|
||||
title: 'Архивировать дисциплину?',
|
||||
message: 'Дисциплина станет недоступна для новых назначений. Исторические данные сохранятся.',
|
||||
confirmLabel: 'Архивировать',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await archiveSubject(subject.id);
|
||||
await loadSubjects(stateRef.current);
|
||||
} catch (error) {
|
||||
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function openTeachersModal(subject) {
|
||||
setTeachersModal({
|
||||
subject,
|
||||
alert: { text: '', isError: false },
|
||||
selectedTeacher: null
|
||||
});
|
||||
}
|
||||
|
||||
async function reloadAssignments() {
|
||||
const subjectIds = (pageData?.items || []).map(subject => subject.id);
|
||||
const loaded = await fetchTeacherAssignments(subjectIds);
|
||||
setAssignments(loaded || []);
|
||||
return loaded || [];
|
||||
}
|
||||
|
||||
async function searchTeachers(query, selectedId) {
|
||||
const teachers = await apiClient.get(teachersSearchEndpoint(query, selectedId));
|
||||
const modalSubjectId = teachersModal?.subject?.id;
|
||||
const assigned = new Set(
|
||||
(assignments || [])
|
||||
.filter(item => String(item.subjectId) === String(modalSubjectId))
|
||||
.map(item => String(item.userId))
|
||||
);
|
||||
return (teachers || []).filter(teacher =>
|
||||
!assigned.has(String(teacher.id)) || String(teacher.id) === String(selectedId));
|
||||
}
|
||||
|
||||
async function submitTeacherAssignment(event) {
|
||||
event.preventDefault();
|
||||
const modal = teachersModal;
|
||||
if (!modal?.selectedTeacher) {
|
||||
setTeachersModal({ ...modal, alert: { text: 'Выберите преподавателя', isError: true } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await assignTeacher({ userId: modal.selectedTeacher.id, subjectId: modal.subject.id });
|
||||
setTeachersModal(current => (
|
||||
current ? { ...current, alert: { text: 'Преподаватель успешно привязан', isError: false }, selectedTeacher: null } : current
|
||||
));
|
||||
await reloadAssignments();
|
||||
} catch (error) {
|
||||
setTeachersModal({ ...modal, alert: { text: error.message || 'Ошибка привязки', isError: true } });
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDeleteAssignment(assignment) {
|
||||
setConfirmState({
|
||||
title: 'Удалить привязку преподавателя?',
|
||||
message: 'Преподаватель больше не будет доступен для этой дисциплины в новых правилах.',
|
||||
confirmLabel: 'Удалить привязку',
|
||||
danger: true,
|
||||
action: async () => {
|
||||
try {
|
||||
await removeTeacherAssignment({
|
||||
userId: assignment.userId,
|
||||
subjectId: assignment.subjectId
|
||||
});
|
||||
await reloadAssignments();
|
||||
} catch (error) {
|
||||
setTeachersModal(current => (
|
||||
current ? { ...current, alert: { text: error.message || 'Ошибка удаления привязки', isError: true } } : current
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runConfirm() {
|
||||
const confirm = confirmState;
|
||||
setConfirmState(null);
|
||||
await confirm.action();
|
||||
}
|
||||
|
||||
const teachersBySubject = groupTeachersBySubject(assignments);
|
||||
const subjects = pageData?.items || [];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card create-card">
|
||||
<h2>Новая дисциплина</h2>
|
||||
<form onSubmit={submitCreate}>
|
||||
<div className="form-row">
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-subject-name">Название дисциплины</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-subject-name"
|
||||
placeholder="Высшая математика"
|
||||
required
|
||||
value={createName}
|
||||
onChange={event => setCreateName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-subject-code">Код предмета</label>
|
||||
<input
|
||||
type="text"
|
||||
id="new-subject-code"
|
||||
placeholder="Например: MATH101"
|
||||
required
|
||||
value={createCode}
|
||||
onChange={event => setCreateCode(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="new-subject-department">Кафедра</label>
|
||||
<select
|
||||
id="new-subject-department"
|
||||
required
|
||||
value={createDepartmentId}
|
||||
onChange={event => setCreateDepartmentId(event.target.value)}
|
||||
>
|
||||
<option value="">Выберите кафедру</option>
|
||||
{(departments || []).map(department => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{departmentOptionLabel(department)}
|
||||
</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">
|
||||
<h2>Все дисциплины</h2>
|
||||
<div className="registry-toolbar">
|
||||
<div className="form-group registry-search">
|
||||
<label htmlFor="subjects-search">Поиск</label>
|
||||
<input
|
||||
type="search"
|
||||
id="subjects-search"
|
||||
placeholder="Название или код дисциплины"
|
||||
value={searchInput}
|
||||
onChange={event => handleSearchInput(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="subjects-department-filter">Кафедра</label>
|
||||
<select
|
||||
id="subjects-department-filter"
|
||||
value={state.departmentId}
|
||||
onChange={event => applyState({ departmentId: event.target.value, page: 0 })}
|
||||
>
|
||||
<option value="">Все кафедры</option>
|
||||
{(departments || []).map(department => (
|
||||
<option key={department.id} value={department.id}>
|
||||
{department.departmentName || department.name || department.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="subjects-sort">Сортировка</label>
|
||||
<select
|
||||
id="subjects-sort"
|
||||
value={state.sort}
|
||||
onChange={event => applyState({ sort: event.target.value, page: 0 })}
|
||||
>
|
||||
{SUBJECT_SORT_OPTIONS.map(option => (
|
||||
<option key={option.value} value={option.value}>{option.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table id="subjects-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Название</th>
|
||||
<th>Код предмета</th>
|
||||
<th>Кафедра</th>
|
||||
<th>Преподаватели</th>
|
||||
<th className="th-actions">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{referenceError ? (
|
||||
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row">Ошибка загрузки справочников: {referenceError}</td></tr>
|
||||
) : tableError ? (
|
||||
<tr>
|
||||
<td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-error">
|
||||
<span>{tableError}</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary"
|
||||
onClick={() => void loadSubjects(stateRef.current)}
|
||||
>
|
||||
Повторить
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
) : tableLoading ? (
|
||||
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка дисциплин...</td></tr>
|
||||
) : subjects.length === 0 ? (
|
||||
<tr><td colSpan={SUBJECT_TABLE_COLUMNS} className="loading-row view-state-empty">{subjectsEmptyMessage(state)}</td></tr>
|
||||
) : subjects.map(subject => {
|
||||
const teachers = teachersBySubject.get(String(subject.id)) || [];
|
||||
return (
|
||||
<tr key={subject.id}>
|
||||
<td>{subject.id}</td>
|
||||
<td><strong>{subject.name}</strong></td>
|
||||
<td>{subject.code || '-'}</td>
|
||||
<td>{departmentLabel(departments || [], subject.departmentId)}</td>
|
||||
<td>
|
||||
<div className="badge-list">
|
||||
{teachers.length === 0 ? (
|
||||
<span className="text-muted"><small>Нет преподавателей</small></span>
|
||||
) : teachers.map(teacher => (
|
||||
<span className="badge badge-ef badge--tight" key={teacher.userId}>
|
||||
{teacher.fullName || teacher.username}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="table-cell-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-secondary btn-manage-teachers"
|
||||
onClick={() => openTeachersModal(subject)}
|
||||
>
|
||||
Преподаватели
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger btn-delete-subject"
|
||||
onClick={() => confirmArchiveSubject(subject)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</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>
|
||||
|
||||
{teachersModal && (
|
||||
<div
|
||||
className="modal-overlay open"
|
||||
onClick={event => {
|
||||
if (event.target === event.currentTarget) setTeachersModal(null);
|
||||
}}
|
||||
>
|
||||
<div className="modal-content card modal-content--medium">
|
||||
<div className="modal-header-row">
|
||||
<div>
|
||||
<h2>Преподаватели дисциплины</h2>
|
||||
<div className="modal-subtitle">
|
||||
{teachersModal.subject.code ? `${teachersModal.subject.code} · ` : ''}{teachersModal.subject.name}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon-md btn-ghost modal-close modal-close--static"
|
||||
aria-label="Закрыть окно"
|
||||
onClick={() => setTeachersModal(null)}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="card create-card modal-subcard">
|
||||
<h3>Добавить привязку</h3>
|
||||
<form onSubmit={submitTeacherAssignment}>
|
||||
<div className="form-row form-row--bottom">
|
||||
<div className="form-group form-group--flush">
|
||||
<label htmlFor="manage-ts-teacher-select">Преподаватель</label>
|
||||
<AsyncCombobox
|
||||
ariaLabel="Поиск преподавателя"
|
||||
placeholder="Найдите преподавателя по ФИО"
|
||||
emptyLabel="Преподаватели не найдены"
|
||||
load={(query, selectedId) => searchTeachers(query, selectedId)}
|
||||
optionLabel={teacher => teacher.fullName || teacher.username}
|
||||
optionDescription={teacher => [teacher.jobTitle, teacher.departmentName].filter(Boolean).join(' · ')}
|
||||
onSelect={teacher => setTeachersModal(current => (
|
||||
current ? { ...current, selectedTeacher: teacher } : current
|
||||
))}
|
||||
selectedLabel={teachersModal.selectedTeacher
|
||||
? (teachersModal.selectedTeacher.fullName || teachersModal.selectedTeacher.username)
|
||||
: ''}
|
||||
selectedValue={teachersModal.selectedTeacher
|
||||
? String(teachersModal.selectedTeacher.id)
|
||||
: ''}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="btn btn-md btn-primary btn-inline">Привязать</button>
|
||||
</div>
|
||||
<div
|
||||
className={`form-alert${teachersModal.alert.text ? (teachersModal.alert.isError ? ' error' : ' success') : ''}`}
|
||||
role="alert"
|
||||
>
|
||||
{teachersModal.alert.text}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="table-wrap">
|
||||
<table id="manage-ts-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Преподаватель</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(teachersBySubject.get(String(teachersModal.subject.id)) || []).length === 0 ? (
|
||||
<tr><td colSpan="2" className="loading-row">Преподаватели не привязаны</td></tr>
|
||||
) : (teachersBySubject.get(String(teachersModal.subject.id)) || []).map(assignment => (
|
||||
<tr key={assignment.userId}>
|
||||
<td>
|
||||
<strong>{assignment.fullName || assignment.username}</strong>{' '}
|
||||
<span className="text-muted">({assignment.username})</span>
|
||||
</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-danger btn-delete-ts"
|
||||
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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user