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

536 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { readUrlState, 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)}
/>
)}
</>
);
}