Маршрут /admin (вкладки «Пользователи» и «Матрица прав») и пункт «Система → Пользователи и роли» в меню; без USERS_ADMIN в /me — экран «Нет доступа», таблицы не монтируются и запросов к /admin/* нет, пункт меню скрыт. Почему без Redux: данные экрана нужны только ему, после каждой правки перечитываются с сервера — хватает локального state и маленького хука useAsyncData (отбрасывает устаревшие ответы и ответы после ухода со страницы). Клиент один — api/client.ts (Bearer из sessionStorage, ошибки сервера -> ApiError с кодом и текстом); ошибки показываются через App.useApp().message (корень обёрнут в antd App). Экран показывает только серверное состояние: при отказе (409 LAST_ADMIN и др.) — уведомление и перечитывание, матрица сохраняет роли по очереди. РАИСА появится в auth/: токен даст SSO вместо dev-входа; этот экран и клиент не меняются. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
134 lines
5.0 KiB
TypeScript
134 lines
5.0 KiB
TypeScript
import { Form, Input, Modal, Select } from 'antd';
|
|
import { useState } from 'react';
|
|
import { ApiError, api } from '../../api/client';
|
|
import type { AdminUser, BusinessSegment, RegionalOffice, Role, UserCreate } from '../../api/types';
|
|
import { useMountedRef, useNotifyError } from './hooks';
|
|
|
|
type Props = {
|
|
open: boolean;
|
|
roles: Role[];
|
|
offices: RegionalOffice[];
|
|
onClose: () => void;
|
|
onCreated: (user: AdminUser) => void;
|
|
};
|
|
|
|
/** Значения формы; пустой сегмент (`undefined`) уходит на сервер как `null` — «не задан». */
|
|
type FormValues = {
|
|
email: string;
|
|
full_name: string;
|
|
employee_no?: string;
|
|
regional_office_id: string;
|
|
business_segment?: BusinessSegment;
|
|
roles?: string[];
|
|
};
|
|
|
|
// Коды сегмента — как enum `BusinessSegment` бэкенда; «не задан» — очищенное поле
|
|
const SEGMENT_OPTIONS: { value: BusinessSegment; label: string }[] = [
|
|
{ value: 'DKB', label: 'DKB' },
|
|
{ value: 'DMSB', label: 'DMSB' },
|
|
{ value: 'DM', label: 'DM' },
|
|
];
|
|
|
|
/**
|
|
* Окно создания пользователя (`POST /admin/users`). Проверки формы — на клиенте (формат email, обязательные поля),
|
|
* окончательная — на сервере: занятый email (409 `EMAIL_EXISTS`) показывается под полем email, прочие ошибки —
|
|
* уведомлением. После закрытия поля очищаются.
|
|
*/
|
|
export function CreateUserModal({ open, roles, offices, onClose, onCreated }: Props) {
|
|
const [form] = Form.useForm<FormValues>();
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const notifyError = useNotifyError();
|
|
const mounted = useMountedRef();
|
|
|
|
const submit = async (values: FormValues) => {
|
|
setSubmitting(true);
|
|
const body: UserCreate = {
|
|
email: values.email.trim(),
|
|
full_name: values.full_name.trim(),
|
|
employee_no: values.employee_no?.trim() || null,
|
|
regional_office_id: values.regional_office_id,
|
|
business_segment: values.business_segment ?? null,
|
|
roles: values.roles ?? [],
|
|
};
|
|
try {
|
|
const user = await api.post<AdminUser>('/admin/users', body);
|
|
if (mounted.current) {
|
|
onCreated(user);
|
|
}
|
|
} catch (e: unknown) {
|
|
if (!mounted.current) {
|
|
return;
|
|
}
|
|
if (e instanceof ApiError && e.code === 'EMAIL_EXISTS') {
|
|
form.setFields([{ name: 'email', errors: [e.message] }]);
|
|
} else {
|
|
notifyError(e);
|
|
}
|
|
} finally {
|
|
if (mounted.current) {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Пока запрос идёт, окно не закрывается: иначе ответ придёт в уже закрытую форму
|
|
const cancel = () => {
|
|
if (!submitting) {
|
|
onClose();
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Modal
|
|
title='Новый пользователь'
|
|
open={open}
|
|
okText='Создать'
|
|
cancelText='Отмена'
|
|
onOk={() => form.submit()}
|
|
onCancel={cancel}
|
|
confirmLoading={submitting}
|
|
cancelButtonProps={{ disabled: submitting }}
|
|
mask={{ closable: !submitting }}
|
|
// форма смонтирована всегда (forceRender), поэтому её можно очистить после закрытия окна
|
|
forceRender
|
|
afterClose={() => form.resetFields()}>
|
|
<Form<FormValues> form={form} layout='vertical' onFinish={submit} disabled={submitting}>
|
|
<Form.Item
|
|
name='email'
|
|
label='Email'
|
|
rules={[
|
|
{ required: true, whitespace: true, message: 'Укажите email' },
|
|
{ type: 'email', message: 'Некорректный email' },
|
|
]}>
|
|
<Input placeholder='i.ivanov@example.ru' autoComplete='off' />
|
|
</Form.Item>
|
|
<Form.Item name='full_name' label='ФИО' rules={[{ required: true, whitespace: true, message: 'Укажите ФИО' }]}>
|
|
<Input placeholder='Иванов Иван Иванович' />
|
|
</Form.Item>
|
|
<Form.Item name='employee_no' label='Табельный номер'>
|
|
<Input />
|
|
</Form.Item>
|
|
<Form.Item name='regional_office_id' label='Филиал' rules={[{ required: true, message: 'Выберите филиал' }]}>
|
|
<Select
|
|
showSearch={{ optionFilterProp: 'label' }}
|
|
placeholder='Выберите филиал'
|
|
// в неактивный филиал нового пользователя не заводим
|
|
options={offices.filter((o) => o.is_active).map((o) => ({ value: o.id, label: o.name }))}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name='business_segment' label='Сегмент'>
|
|
<Select allowClear placeholder='— (не задан)' options={SEGMENT_OPTIONS} />
|
|
</Form.Item>
|
|
<Form.Item name='roles' label='Роли'>
|
|
<Select
|
|
mode='multiple'
|
|
placeholder='Без ролей'
|
|
// назначить можно только активную роль (сервер отвечает 422 UNKNOWN_ROLE на неактивную)
|
|
options={roles.filter((r) => r.is_active).map((r) => ({ value: r.code, label: `${r.code} — ${r.name}` }))}
|
|
/>
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
);
|
|
}
|