Files
magistr/frontend/react/admin/tabs/UsersTab.jsx
2026-10-02 04:07:56 +03:00

403 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 { CustomSelect } from '../../shared/ui/CustomSelect.jsx';
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) {
stateRef.current = nextState;
setState(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>
<CustomSelect
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>
</CustomSelect>
</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>
<CustomSelect
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>
))}
</CustomSelect>
</div>
<div className="form-group">
<label htmlFor="users-sort">Сортировка</label>
<CustomSelect
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>
))}
</CustomSelect>
</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)}
/>
)}
</>
);
}