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