first commit from arm-core
This commit is contained in:
commit
383e4e3e5d
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
.env
|
||||
package-lock.json
|
||||
91
biome.json
Normal file
91
biome.json
Normal file
@ -0,0 +1,91 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"ignore": ["node_modules"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"lineWidth": 140,
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"ignore": ["*.json"],
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"a11y": {
|
||||
"useButtonType": "off",
|
||||
"noSvgWithoutTitle": "off"
|
||||
},
|
||||
"complexity": {
|
||||
"noForEach": "off",
|
||||
"noUselessTypeConstraint": "warn"
|
||||
},
|
||||
"correctness": {
|
||||
"noUnusedVariables": "error",
|
||||
"noUnusedImports": "error",
|
||||
"useExhaustiveDependencies": "info"
|
||||
},
|
||||
"nursery": {
|
||||
"noCommonJs": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateProperties": "error",
|
||||
"noNestedTernary": "error",
|
||||
"useAtIndex": "error",
|
||||
"useCollapsedIf": "error",
|
||||
"useConsistentCurlyBraces": "error"
|
||||
},
|
||||
"performance": {
|
||||
"noDelete": "off",
|
||||
"noAccumulatingSpread": "off"
|
||||
},
|
||||
"style": {
|
||||
"useDefaultSwitchClause": "error",
|
||||
"useForOf": "error",
|
||||
"useFragmentSyntax": "error",
|
||||
"useConsistentArrayType": "error",
|
||||
"useShorthandAssign": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noConsole": "warn",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"noEvolvingTypes": "error",
|
||||
"noExplicitAny": "warn",
|
||||
"noSkippedTests": "warn",
|
||||
"useAwait": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "single",
|
||||
"bracketSameLine": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"parser": {
|
||||
"allowComments": true
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"formatter": {
|
||||
"enabled": true
|
||||
},
|
||||
"parser": {
|
||||
"cssModules": true
|
||||
}
|
||||
}
|
||||
}
|
||||
38
package.json
Normal file
38
package.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "rsbuild build",
|
||||
"start": "rsbuild dev --open",
|
||||
"preview": "rsbuild preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"antd": "^6.5.0",
|
||||
"markdown-to-jsx": "^9.1.2",
|
||||
"pdfjs-dist": "^5.7.284",
|
||||
"react": "^18.3.1",
|
||||
"react-daisyui": "^5.0.5",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router-dom": "^7.8.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@rsbuild/core": "1.6.7",
|
||||
"@rsbuild/plugin-react": "^1.1.0",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"daisyui": "^4.12.10",
|
||||
"tailwindcss": "^3.4.13",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-linux-x64-musl": "^1.9.4",
|
||||
"raisa-fastapi-protected-web": "^1.0.0",
|
||||
"raisa-ui": "^1.0.0"
|
||||
}
|
||||
}
|
||||
5
postcss.config.cjs
Normal file
5
postcss.config.cjs
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
}
|
||||
}
|
||||
0
public/.gitkeep
Normal file
0
public/.gitkeep
Normal file
13
public/index.html
Normal file
13
public/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru-RU">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>Пример React-приложения</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
32
rsbuild.config.ts
Normal file
32
rsbuild.config.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { defineConfig, loadEnv } from '@rsbuild/core';
|
||||
import { pluginReact } from '@rsbuild/plugin-react';
|
||||
|
||||
const { publicVars, rawPublicVars } = loadEnv({prefixes: ['REACT_APP_']});
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
base: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : '/'
|
||||
},
|
||||
output: {
|
||||
filename: {
|
||||
js: '[name]_script.[contenthash:8].js',
|
||||
css: '[name]_style.[contenthash:8].css',
|
||||
},
|
||||
distPath: {
|
||||
favicon: 'static/image',
|
||||
},
|
||||
assetPrefix: (rawPublicVars['REACT_APP_ROOT_PATH'] !== undefined) ? rawPublicVars['REACT_APP_ROOT_PATH'] : './'
|
||||
},
|
||||
html: {
|
||||
template: './public/index.html',
|
||||
favicon: './src/assets/favicon.ico',
|
||||
},
|
||||
source: {
|
||||
include: [{ not: /[\\/]core-js[\\/]/ }],
|
||||
define: {
|
||||
...publicVars,
|
||||
'process.env': JSON.stringify(rawPublicVars),
|
||||
}
|
||||
},
|
||||
plugins: [pluginReact()],
|
||||
});
|
||||
0
src/App.css
Normal file
0
src/App.css
Normal file
33
src/App.tsx
Normal file
33
src/App.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import './App.css';
|
||||
import { Layout } from 'antd';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { AuthGate } from './auth/AuthGate';
|
||||
import { Config } from './conf/config';
|
||||
import { Admin } from './layouts/Admin';
|
||||
import { Main } from './layouts/Main';
|
||||
import { store } from './store/store';
|
||||
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
||||
|
||||
// Каркас: экраны по docs/context/10-user-journey.md добавляются срезами.
|
||||
function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<BrowserRouter basename={Config.APP_ADDRESS}>
|
||||
<AuthGate>
|
||||
<Layout hasSider={true} style={{ height: '100vh' }}>
|
||||
<Sidebar />
|
||||
<Layout.Content>
|
||||
<Routes>
|
||||
<Route path='/' element={<Main />} />
|
||||
<Route path='/admin' element={<Admin />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
119
src/api/client.ts
Normal file
119
src/api/client.ts
Normal file
@ -0,0 +1,119 @@
|
||||
import { clearToken, getToken } from '../auth/token';
|
||||
import { Config } from '../conf/config';
|
||||
|
||||
// Хвостовые слэши срезаются: API_URL='/' иначе дал бы `//api/v1` (адрес без хоста)
|
||||
export const API_BASE = `${Config.API_URL.replace(/\/+$/, '')}/api/v1`;
|
||||
|
||||
/** Событие `window`: сервер ответил 401, токен сброшен — интерфейс должен вернуться к входу. */
|
||||
export const UNAUTHORIZED_EVENT = 'armka:unauthorized';
|
||||
|
||||
const FALLBACK_MESSAGE = 'Не удалось выполнить запрос';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public code: string | undefined,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
type ErrorInfo = { code: string | undefined; message: string };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Тело ошибки бэкенда — `{"detail": …}` в одной из трёх форм:
|
||||
* строка (HTTPException) → message; объект `{code, message}` (доменная ошибка) → code + message;
|
||||
* массив (422 валидации pydantic) → `msg` первого элемента.
|
||||
*/
|
||||
function parseErrorBody(body: unknown): ErrorInfo {
|
||||
const detail = isRecord(body) ? body.detail : undefined;
|
||||
if (typeof detail === 'string') {
|
||||
return { code: undefined, message: detail };
|
||||
}
|
||||
if (isRecord(detail)) {
|
||||
const code = typeof detail.code === 'string' ? detail.code : undefined;
|
||||
const message = typeof detail.message === 'string' ? detail.message : FALLBACK_MESSAGE;
|
||||
return { code, message };
|
||||
}
|
||||
if (Array.isArray(detail)) {
|
||||
const first: unknown = detail[0];
|
||||
if (isRecord(first) && typeof first.msg === 'string') {
|
||||
return { code: undefined, message: first.msg };
|
||||
}
|
||||
}
|
||||
return { code: undefined, message: FALLBACK_MESSAGE };
|
||||
}
|
||||
|
||||
async function readErrorInfo(response: Response): Promise<ErrorInfo> {
|
||||
try {
|
||||
return parseErrorBody(await response.json());
|
||||
} catch {
|
||||
// тело не JSON (например, ответ прокси) — показываем общий текст
|
||||
return { code: undefined, message: FALLBACK_MESSAGE };
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
const token = getToken();
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
// JSON-заголовок только для строкового тела; FormData и др. отдаём браузеру как есть (он сам задаёт boundary)
|
||||
if (typeof init.body === 'string' && !headers.has('Content-Type')) {
|
||||
headers.set('Content-Type', 'application/json');
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${path}`, { ...init, headers });
|
||||
} catch (e: unknown) {
|
||||
// отмену запроса (AbortController) вызывающий код обрабатывает сам; остальное — сеть/CORS/DNS: текст браузера не показываем
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
throw e;
|
||||
}
|
||||
throw new ApiError(0, 'NETWORK', 'Нет связи с сервером');
|
||||
}
|
||||
|
||||
if (response.status === 401) {
|
||||
clearToken();
|
||||
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
|
||||
const info = await readErrorInfo(response);
|
||||
throw new ApiError(401, info.code, info.message);
|
||||
}
|
||||
if (!response.ok) {
|
||||
const info = await readErrorInfo(response);
|
||||
throw new ApiError(response.status, info.code, info.message);
|
||||
}
|
||||
if (response.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
// 2xx с пустым или не-JSON телом (страница прокси, 200 без содержимого) не должен давать «сырой» SyntaxError
|
||||
const text = await response.text();
|
||||
if (text === '') {
|
||||
return undefined as T;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as T;
|
||||
} catch {
|
||||
throw new ApiError(response.status, 'BAD_RESPONSE', 'Некорректный ответ сервера');
|
||||
}
|
||||
}
|
||||
|
||||
function withJsonBody(method: string, body: unknown): RequestInit {
|
||||
return body === undefined ? { method } : { method, body: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => apiFetch<T>(path),
|
||||
post: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('POST', body)),
|
||||
patch: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PATCH', body)),
|
||||
put: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PUT', body)),
|
||||
del: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||
};
|
||||
106
src/api/types.ts
Normal file
106
src/api/types.ts
Normal file
@ -0,0 +1,106 @@
|
||||
// Типы ответов API. Имена полей — как в Pydantic-схемах бэкенда (api/src/schemas/*.py), это источник истины.
|
||||
|
||||
export type BusinessSegment = 'DKB' | 'DMSB' | 'DM';
|
||||
|
||||
/** Как `PermissionObject` / `PermissionAction` в api/src/models/enums.py. */
|
||||
export type PermissionObject = 'DIRECTORY' | 'DOCUMENT' | 'CONSOLIDATION' | 'KFO' | 'EX' | 'EXPORT' | 'USERS' | 'AUDIT';
|
||||
export type PermissionAction = 'VIEW' | 'EDIT' | 'APPROVE' | 'VERIFY' | 'DELETE' | 'EXPORT' | 'ADMIN';
|
||||
|
||||
/** Страница списка верхнего уровня (`schemas/common.py:Page`). */
|
||||
export interface Page<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Филиал кратко: так он приходит в `GET /me` (без `is_active`). */
|
||||
export interface RegionalOfficeBrief {
|
||||
id: string;
|
||||
name: string;
|
||||
is_head_office: boolean;
|
||||
}
|
||||
|
||||
/** Филиал целиком: `GET /directories/regional-offices` (простой массив, не `Page`) и пользователи администратора. */
|
||||
export interface RegionalOffice extends RegionalOfficeBrief {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
/** `GET /me`: профиль, коды ролей и эффективные права (оба списка отсортированы по алфавиту). */
|
||||
export interface Me {
|
||||
id: string;
|
||||
email: string;
|
||||
full_name: string;
|
||||
regional_office: RegionalOfficeBrief;
|
||||
business_segment: BusinessSegment | null;
|
||||
roles: string[];
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** `POST /dev/token`: JWT для заголовка `Authorization: Bearer` (только при AUTH_DEV_ISSUER и DEBUG). */
|
||||
export interface DevTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
/** `UserOut`: пользователь для администратора. */
|
||||
export interface AdminUser {
|
||||
id: string;
|
||||
email: string | null;
|
||||
full_name: string;
|
||||
employee_no: string | null;
|
||||
regional_office: RegionalOffice;
|
||||
business_segment: BusinessSegment | null;
|
||||
is_active: boolean;
|
||||
roles: string[];
|
||||
created_at: string | null;
|
||||
updated_at: string | null;
|
||||
}
|
||||
|
||||
/** `UserCreate`: тело `POST /admin/users`. */
|
||||
export interface UserCreate {
|
||||
email: string;
|
||||
full_name: string;
|
||||
regional_office_id: string;
|
||||
employee_no?: string | null;
|
||||
business_segment?: BusinessSegment | null;
|
||||
roles?: string[];
|
||||
}
|
||||
|
||||
/** `UserUpdate`: тело `PATCH /admin/users/{id}` (передаются только изменяемые поля). */
|
||||
export interface UserUpdate {
|
||||
full_name?: string;
|
||||
employee_no?: string | null;
|
||||
regional_office_id?: string;
|
||||
business_segment?: BusinessSegment | null;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
/** `RoleOut`: роль с кодами её прав. */
|
||||
export interface Role {
|
||||
code: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
/** `PermissionOut`: право из справочника (только чтение). */
|
||||
export interface Permission {
|
||||
code: string;
|
||||
object_type: PermissionObject;
|
||||
action: PermissionAction;
|
||||
name: string;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
/** `RolesSet`: новый набор ролей пользователя. */
|
||||
export interface RolesSet {
|
||||
roles: string[];
|
||||
}
|
||||
|
||||
/** `PermissionsSet`: новый набор прав роли. */
|
||||
export interface PermissionsSet {
|
||||
permissions: string[];
|
||||
}
|
||||
BIN
src/assets/favicon.ico
Executable file
BIN
src/assets/favicon.ico
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 39 KiB |
18
src/auth/AuthGate.tsx
Normal file
18
src/auth/AuthGate.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import { Spin } from 'antd';
|
||||
import type { ReactNode } from 'react';
|
||||
import { DevLogin } from './DevLogin';
|
||||
import { MeContext } from './MeContext';
|
||||
import { useMe } from './useMe';
|
||||
|
||||
// Пока профиль не загружен — вход (или индикатор загрузки); после входа дети получают профиль через `useCurrentUser`.
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { me, loading, error, reload } = useMe();
|
||||
|
||||
if (loading) {
|
||||
return <Spin fullscreen />;
|
||||
}
|
||||
if (!me) {
|
||||
return <DevLogin onLogin={reload} error={error} />;
|
||||
}
|
||||
return <MeContext.Provider value={me}>{children}</MeContext.Provider>;
|
||||
}
|
||||
69
src/auth/DevLogin.tsx
Normal file
69
src/auth/DevLogin.tsx
Normal file
@ -0,0 +1,69 @@
|
||||
import { Alert, Button, Form, Input } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import type { DevTokenResponse } from '../api/types';
|
||||
import { setToken } from './token';
|
||||
|
||||
type Props = { onLogin: () => void; error?: string | null };
|
||||
|
||||
// Вход по email для разработки: бэкенд выпускает токен только при AUTH_DEV_ISSUER=true и DEBUG=true.
|
||||
function DevLoginForm({ onLogin, error }: Props) {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [failure, setFailure] = useState<string | null>(null);
|
||||
|
||||
const submit = async ({ email }: { email: string }) => {
|
||||
setSubmitting(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
const { access_token } = await api.post<DevTokenResponse>('/dev/token', { email: email.trim() });
|
||||
setToken(access_token);
|
||||
onLogin();
|
||||
} catch (e: unknown) {
|
||||
setFailure(e instanceof Error ? e.message : 'Не удалось выполнить вход');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const shownError = failure ?? error ?? null;
|
||||
|
||||
return (
|
||||
<Form<{ email: string }> layout='vertical' onFinish={submit} style={{ width: 360 }}>
|
||||
<h3>Вход для разработки</h3>
|
||||
{shownError && <Alert type='error' showIcon message={shownError} style={{ marginBottom: 16 }} />}
|
||||
<Form.Item name='email' label='Email' rules={[{ required: true, whitespace: true, message: 'Укажите email' }]}>
|
||||
<Input placeholder='admin@armka.local' autoFocus />
|
||||
</Form.Item>
|
||||
<Button type='primary' htmlType='submit' loading={submitting} block>
|
||||
Войти (dev)
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
// Production: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос
|
||||
function ProdNotice({ onLogin, error }: Props) {
|
||||
return (
|
||||
<div style={{ width: 360 }}>
|
||||
<p>Вход выполняется через РАИСА</p>
|
||||
{error && <Alert type='error' showIcon message={error} style={{ marginBottom: 16 }} />}
|
||||
<Button onClick={onLogin} block>
|
||||
Повторить
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DevLogin(props: Props) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100svh' }}>
|
||||
{/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */}
|
||||
{process.env.NODE_ENV !== 'production' ? (
|
||||
<DevLoginForm {...props} />
|
||||
) : (
|
||||
// TODO(OQ): параметры JWT РАИСА (издатель, claim с email)
|
||||
<ProdNotice {...props} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
src/auth/MeContext.ts
Normal file
13
src/auth/MeContext.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { Me } from '../api/types';
|
||||
|
||||
export const MeContext = createContext<Me | null>(null);
|
||||
|
||||
/** Текущий пользователь; вызывать только внутри `AuthGate` (вне него профиля нет — это ошибка программиста). */
|
||||
export function useCurrentUser(): Me {
|
||||
const me = useContext(MeContext);
|
||||
if (!me) {
|
||||
throw new Error('useCurrentUser вызван вне AuthGate');
|
||||
}
|
||||
return me;
|
||||
}
|
||||
3
src/auth/permissions.ts
Normal file
3
src/auth/permissions.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер:
|
||||
// проверки на клиенте только прячут то, что пользователю всё равно не разрешат.
|
||||
export const USERS_ADMIN = 'USERS_ADMIN';
|
||||
33
src/auth/token.ts
Normal file
33
src/auth/token.ts
Normal file
@ -0,0 +1,33 @@
|
||||
// Токен доступа живёт в sessionStorage: пропадает при закрытии вкладки и не доступен другим вкладкам (design D-9).
|
||||
// Любой доступ к хранилищу обёрнут в try/catch: в приватном режиме или при заблокированных данных сайта оно может
|
||||
// бросать исключение — тогда токен хранится только в памяти страницы, а приложение продолжает работать.
|
||||
const KEY = 'armka.token';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
|
||||
// Приоритет у sessionStorage (общий для вкладки); токен в памяти — только запасной вариант, когда хранилище недоступно
|
||||
export function getToken(): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(KEY) ?? memoryToken;
|
||||
} catch {
|
||||
return memoryToken;
|
||||
}
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
memoryToken = token;
|
||||
try {
|
||||
sessionStorage.setItem(KEY, token);
|
||||
} catch {
|
||||
// хранилище недоступно: остаётся токен в памяти
|
||||
}
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
memoryToken = null;
|
||||
try {
|
||||
sessionStorage.removeItem(KEY);
|
||||
} catch {
|
||||
// хранилище недоступно: токен в памяти уже сброшен
|
||||
}
|
||||
}
|
||||
65
src/auth/useMe.ts
Normal file
65
src/auth/useMe.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { UNAUTHORIZED_EVENT, api } from '../api/client';
|
||||
import type { Me } from '../api/types';
|
||||
import { getToken } from './token';
|
||||
|
||||
type MeState = { me: Me | null; loading: boolean; error: string | null; reload: () => void };
|
||||
|
||||
/**
|
||||
* Профиль текущего пользователя (`GET /me`). Запрос уходит только при наличии токена; ответ после размонтирования
|
||||
* или после повторного `reload` (устаревший) отбрасывается. На событие `armka:unauthorized` профиль сбрасывается.
|
||||
*/
|
||||
export function useMe(): MeState {
|
||||
const [me, setMe] = useState<Me | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(() => getToken() !== null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadCount, setReloadCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// reloadCount в зависимостях: его смена перезапускает загрузку
|
||||
void reloadCount;
|
||||
if (!getToken()) {
|
||||
setMe(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api
|
||||
.get<Me>('/me')
|
||||
.then((data) => {
|
||||
if (!cancelled) {
|
||||
setMe(data);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!cancelled) {
|
||||
setMe(null);
|
||||
setError(e instanceof Error ? e.message : 'Не удалось загрузить профиль');
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [reloadCount]);
|
||||
|
||||
useEffect(() => {
|
||||
const onUnauthorized = () => {
|
||||
setMe(null);
|
||||
setLoading(false);
|
||||
setError('Сессия истекла');
|
||||
};
|
||||
window.addEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
||||
return () => window.removeEventListener(UNAUTHORIZED_EVENT, onUnauthorized);
|
||||
}, []);
|
||||
|
||||
const reload = useCallback(() => setReloadCount((n) => n + 1), []);
|
||||
|
||||
return { me, loading, error, reload };
|
||||
}
|
||||
0
src/components/.gitkeep
Normal file
0
src/components/.gitkeep
Normal file
7
src/conf/config.ts
Normal file
7
src/conf/config.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// biome-ignore lint/suspicious/noExplicitAny: process.env подставляется rsbuild на этапе сборки
|
||||
const environment = (process as any).env;
|
||||
|
||||
export const Config = {
|
||||
API_URL: (environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '') as string,
|
||||
APP_ADDRESS: `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}` as string,
|
||||
};
|
||||
1
src/env.d.ts
vendored
Normal file
1
src/env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
116
src/index.css
Normal file
116
src/index.css
Normal file
@ -0,0 +1,116 @@
|
||||
/* :root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
} */
|
||||
|
||||
body {
|
||||
font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif;
|
||||
margin: 0px;
|
||||
}
|
||||
44
src/index.tsx
Normal file
44
src/index.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import { App as AntApp, ConfigProvider } from 'antd';
|
||||
import App from './App';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Элемент #root не найден');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
// <StrictMode>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: 'rgba(0, 255, 0, 0.5)',
|
||||
colorPrimaryHover: 'rgba(0, 255, 0, 1)',
|
||||
colorBgLayout: 'rgb(247, 248, 250)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: '#fff',
|
||||
},
|
||||
Button: {
|
||||
// textHoverBg: "green",
|
||||
colorBorder: 'rgba(0,0,0,0.5)',
|
||||
defaultActiveBorderColor: 'rgba(0,0,0,0.5)',
|
||||
defaultBorderColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
Input: {
|
||||
activeBorderColor: 'rgba(46, 166, 75, 0.35)',
|
||||
activeShadow: '0 0 0 1px #2ea64b59,0 1px 2px #0000000f',
|
||||
hoverBorderColor: 'rgba(46, 166, 75, 0.35)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* AntApp даёт message/notification через App.useApp() с темой ConfigProvider; component={false} — без лишнего div */}
|
||||
<AntApp component={false}>
|
||||
<App />
|
||||
</AntApp>
|
||||
</ConfigProvider>,
|
||||
// </StrictMode>,
|
||||
);
|
||||
32
src/layouts/Admin.tsx
Normal file
32
src/layouts/Admin.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Result, Tabs } from 'antd';
|
||||
import { useCurrentUser } from '../auth/MeContext';
|
||||
import { USERS_ADMIN } from '../auth/permissions';
|
||||
import { PermissionMatrix } from '../widgets/Admin/PermissionMatrix';
|
||||
import { UsersTable } from '../widgets/Admin/UsersTable';
|
||||
|
||||
/**
|
||||
* Экран администратора `/admin`: пользователи и роли, матрица прав.
|
||||
*
|
||||
* Профиль берём из `AuthGate` (`useCurrentUser`), а не новым запросом `/me`. Без права `USERS_ADMIN` таблицы даже не
|
||||
* создаются — значит, их эффекты не запускаются и запросов к `/admin/*` нет. Проверка здесь — только удобство
|
||||
* интерфейса: доступ на самом деле решает сервер (403 на `/admin/*` без права).
|
||||
*/
|
||||
export function Admin() {
|
||||
const me = useCurrentUser();
|
||||
|
||||
if (!me.permissions.includes(USERS_ADMIN)) {
|
||||
return <Result status='403' title='Нет доступа' subTitle='Раздел доступен пользователям с правом администрирования' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ margin: 10, padding: 16, background: 'white', borderRadius: 10, height: 'calc(100% - 20px)', overflow: 'auto' }}>
|
||||
{/* вкладка «Матрица прав» создаётся (и грузит данные) только при первом открытии */}
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: 'users', label: 'Пользователи', children: <UsersTable /> },
|
||||
{ key: 'matrix', label: 'Матрица прав', children: <PermissionMatrix /> },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/layouts/Main.tsx
Normal file
21
src/layouts/Main.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useCurrentUser } from '../auth/MeContext';
|
||||
|
||||
// Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md.
|
||||
export function Main() {
|
||||
const me = useCurrentUser();
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
margin: 10,
|
||||
padding: 16,
|
||||
background: 'white',
|
||||
borderRadius: 10,
|
||||
height: 'calc(100% - 20px)',
|
||||
}}>
|
||||
<h3>АРМ КА</h3>
|
||||
<p>{me.full_name}</p>
|
||||
<p>Роли: {me.roles.length > 0 ? me.roles.join(', ') : 'нет'}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
0
src/store/slices/.gitkeep
Normal file
0
src/store/slices/.gitkeep
Normal file
12
src/store/store.ts
Normal file
12
src/store/store.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
|
||||
// Каркас: слайсы добавляются срезами в ./slices.
|
||||
export const store = configureStore({
|
||||
reducer: {},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
||||
export const useAppSelector = useSelector.withTypes<RootState>();
|
||||
0
src/utils.ts
Normal file
0
src/utils.ts
Normal file
141
src/widgets/Admin/CreateUserModal.tsx
Normal file
141
src/widgets/Admin/CreateUserModal.tsx
Normal file
@ -0,0 +1,141 @@
|
||||
import { Form, Input, Modal, Select } from 'antd';
|
||||
import { useRef, 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();
|
||||
// Замок от двойного клика: onFinish вызывается после асинхронной проверки полей, и второй клик успел бы отправить
|
||||
// второй POST до того, как `submitting` отключит кнопку; ref меняется сразу
|
||||
const submitLock = useRef(false);
|
||||
|
||||
const submit = async (values: FormValues) => {
|
||||
if (submitLock.current) {
|
||||
return;
|
||||
}
|
||||
submitLock.current = true;
|
||||
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 {
|
||||
submitLock.current = false;
|
||||
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>
|
||||
);
|
||||
}
|
||||
168
src/widgets/Admin/PermissionMatrix.tsx
Normal file
168
src/widgets/Admin/PermissionMatrix.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import { App, Button, Checkbox, Flex, Table, type TableColumnsType, Tag, Tooltip, Typography } from 'antd';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ApiError, api } from '../../api/client';
|
||||
import type { Permission, Role } from '../../api/types';
|
||||
import { useAsyncData, useMountedRef } from './hooks';
|
||||
|
||||
type MatrixData = { roles: Role[]; permissions: Permission[] };
|
||||
|
||||
// Роли и права нужны вместе: без любой из половин матрицу не построить
|
||||
const loadMatrix = async (): Promise<MatrixData> => {
|
||||
const [roles, permissions] = await Promise.all([api.get<Role[]>('/admin/roles'), api.get<Permission[]>('/admin/permissions')]);
|
||||
return { roles, permissions };
|
||||
};
|
||||
|
||||
/**
|
||||
* Черновик: новые наборы прав только тех ролей, которые отличаются от сервера (код роли → коды прав).
|
||||
* `Map`, а не объект: у объекта есть «унаследованные» ключи (`constructor`, `toString`), и проверка `code in draft`
|
||||
* могла бы ошибиться на роли с таким кодом.
|
||||
*/
|
||||
type Draft = ReadonlyMap<string, string[]>;
|
||||
|
||||
const EMPTY_DRAFT: Draft = new Map();
|
||||
|
||||
function sameSet(a: string[], b: string[]): boolean {
|
||||
return a.length === b.length && a.every((code) => b.includes(code));
|
||||
}
|
||||
|
||||
/**
|
||||
* Матрица «право × роль»: строки — права, колонки — роли, в ячейке — галочка.
|
||||
*
|
||||
* Галочки меняют только локальный черновик; «Сохранить» отправляет `PUT /admin/roles/{code}/permissions` для каждой
|
||||
* изменённой роли — по очереди, а не параллельно: проверка «не снят ли последний USERS_ADMIN» на сервере идёт по
|
||||
* текущему состоянию БД, и параллельные запросы могли бы пройти её одновременно. После сохранения (даже частично
|
||||
* неудачного) роли из ответов сервера сразу подставляются в матрицу, черновик сбрасывается и матрица перечитывается:
|
||||
* на экране — то, что реально сохранено, даже если перечитать не удалось.
|
||||
*/
|
||||
export function PermissionMatrix() {
|
||||
const matrix = useAsyncData(loadMatrix);
|
||||
const [draft, setDraft] = useState<Draft>(EMPTY_DRAFT);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const savingLock = useRef(false);
|
||||
const { message } = App.useApp();
|
||||
const mounted = useMountedRef();
|
||||
|
||||
const roles = matrix.data?.roles ?? [];
|
||||
const permissions = matrix.data?.permissions ?? [];
|
||||
const changedRoles = roles.filter((role) => draft.has(role.code));
|
||||
|
||||
const permissionsOf = (role: Role): string[] => draft.get(role.code) ?? role.permissions;
|
||||
|
||||
const toggle = (role: Role, permissionCode: string, checked: boolean) => {
|
||||
// считаем от предыдущего черновика (prev), а не от отрисованного: два быстрых клика не потеряют первый
|
||||
setDraft((prev) => {
|
||||
const current = prev.get(role.code) ?? role.permissions;
|
||||
const without = current.filter((code) => code !== permissionCode);
|
||||
const next = checked ? [...without, permissionCode] : without;
|
||||
const updated = new Map(prev);
|
||||
// вернули как было на сервере — роль больше не считается изменённой
|
||||
if (sameSet(next, role.permissions)) {
|
||||
updated.delete(role.code);
|
||||
} else {
|
||||
updated.set(role.code, next);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (savingLock.current || changedRoles.length === 0) {
|
||||
return;
|
||||
}
|
||||
savingLock.current = true;
|
||||
setSaving(true);
|
||||
const failed: string[] = [];
|
||||
// роли из ответов успешных PUT: подставим их в матрицу, не дожидаясь перечитывания
|
||||
const saved = new Map<string, Role>();
|
||||
for (const role of changedRoles) {
|
||||
try {
|
||||
const permissionCodes = draft.get(role.code) ?? role.permissions;
|
||||
const updated = await api.put<Role>(`/admin/roles/${encodeURIComponent(role.code)}/permissions`, {
|
||||
permissions: permissionCodes,
|
||||
});
|
||||
saved.set(updated.code, updated);
|
||||
} catch (e: unknown) {
|
||||
failed.push(`${role.code}: ${e instanceof ApiError ? e.message : 'не удалось выполнить запрос'}`);
|
||||
}
|
||||
}
|
||||
savingLock.current = false;
|
||||
if (!mounted.current) {
|
||||
return;
|
||||
}
|
||||
if (saved.size > 0) {
|
||||
message.success(`Сохранено ролей: ${saved.size}`);
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
message.error(`Не сохранены: ${failed.join('; ')}`);
|
||||
}
|
||||
// если перечитывание ниже не удастся, в матрице уже будут сохранённые наборы прав, а не прежние
|
||||
matrix.setData((prev) => (prev ? { ...prev, roles: prev.roles.map((r) => saved.get(r.code) ?? r) } : prev));
|
||||
setDraft(EMPTY_DRAFT);
|
||||
await matrix.reload();
|
||||
if (mounted.current) {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const roleColumns: TableColumnsType<Permission> = roles.map((role) => ({
|
||||
key: role.code,
|
||||
align: 'center',
|
||||
title: (
|
||||
<Tooltip title={role.description ?? role.name}>
|
||||
<Flex vertical align='center' gap={2}>
|
||||
<span>{role.code}</span>
|
||||
{role.is_system && <Tag>системная</Tag>}
|
||||
{!role.is_active && <Tag color='default'>неактивна</Tag>}
|
||||
{draft.has(role.code) && <Tag color='orange'>изменена</Tag>}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
),
|
||||
render: (_, permission) => (
|
||||
<Checkbox
|
||||
aria-label={`${role.code}: ${permission.code}`}
|
||||
checked={permissionsOf(role).includes(permission.code)}
|
||||
disabled={saving || matrix.loading}
|
||||
onChange={(e) => toggle(role, permission.code, e.target.checked)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
const columns: TableColumnsType<Permission> = [
|
||||
{
|
||||
title: 'Право',
|
||||
key: 'permission',
|
||||
fixed: 'left',
|
||||
width: 260,
|
||||
render: (_, permission) => (
|
||||
<>
|
||||
<Typography.Text code>{permission.code}</Typography.Text>
|
||||
<div>{permission.name}</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...roleColumns,
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex gap={8} align='center' style={{ marginBottom: 12 }}>
|
||||
<Button type='primary' onClick={() => void save()} loading={saving} disabled={changedRoles.length === 0}>
|
||||
Сохранить
|
||||
</Button>
|
||||
<Button onClick={() => setDraft(EMPTY_DRAFT)} disabled={saving || changedRoles.length === 0}>
|
||||
Отменить изменения
|
||||
</Button>
|
||||
{changedRoles.length > 0 && <Typography.Text type='secondary'>Изменено ролей: {changedRoles.length}</Typography.Text>}
|
||||
</Flex>
|
||||
<Table<Permission>
|
||||
rowKey='code'
|
||||
size='small'
|
||||
columns={columns}
|
||||
dataSource={permissions}
|
||||
loading={matrix.loading}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
154
src/widgets/Admin/UsersTable.tsx
Normal file
154
src/widgets/Admin/UsersTable.tsx
Normal file
@ -0,0 +1,154 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { App, Button, Flex, Select, Switch, Table, type TableColumnsType, Typography } from 'antd';
|
||||
import { useRef, useState } from 'react';
|
||||
import { api } from '../../api/client';
|
||||
import type { AdminUser, Page, RegionalOffice, Role } from '../../api/types';
|
||||
import { CreateUserModal } from './CreateUserModal';
|
||||
import { useAsyncData, useMountedRef, useNotifyError } from './hooks';
|
||||
|
||||
// Сервер отдаёт не больше 200 пользователей за запрос; постраничный просмотр появится, когда пользователей станет больше
|
||||
const USERS_LIMIT = 200;
|
||||
|
||||
// Загрузчики — функции уровня модуля: они не меняются между отрисовками (требование useAsyncData)
|
||||
const loadUsers = () => api.get<Page<AdminUser>>(`/admin/users?limit=${USERS_LIMIT}`);
|
||||
const loadRoles = () => api.get<Role[]>('/admin/roles');
|
||||
const loadOffices = () => api.get<RegionalOffice[]>('/directories/regional-offices');
|
||||
|
||||
/**
|
||||
* Таблица пользователей: роли меняются прямо в строке (`PUT /admin/users/{id}/roles`), активность — переключателем
|
||||
* (`PATCH /admin/users/{id}`), новый пользователь — через окно создания.
|
||||
*
|
||||
* Состояние на экране всегда серверное: значение в строке меняется только после ответа сервера (берём пользователя из
|
||||
* ответа). При ошибке (например, 409 `LAST_ADMIN`) показываем уведомление и перечитываем список, поэтому контрол
|
||||
* никогда не показывает отвергнутое сервером состояние. Пока одна строка сохраняется, правка остальных заблокирована —
|
||||
* запросы не идут параллельно и не обгоняют друг друга.
|
||||
*/
|
||||
export function UsersTable() {
|
||||
const users = useAsyncData(loadUsers);
|
||||
const roles = useAsyncData(loadRoles);
|
||||
const offices = useAsyncData(loadOffices);
|
||||
const [savingUserId, setSavingUserId] = useState<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const notifyError = useNotifyError();
|
||||
const { message } = App.useApp();
|
||||
const mounted = useMountedRef();
|
||||
// Замок от двойного клика: состояние React обновится только к следующей отрисовке, а ref — сразу
|
||||
const savingLock = useRef(false);
|
||||
|
||||
const roleList = roles.data ?? [];
|
||||
const page = users.data;
|
||||
const busy = savingUserId !== null || users.loading;
|
||||
|
||||
// Одна правка строки: запрос → строка из ответа сервера; ошибка → уведомление и перечитывание списка
|
||||
const saveRow = async (userId: string, request: () => Promise<AdminUser>) => {
|
||||
if (savingLock.current) {
|
||||
return;
|
||||
}
|
||||
savingLock.current = true;
|
||||
setSavingUserId(userId);
|
||||
try {
|
||||
const updated = await request();
|
||||
if (mounted.current) {
|
||||
users.setData((prev) => (prev ? { ...prev, items: prev.items.map((u) => (u.id === updated.id ? updated : u)) } : prev));
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (mounted.current) {
|
||||
notifyError(e);
|
||||
await users.reload();
|
||||
}
|
||||
} finally {
|
||||
savingLock.current = false;
|
||||
if (mounted.current) {
|
||||
setSavingUserId(null);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const changeRoles = (user: AdminUser, codes: string[]) =>
|
||||
saveRow(user.id, () => api.put<AdminUser>(`/admin/users/${user.id}/roles`, { roles: codes }));
|
||||
|
||||
const changeActive = (user: AdminUser, isActive: boolean) =>
|
||||
saveRow(user.id, () => api.patch<AdminUser>(`/admin/users/${user.id}`, { is_active: isActive }));
|
||||
|
||||
const onCreated = (user: AdminUser) => {
|
||||
setCreateOpen(false);
|
||||
message.success(`Пользователь ${user.email ?? user.full_name} создан`);
|
||||
void users.reload();
|
||||
};
|
||||
|
||||
const columns: TableColumnsType<AdminUser> = [
|
||||
{ title: 'Email', dataIndex: 'email', render: (email: string | null) => email ?? '—' },
|
||||
{ title: 'ФИО', dataIndex: 'full_name' },
|
||||
{ title: 'Филиал', key: 'office', render: (_, user) => user.regional_office.name },
|
||||
{
|
||||
title: 'Роли',
|
||||
key: 'roles',
|
||||
width: 360,
|
||||
render: (_, user) => (
|
||||
<Select
|
||||
mode='multiple'
|
||||
style={{ width: '100%' }}
|
||||
placeholder='Без ролей'
|
||||
aria-label={`Роли пользователя ${user.email ?? user.full_name}`}
|
||||
value={user.roles}
|
||||
loading={savingUserId === user.id}
|
||||
disabled={busy || roles.data === null}
|
||||
// в строке — коды ролей (коротко), в выпадающем списке — код и название. Неактивную роль нельзя назначить
|
||||
// заново (сервер ответит 422 UNKNOWN_ROLE), но уже назначенную — можно снять: у disabled-опции rc-select не
|
||||
// рисует крестик на теге, поэтому disabled только для тех неактивных, которых у пользователя нет
|
||||
options={roleList.map((r) => ({
|
||||
value: r.code,
|
||||
label: r.code,
|
||||
title: r.name,
|
||||
disabled: !r.is_active && !user.roles.includes(r.code),
|
||||
}))}
|
||||
optionRender={(option) => `${option.data.label} — ${option.data.title}`}
|
||||
onChange={(codes: string[]) => void changeRoles(user, codes)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Активен',
|
||||
key: 'is_active',
|
||||
width: 100,
|
||||
render: (_, user) => (
|
||||
<Switch
|
||||
checked={user.is_active}
|
||||
loading={savingUserId === user.id}
|
||||
disabled={busy}
|
||||
aria-label={`Активен: ${user.email ?? user.full_name}`}
|
||||
onChange={(checked) => void changeActive(user, checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex justify='space-between' align='center' style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type='secondary'>
|
||||
{page && `Всего пользователей: ${page.total}`}
|
||||
{page && page.total > page.items.length && ` (показаны первые ${page.items.length})`}
|
||||
</Typography.Text>
|
||||
<Button type='primary' icon={<PlusOutlined />} onClick={() => setCreateOpen(true)} disabled={roles.data === null}>
|
||||
Создать пользователя
|
||||
</Button>
|
||||
</Flex>
|
||||
<Table<AdminUser>
|
||||
rowKey='id'
|
||||
size='small'
|
||||
columns={columns}
|
||||
dataSource={page?.items ?? []}
|
||||
loading={users.loading}
|
||||
pagination={false}
|
||||
/>
|
||||
<CreateUserModal
|
||||
open={createOpen}
|
||||
roles={roleList}
|
||||
offices={offices.data ?? []}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreated={onCreated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
94
src/widgets/Admin/hooks.ts
Normal file
94
src/widgets/Admin/hooks.ts
Normal file
@ -0,0 +1,94 @@
|
||||
import { App } from 'antd';
|
||||
import { type Dispatch, type SetStateAction, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { ApiError } from '../../api/client';
|
||||
|
||||
/**
|
||||
* Показ ошибки запроса уведомлением antd. `ApiError` уже несёт текст с сервера (например, «Нельзя снять последнего
|
||||
* администратора» для 409 `LAST_ADMIN`); прочие исключения — общий текст. `message` берём из `App.useApp()`, а не
|
||||
* статический `message.error`: так уведомление получает тему из `ConfigProvider` (статический вызов её не видит).
|
||||
*/
|
||||
export function useNotifyError(): (e: unknown) => void {
|
||||
const { message } = App.useApp();
|
||||
return useCallback(
|
||||
(e: unknown) => {
|
||||
// отмена запроса (AbortController) — не ошибка для пользователя
|
||||
if (e instanceof DOMException && e.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
message.error(e instanceof ApiError ? e.message : 'Не удалось выполнить запрос');
|
||||
},
|
||||
[message],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Флаг «компонент ещё на экране». Проверяется после `await` в обработчиках кнопок, чтобы не менять состояние
|
||||
* компонента, который пользователь уже закрыл (ушёл со страницы, пока шёл запрос).
|
||||
*/
|
||||
export function useMountedRef(): { readonly current: boolean } {
|
||||
const mounted = useRef(false);
|
||||
useEffect(() => {
|
||||
mounted.current = true;
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, []);
|
||||
return mounted;
|
||||
}
|
||||
|
||||
type AsyncData<T> = {
|
||||
/**
|
||||
* Последние успешно полученные данные. `null` — первая загрузка ещё идёт или не удалась; неудачный `reload()`
|
||||
* оставляет прежние данные (ошибка показывается уведомлением).
|
||||
*/
|
||||
data: T | null;
|
||||
/** Замена данных без запроса (например, строка из ответа `PUT`). */
|
||||
setData: Dispatch<SetStateAction<T | null>>;
|
||||
loading: boolean;
|
||||
/** Повторная загрузка; промис не отклоняется — ошибка уже показана уведомлением. */
|
||||
reload: () => Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Загрузка данных при появлении компонента и по `reload()`.
|
||||
*
|
||||
* `loader` должен быть стабильным (функция уровня модуля или `useCallback`), иначе загрузка пойдёт на каждой отрисовке.
|
||||
* Защита от устаревших ответов: у каждого запуска свой номер; применяется только ответ последнего запуска. Ответ,
|
||||
* пришедший после более нового `reload()` или после ухода со страницы, отбрасывается.
|
||||
*/
|
||||
export function useAsyncData<T>(loader: () => Promise<T>): AsyncData<T> {
|
||||
const notifyError = useNotifyError();
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const latestRun = useRef(0);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
latestRun.current += 1;
|
||||
const run = latestRun.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await loader();
|
||||
if (run === latestRun.current) {
|
||||
setData(result);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
if (run === latestRun.current) {
|
||||
notifyError(e);
|
||||
}
|
||||
} finally {
|
||||
if (run === latestRun.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [loader, notifyError]);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
return () => {
|
||||
// уход со страницы: ответы запросов «в полёте» станут устаревшими и будут отброшены
|
||||
latestRun.current += 1;
|
||||
};
|
||||
}, [reload]);
|
||||
|
||||
return { data, setData, loading, reload };
|
||||
}
|
||||
109
src/widgets/Sidebar/Sidebar.tsx
Normal file
109
src/widgets/Sidebar/Sidebar.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { HomeOutlined, LeftOutlined, RightOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { Divider, Layout, Tooltip } from 'antd';
|
||||
import { type ReactNode, memo, useMemo, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useCurrentUser } from '../../auth/MeContext';
|
||||
import { USERS_ADMIN } from '../../auth/permissions';
|
||||
import styles from './styles.module.css';
|
||||
|
||||
type NavItem = {
|
||||
link: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
/** Право из `/me`, без которого пункт не показывается; нет — пункт виден всем. */
|
||||
permission?: string;
|
||||
};
|
||||
|
||||
type Group = {
|
||||
title: string;
|
||||
key: string;
|
||||
children: NavItem[];
|
||||
};
|
||||
|
||||
// Каркас: разделы меню — по карте экранов docs/context/10-user-journey.md, добавляются срезами.
|
||||
const config: Group[] = [
|
||||
{
|
||||
title: 'Главная',
|
||||
key: 'home',
|
||||
children: [{ link: '/', icon: <HomeOutlined />, label: 'Главная' }],
|
||||
},
|
||||
{
|
||||
title: 'Система',
|
||||
key: 'system',
|
||||
children: [{ link: '/admin', icon: <TeamOutlined />, label: 'Пользователи и роли', permission: USERS_ADMIN }],
|
||||
},
|
||||
];
|
||||
|
||||
/** Меню для пользователя: пункты без нужного права убираются, опустевшие группы — тоже. */
|
||||
function visibleGroups(permissions: string[]): Group[] {
|
||||
return config
|
||||
.map((group) => ({ ...group, children: group.children.filter((i) => !i.permission || permissions.includes(i.permission)) }))
|
||||
.filter((group) => group.children.length > 0);
|
||||
}
|
||||
|
||||
function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) {
|
||||
return (
|
||||
<Tooltip placement='right' title={title}>
|
||||
<NavLink to={to} className={({ isActive }) => `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}>
|
||||
{icon}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
|
||||
return (
|
||||
<NavLink className={({ isActive }) => `${styles.linkBase} ${isActive ? styles.linkActive : ''}`} to={to}>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
|
||||
return !isCollapsed ? (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{group.title}</div>
|
||||
{group.children.map((i) => (
|
||||
<NavLinkStyled key={i.link} to={i.link}>
|
||||
{i.icon}
|
||||
{i.label}
|
||||
</NavLinkStyled>
|
||||
))}
|
||||
<Divider size='small' />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{group.children.map((i) => (
|
||||
<CollapsedIcon key={i.link} icon={i.icon} to={i.link} title={i.label} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const me = useCurrentUser();
|
||||
// useMemo: группы пересчитываются только при смене прав, иначе memo(Section) получал бы новый объект на каждой отрисовке
|
||||
const groups = useMemo(() => visibleGroups(me.permissions), [me.permissions]);
|
||||
|
||||
return (
|
||||
<Layout.Sider
|
||||
collapsed={isCollapsed}
|
||||
style={{ height: 'auto', margin: '10px', borderRadius: 10 }}
|
||||
width={280}
|
||||
collapsedWidth={64}
|
||||
collapsible
|
||||
trigger={null}>
|
||||
<div className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ''}`}>
|
||||
<div className={styles.topbar}>
|
||||
<button type='button' className={styles.collapseBtn} onClick={() => setIsCollapsed(!isCollapsed)}>
|
||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
);
|
||||
}
|
||||
188
src/widgets/Sidebar/styles.module.css
Normal file
188
src/widgets/Sidebar/styles.module.css
Normal file
@ -0,0 +1,188 @@
|
||||
.company_group_item {
|
||||
border: 2px solid grey;
|
||||
border-radius: 2px;
|
||||
height: 50px;
|
||||
width: 90%;
|
||||
cursor: pointer;
|
||||
background-color: lightgray;
|
||||
}
|
||||
.active_cg {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapsedList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 0px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #8c8c8c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subgroupToggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subgroup {
|
||||
/* padding-left: 8px; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* gap: 10px; */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.linkBase {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* .linkActive:hover {
|
||||
color: rgb(31, 114, 55);
|
||||
} */
|
||||
|
||||
.linkBase:hover {
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
background: rgb(249, 250, 251);
|
||||
}
|
||||
|
||||
.linkBase.linkActive {
|
||||
color: rgb(31, 114, 55);
|
||||
background: #edf8ee;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
padding-top: 8;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.wrapperCollapsed {
|
||||
padding: 12px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsedList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.iconBtnActive,
|
||||
.iconBtnActive:hover {
|
||||
background: rgba(46, 166, 75, 0.16);
|
||||
color: rgb(31, 114, 55);
|
||||
}
|
||||
|
||||
.iconBtnNav:hover {
|
||||
color: #101828;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.iconBtnAction {
|
||||
background: linear-gradient(160deg, #2ea64b, #1f7237);
|
||||
font-size: small;
|
||||
box-shadow: 0 1px 3px #1f723740;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.iconBtnAction:hover {
|
||||
background: linear-gradient(160deg, #268f40, #185c2b);
|
||||
}
|
||||
|
||||
/* .iconBtn. */
|
||||
|
||||
.collapseBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
43
tailwind.config.js
Normal file
43
tailwind.config.js
Normal file
@ -0,0 +1,43 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: [
|
||||
"./src/**/*.{html,js,jsx,ts,tsx}",
|
||||
"node_modules/daisyui/dist/**/*.js",
|
||||
"node_modules/react-daisyui/dist/**/*.js",
|
||||
"node_modules/raisa-ui/dist/**/*.js"
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [require('daisyui')],
|
||||
daisyui: {
|
||||
themes: [
|
||||
{
|
||||
rshb: {
|
||||
...require('daisyui/src/theming/themes')['light'],
|
||||
|
||||
"primary": "#171A1C",
|
||||
"primary-content": "#fcfcfd",
|
||||
"secondary": "#ECEDEF",
|
||||
"secondary-content": "#687178",
|
||||
"accent": "#8ecaff",
|
||||
"accent-content": "#fcfcfd",
|
||||
"neutral": "#ecedef",
|
||||
"neutral-content": "#171A1C",
|
||||
"base-100": "#FCFCFD",
|
||||
"base-200": "#F6F7F8",
|
||||
"base-300": "#ECEDEF",
|
||||
"base-content": "#171A1C",
|
||||
"info": "#0894FF",
|
||||
"info-content": "#E3E5E8",
|
||||
"success": "#39B33D",
|
||||
"success-content": "#E3E5E8",
|
||||
"warning": "#E6B200",
|
||||
"warning-content": "#fcfcfd",
|
||||
"error": "#FF371A",
|
||||
"error-content": "#fcfcfd",
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["DOM", "ES2020"],
|
||||
"jsx": "react-jsx",
|
||||
"target": "ES2020",
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true,
|
||||
|
||||
/* modules */
|
||||
"module": "ESNext",
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
|
||||
/* type checking */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user