AURORA-1690: показ пользователя и ролей по клику на логотип АРМ КА
Closes #184 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
143a484a72
commit
c65f00c837
10
openspec/changes/184-user-role/specs/app-shell-ui/spec.md
Normal file
10
openspec/changes/184-user-role/specs/app-shell-ui/spec.md
Normal file
@ -0,0 +1,10 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Текущий пользователь и его роли в боковом меню
|
||||
Клик по логотипу «АРМ КА» в раскрытом боковом меню SHALL раскрывать блок с данными текущего пользователя из `GET /me`:
|
||||
ФИО, логин (email), роли (коды как отдаёт API, через запятую; без ролей — «не назначена») и филиал. Повторный клик или
|
||||
клик вне блока SHALL закрывать его. Роли показываются как есть, по ним доступ на фронте не решается.
|
||||
|
||||
#### Scenario: Пользователь с ролью
|
||||
- **WHEN** аналитик с ролью ANALYST кликает по логотипу «АРМ КА»
|
||||
- **THEN** раскрывается блок с его ФИО, email, ролью ANALYST и филиалом
|
||||
@ -4,12 +4,12 @@ import { Archive, Building2, ScrollText, UserCog2, Users2 } from 'lucide-react';
|
||||
import { type ReactNode, memo, useState } from 'react';
|
||||
import { NavLink, useNavigate } from 'react-router-dom';
|
||||
import { USERS_ADMIN } from '../../auth/permissions';
|
||||
import { Logo } from '../../components/Logo';
|
||||
import { createConsolidation } from '../../services/api/consolidations';
|
||||
import { getConsolidationsListThunk } from '../../store/slices/consolidations/thunks';
|
||||
import { useAppDispatch } from '../../store/store';
|
||||
import { quarterLabel } from '../../utils/labels';
|
||||
import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
|
||||
import { UserMenu } from '../UserMenu/UserMenu';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type CollapsedIconProps = {
|
||||
@ -164,7 +164,7 @@ export function Sidebar() {
|
||||
trigger={null}>
|
||||
<div className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ''}`}>
|
||||
<div className={styles.topbar}>
|
||||
{!isCollapsed && <Logo />}
|
||||
{!isCollapsed && <UserMenu />}
|
||||
<button className={styles.collapseBtn} onClick={() => setIsCollapsed(!isCollapsed)}>
|
||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</button>
|
||||
|
||||
26
web/src/widgets/UserMenu/UserMenu.tsx
Normal file
26
web/src/widgets/UserMenu/UserMenu.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { Popover } from 'antd';
|
||||
import { useCurrentUser } from '../../auth/MeContext';
|
||||
import { Logo } from '../../components/Logo';
|
||||
import { userInfoRows } from './userInfo';
|
||||
|
||||
/** Логотип «АРМ КА»; по клику раскрывается блок с текущим пользователем и его ролями (`GET /me`). */
|
||||
export function UserMenu({ compact = false }: { compact?: boolean }) {
|
||||
const me = useCurrentUser();
|
||||
const content = (
|
||||
<dl style={{ margin: 0, minWidth: 220 }} data-testid='user-info'>
|
||||
{userInfoRows(me).map((r) => (
|
||||
<div key={r.label} style={{ display: 'flex', gap: 8, padding: '2px 0' }}>
|
||||
<dt style={{ color: 'rgba(55, 65, 81, 0.6)', width: 90, flexShrink: 0 }}>{r.label}</dt>
|
||||
<dd style={{ margin: 0, fontWeight: 500, wordBreak: 'break-word' }}>{r.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
return (
|
||||
<Popover trigger='click' placement='bottomLeft' content={content}>
|
||||
<button type='button' title='Текущий пользователь' style={{ background: 'none', border: 0, padding: 0, cursor: 'pointer' }}>
|
||||
<Logo compact={compact} />
|
||||
</button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
34
web/src/widgets/UserMenu/userInfo.test.ts
Normal file
34
web/src/widgets/UserMenu/userInfo.test.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Me } from '../../api/types';
|
||||
import { userInfoRows } from './userInfo';
|
||||
|
||||
const base: Me = {
|
||||
id: '1',
|
||||
email: 'analyst@rshb.ru',
|
||||
full_name: 'Иванов Иван',
|
||||
regional_office: { id: 'o1', name: 'Центральный офис', is_head_office: true },
|
||||
business_segment: null,
|
||||
roles: ['ANALYST'],
|
||||
permissions: [],
|
||||
};
|
||||
|
||||
describe('userInfoRows', () => {
|
||||
it('показывает ФИО, логин, роль и филиал', () => {
|
||||
expect(userInfoRows(base)).toEqual([
|
||||
{ label: 'Пользователь', value: 'Иванов Иван' },
|
||||
{ label: 'Логин', value: 'analyst@rshb.ru' },
|
||||
{ label: 'Роль', value: 'ANALYST' },
|
||||
{ label: 'Филиал', value: 'Центральный офис' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('несколько ролей — через запятую, подпись во множественном числе', () => {
|
||||
const row = userInfoRows({ ...base, roles: ['ANALYST', 'VERIFIER'] }).find((r) => r.value.includes('VERIFIER'));
|
||||
expect(row).toEqual({ label: 'Роли', value: 'ANALYST, VERIFIER' });
|
||||
});
|
||||
|
||||
it('без ролей пишет «не назначена»', () => {
|
||||
const row = userInfoRows({ ...base, roles: [] }).find((r) => r.label === 'Роль');
|
||||
expect(row?.value).toBe('не назначена');
|
||||
});
|
||||
});
|
||||
14
web/src/widgets/UserMenu/userInfo.ts
Normal file
14
web/src/widgets/UserMenu/userInfo.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import type { Me } from '../../api/types';
|
||||
|
||||
export type UserInfoRow = { label: string; value: string };
|
||||
|
||||
/** Строки блока «Текущий пользователь»: ФИО, email, роли (коды как отдаёт API), филиал. Пустые значения не показываем. */
|
||||
export function userInfoRows(me: Me): UserInfoRow[] {
|
||||
const rows: UserInfoRow[] = [
|
||||
{ label: 'Пользователь', value: me.full_name },
|
||||
{ label: 'Логин', value: me.email },
|
||||
{ label: me.roles.length > 1 ? 'Роли' : 'Роль', value: me.roles.length > 0 ? me.roles.join(', ') : 'не назначена' },
|
||||
{ label: 'Филиал', value: me.regional_office?.name ?? '' },
|
||||
];
|
||||
return rows.filter((r) => r.value !== '');
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user