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

400 lines
18 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 { AlertDialog, ConfirmDialog } from '../../shared/ui/ConfirmDialog.jsx';
import { Pagination } from '../../shared/ui/Pagination.jsx';
import {
ROLE_FILTER_OPTIONS,
USER_SORT_OPTIONS,
USER_TABLE_COLUMNS,
archiveUser,
createUser,
fetchUsersPage,
initialUsersState,
restoreUser,
roleBadgeClass,
roleLabel,
usersEmptyMessage,
usersUrlState
} from '../users-service.js';
const SEARCH_DEBOUNCE_MS = 320;
export function UsersTab() {
const [state, setState] = useState(() => initialUsersState(
typeof window === 'undefined' ? {} : readUrlState({
userQuery: '',
userRole: '',
userPage: '0',
userSize: '25',
userSort: 'fullName,asc'
})
));
const [searchInput, setSearchInput] = useState(() => state.query);
const [pageData, setPageData] = useState(null);
const [tableError, setTableError] = useState('');
const [tableLoading, setTableLoading] = useState(true);
const [createUsername, setCreateUsername] = useState('');
const [createPassword, setCreatePassword] = useState('');
const [createRole, setCreateRole] = useState('STUDENT');
const [createFullName, setCreateFullName] = useState('');
const [createJobTitle, setCreateJobTitle] = useState('');
const [createDepartmentId, setCreateDepartmentId] = useState('');
const [createAlert, setCreateAlert] = useState({ text: '', isError: false });
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(() => {
void loadUsers(stateRef.current);
return () => {
requestNumber.current += 1;
};
}, []);
useEffect(() => () => {
window.clearTimeout(searchTimer.current);
}, []);
async function loadUsers(nextState) {
const currentRequest = requestNumber.current + 1;
requestNumber.current = currentRequest;
setTableLoading(true);
setTableError('');
try {
let page = await fetchUsersPage(nextState);
if (!(page.items || []).length && page.totalItems > 0 && nextState.page > 0) {
nextState = { ...nextState, page: Math.max(0, page.totalPages - 1) };
page = await fetchUsersPage(nextState);
}
if (requestNumber.current !== currentRequest) return;
setState(nextState);
setPageData(page);
setTableLoading(false);
writeUrlState(usersUrlState(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) {
void loadUsers({ ...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 submitCreate(event) {
event.preventDefault();
const username = createUsername.trim();
const password = createPassword;
const fullName = createFullName.trim();
const jobTitle = createJobTitle.trim();
if (!username || !password || !fullName || !jobTitle || !createDepartmentId) {
setCreateAlert({ text: 'Заполните все поля', isError: true });
return;
}
if (password.length < 8) {
setCreateAlert({ text: 'Пароль должен быть не короче 8 символов', isError: true });
return;
}
try {
const created = await createUser({
username,
password,
role: createRole,
fullName,
jobTitle,
departmentId: createDepartmentId
});
setCreateAlert({ text: `Пользователь «${created.username}» создан`, isError: false });
setCreateUsername('');
setCreatePassword('');
setCreateFullName('');
setCreateJobTitle('');
setCreateDepartmentId('');
await loadUsers(stateRef.current);
} catch (error) {
setCreateAlert({ text: error.message || 'Ошибка соединения', isError: true });
}
}
function confirmArchiveUser(user) {
setConfirmState({
title: 'Архивировать пользователя?',
message: 'Доступ будет отключён, а история расписания и связей сохранится.',
confirmLabel: 'Архивировать',
danger: true,
action: async () => {
try {
await archiveUser(user.id);
await loadUsers(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка архивирования', message: error.message || 'Ошибка архивирования' });
}
}
});
}
async function restore(user) {
try {
await restoreUser(user.id);
await loadUsers(stateRef.current);
} catch (error) {
setAlertState({ title: 'Ошибка восстановления', message: error.message || 'Ошибка восстановления' });
}
}
async function runConfirm() {
const confirm = confirmState;
setConfirmState(null);
await confirm.action();
}
const users = pageData?.items || [];
return (
<>
<div className="card create-card">
<h2>Новый пользователь</h2>
<form onSubmit={submitCreate}>
<div className="form-row">
<div className="form-group">
<label htmlFor="new-username">Имя пользователя</label>
<input
type="text"
id="new-username"
placeholder="Имя пользователя"
autoComplete="username"
required
value={createUsername}
onChange={event => setCreateUsername(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-password">Пароль</label>
<input
type="password"
id="new-password"
placeholder="Минимум 8 символов"
autoComplete="new-password"
required
value={createPassword}
onChange={event => setCreatePassword(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-role">Роль</label>
<select
id="new-role"
value={createRole}
onChange={event => setCreateRole(event.target.value)}
>
<option value="STUDENT">Студент</option>
<option value="TEACHER">Преподаватель</option>
<option value="DEPARTMENT">Кафедра</option>
<option value="EDUCATION_OFFICE">Учебный отдел</option>
<option value="SCHEDULE_VIEWER">Просмотр расписаний</option>
<option value="ADMIN">Администратор</option>
</select>
</div>
<div className="form-group">
<label htmlFor="new-fullname">ФИО пользователя</label>
<input
type="text"
id="new-fullname"
placeholder="Иванов Иван Иванович"
required
value={createFullName}
onChange={event => setCreateFullName(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-jobtitle">Должность</label>
<input
type="text"
id="new-jobtitle"
placeholder="Студент / Доцент"
required
value={createJobTitle}
onChange={event => setCreateJobTitle(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="new-department">ID Кафедры</label>
<input
type="number"
id="new-department"
placeholder="ID"
required
value={createDepartmentId}
onChange={event => setCreateDepartmentId(event.target.value)}
/>
</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="users-search">Поиск</label>
<input
type="search"
id="users-search"
placeholder="Логин, ФИО или должность"
value={searchInput}
onChange={event => handleSearchInput(event.target.value)}
/>
</div>
<div className="form-group">
<label htmlFor="users-role-filter">Роль</label>
<select
id="users-role-filter"
value={state.role}
onChange={event => applyState({ role: event.target.value, page: 0 })}
>
<option value="">Все роли</option>
{ROLE_FILTER_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
<div className="form-group">
<label htmlFor="users-sort">Сортировка</label>
<select
id="users-sort"
value={state.sort}
onChange={event => applyState({ sort: event.target.value, page: 0 })}
>
{USER_SORT_OPTIONS.map(option => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</div>
</div>
<div className="table-wrap">
<table id="users-table">
<thead>
<tr>
<th>ID</th>
<th>Имя пользователя</th>
<th>ФИО</th>
<th>Должность</th>
<th>Кафедра</th>
<th>Роль</th>
<th className="th-actions">Действия</th>
</tr>
</thead>
<tbody>
{tableError ? (
<tr>
<td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-error">
<span>{tableError}</span>
<button
type="button"
className="btn btn-sm btn-secondary"
onClick={() => void loadUsers(stateRef.current)}
>
Повторить
</button>
</td>
</tr>
) : tableLoading ? (
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row"><span className="state-spinner" aria-hidden="true" />Загрузка пользователей...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={USER_TABLE_COLUMNS} className="loading-row view-state-empty">{usersEmptyMessage(state)}</td></tr>
) : users.map(user => (
<tr key={user.id}>
<td>{user.id}</td>
<td>{user.username}</td>
<td>{user.fullName || '-'}</td>
<td>{user.jobTitle || '-'}</td>
<td>{user.departmentName || '-'}</td>
<td><span className={`badge ${roleBadgeClass(user.role)}`}>{roleLabel(user.role)}</span></td>
<td className="table-cell-actions">
{user.status === 'ARCHIVED' ? (
<button
type="button"
className="btn btn-sm btn-secondary btn-restore-user"
onClick={() => void restore(user)}
>
Восстановить
</button>
) : (
<button
type="button"
className="btn btn-sm btn-danger-subtle btn-archive-user"
onClick={() => confirmArchiveUser(user)}
>
Архивировать
</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>
{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)}
/>
)}
</>
);
}