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:
372
frontend/react/admin/tabs/TeacherRequestsTab.jsx
Normal file
372
frontend/react/admin/tabs/TeacherRequestsTab.jsx
Normal file
@@ -0,0 +1,372 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user