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

373 lines
15 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 { PromptDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import { requestCountLabel } from '../admin-service.js';
import {
REQUEST_STATUS_OPTIONS,
REQUEST_TABLE_COLUMNS,
approvePayloadFromFields,
approveRequest,
fetchDepartments,
fetchTeacherRequestsPage,
initialTeacherRequestsState,
pendingCountDetail,
rejectRequest,
requestDepartmentLabel,
reviewedStatusLabel,
teacherRequestsEmptyMessage,
teacherRequestsUrlState
} from '../teacher-requests-service.js';
const SEARCH_DEBOUNCE_MS = 320;
export function TeacherRequestsTab() {
const [state, setState] = useState(() => initialTeacherRequestsState(
typeof window === 'undefined' ? {} : readUrlState({
requestQuery: '',
requestStatus: 'PENDING',
requestPage: '0',
requestSize: '25'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [departments, setDepartments] = useState([]);
const [departmentsError, setDepartmentsError] = useState(false);
const [pageData, setPageData] = useState(null);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [alert, setAlert] = useState({ text: '', isError: false });
const [rejectState, setRejectState] = useState(null);
const searchTimer = useRef(null);
const requestNumber = useRef(0);
const stateRef = useRef(state);
stateRef.current = state;
useEffect(() => {
let active = true;
(async () => {
try {
const loadedDepartments = await fetchDepartments();
if (!active) return;
setDepartments(loadedDepartments || []);
await loadRequests(stateRef.current);
} catch {
if (!active) return;
setTableLoading(false);
setDepartmentsError(true);
}
})();
return () => {
active = false;
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadRequests(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
setAlert(current => (current.text ? { text: '', isError: false } : current));
try {
let page = await fetchTeacherRequestsPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchTeacherRequestsPage(nextState);
}
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setTableLoading(false);
writeUrlState(teacherRequestsUrlState(nextState));
document.dispatchEvent(new CustomEvent('teacherRequestsChanged', {
detail: pendingCountDetail(nextState, page.totalItems)
}));
trackUxEvent('реестр_заявок_загружен', {
total: Number(page.totalItems || 0),
page: nextState.page
});
} catch {
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(null);
setTableLoading(false);
setTableError('Не удалось загрузить заявки преподавателей');
}
}
function applyState(patch) {
void loadRequests({ ...stateRef.current, ...patch });
}
function handleSearchInput(value) {
setSearchInput(value);
window.clearTimeout(searchTimer.current);
searchTimer.current = window.setTimeout(() => {
applyState({ query: value.trim(), page: 0 });
}, SEARCH_DEBOUNCE_MS);
}
async function approve(requestId, fields) {
const { payload, error } = approvePayloadFromFields(fields);
if (error) {
setAlert({ text: error, isError: true });
return;
}
try {
await approveRequest(requestId, payload);
setAlert({ text: 'Преподаватель создан, заявка одобрена', isError: false });
await loadRequests(stateRef.current);
} catch (err) {
setAlert({ text: err.message || 'Ошибка одобрения заявки', isError: true });
}
}
async function confirmReject(reviewComment) {
const requestId = rejectState?.requestId;
setRejectState(null);
if (!requestId) return;
try {
await rejectRequest(requestId, reviewComment);
setAlert({ text: 'Заявка отклонена', isError: false });
await loadRequests(stateRef.current);
} catch (err) {
setAlert({ text: err.message || 'Ошибка отклонения заявки', isError: true });
}
}
const requests = pageData?.items || [];
const countLabel = requestCountLabel(pageData ? Number(pageData.totalItems || 0) : 0);
return (
<div className="card">
<div className="card-header-row">
<h2>Заявки на преподавателей</h2>
<div className="teacher-request-toolbar">
<span className="muted">{countLabel}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadRequests(stateRef.current)}
>
Обновить
</button>
</div>
</div>
<div
className={`form-alert${alert.text ? (alert.isError ? ' error' : ' success') : ''}`}
role="alert"
>
{alert.text}
</div>
<div className="registry-toolbar">
<div className="form-group registry-search">
<label htmlFor="teacher-requests-search">Поиск</label>
<input
type="search"
id="teacher-requests-search"
placeholder="ФИО, логин, должность или кафедра"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="teacher-requests-status">Статус</label>
<select
id="teacher-requests-status"
value={state.status}
onChange={event => applyState({ status: event.target.value, page: 0 })}
>
{REQUEST_STATUS_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>Кафедра</th>
<th>Логин</th>
<th>ФИО</th>
<th>Должность</th>
<th>Пароль</th>
<th>Комментарий</th>
<th>Действия</th>
</tr>
</thead>
<tbody id="teacher-requests-tbody">
{departmentsError ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
Не удалось загрузить заявки преподавателей
</td>
</tr>
) : tableError ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadRequests(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row">
<span className="state-spinner" aria-hidden="true" />Загрузка заявок...
</td>
</tr>
) : requests.length === 0 ? (
<tr>
<td colSpan={REQUEST_TABLE_COLUMNS} className="loading-row view-state-empty">
{teacherRequestsEmptyMessage(state)}
</td>
</tr>
) : requests.map(request => (
<TeacherRequestRow
key={request.id}
request={request}
departments={departments}
onApprove={approve}
onReject={requestId => setRejectState({ requestId })}
/>
))}
</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 })}
/>
)}
{rejectState && (
<PromptDialog
title="Отклонить заявку?"
message="Комментарий увидит кафедра, отправившая заявку."
confirmLabel="Отклонить"
danger
input={{
label: 'Причина отклонения',
placeholder: 'Укажите, что нужно исправить',
required: true,
requiredMessage: 'Укажите причину отклонения'
}}
onConfirm={value => void confirmReject(value)}
onClose={() => setRejectState(null)}
/>
)}
</div>
);
}
// Строка заявки хранит черновик полей локально: значения инпутов строки
// отправляются при одобрении — как в legacy-реестре.
function TeacherRequestRow({ request, departments, onApprove, onReject }) {
const [departmentId, setDepartmentId] = useState(String(request.departmentId ?? ''));
const [username, setUsername] = useState(request.username || '');
const [fullName, setFullName] = useState(request.fullName || '');
const [jobTitle, setJobTitle] = useState(request.jobTitle || '');
const [password, setPassword] = useState('');
function approve() {
onApprove(request.id, { departmentId, username, fullName, jobTitle, password });
}
return (
<tr>
<td>
<select
value={departmentId}
onChange={event => setDepartmentId(event.target.value)}
>
{departments.map(department => (
<option key={department.id} value={department.id}>
{requestDepartmentLabel(department)}
</option>
))}
</select>
</td>
<td>
<input
type="text"
maxLength={50}
autoComplete="username"
value={username}
onChange={event => setUsername(event.target.value)}
/>
</td>
<td>
<input
type="text"
maxLength={255}
value={fullName}
onChange={event => setFullName(event.target.value)}
/>
</td>
<td>
<input
type="text"
maxLength={255}
value={jobTitle}
onChange={event => setJobTitle(event.target.value)}
/>
</td>
<td>
<input
type="password"
className="teacher-request-password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
value={password}
onChange={event => setPassword(event.target.value)}
/>
</td>
<td>{request.comment || '-'}</td>
<td>
<div className="teacher-request-actions">
{request.status === 'PENDING' ? (
<>
<button
type="button"
className="btn btn-sm btn-primary teacher-request-approve"
onClick={approve}
>
Одобрить
</button>
<button
type="button"
className="btn btn-sm btn-danger-subtle teacher-request-reject"
onClick={() => onReject(request.id)}
>
Отклонить
</button>
</>
) : (
<span className="badge">{reviewedStatusLabel(request.status)}</span>
)}
</div>
</td>
</tr>
);
}