From 383e4e3e5dd43aafa7412808c50cafb7339202aa Mon Sep 17 00:00:00 2001 From: maxim Date: Tue, 6 Oct 2026 13:28:45 +0300 Subject: [PATCH] first commit from arm-core --- .gitignore | 3 + biome.json | 91 ++++++++++++ package.json | 38 +++++ postcss.config.cjs | 5 + public/.gitkeep | 0 public/index.html | 13 ++ rsbuild.config.ts | 32 +++++ src/App.css | 0 src/App.tsx | 33 +++++ src/api/client.ts | 119 ++++++++++++++++ src/api/types.ts | 106 ++++++++++++++ src/assets/favicon.ico | Bin 0 -> 40382 bytes src/auth/AuthGate.tsx | 18 +++ src/auth/DevLogin.tsx | 69 +++++++++ src/auth/MeContext.ts | 13 ++ src/auth/permissions.ts | 3 + src/auth/token.ts | 33 +++++ src/auth/useMe.ts | 65 +++++++++ src/components/.gitkeep | 0 src/conf/config.ts | 7 + src/env.d.ts | 1 + src/index.css | 116 +++++++++++++++ src/index.tsx | 44 ++++++ src/layouts/Admin.tsx | 32 +++++ src/layouts/Main.tsx | 21 +++ src/store/slices/.gitkeep | 0 src/store/store.ts | 12 ++ src/utils.ts | 0 src/widgets/Admin/CreateUserModal.tsx | 141 +++++++++++++++++++ src/widgets/Admin/PermissionMatrix.tsx | 168 ++++++++++++++++++++++ src/widgets/Admin/UsersTable.tsx | 154 ++++++++++++++++++++ src/widgets/Admin/hooks.ts | 94 +++++++++++++ src/widgets/Sidebar/Sidebar.tsx | 109 ++++++++++++++ src/widgets/Sidebar/styles.module.css | 188 +++++++++++++++++++++++++ tailwind.config.js | 43 ++++++ tsconfig.json | 23 +++ 36 files changed, 1794 insertions(+) create mode 100644 .gitignore create mode 100644 biome.json create mode 100644 package.json create mode 100644 postcss.config.cjs create mode 100644 public/.gitkeep create mode 100644 public/index.html create mode 100644 rsbuild.config.ts create mode 100644 src/App.css create mode 100644 src/App.tsx create mode 100644 src/api/client.ts create mode 100644 src/api/types.ts create mode 100755 src/assets/favicon.ico create mode 100644 src/auth/AuthGate.tsx create mode 100644 src/auth/DevLogin.tsx create mode 100644 src/auth/MeContext.ts create mode 100644 src/auth/permissions.ts create mode 100644 src/auth/token.ts create mode 100644 src/auth/useMe.ts create mode 100644 src/components/.gitkeep create mode 100644 src/conf/config.ts create mode 100644 src/env.d.ts create mode 100644 src/index.css create mode 100644 src/index.tsx create mode 100644 src/layouts/Admin.tsx create mode 100644 src/layouts/Main.tsx create mode 100644 src/store/slices/.gitkeep create mode 100644 src/store/store.ts create mode 100644 src/utils.ts create mode 100644 src/widgets/Admin/CreateUserModal.tsx create mode 100644 src/widgets/Admin/PermissionMatrix.tsx create mode 100644 src/widgets/Admin/UsersTable.tsx create mode 100644 src/widgets/Admin/hooks.ts create mode 100644 src/widgets/Sidebar/Sidebar.tsx create mode 100644 src/widgets/Sidebar/styles.module.css create mode 100644 tailwind.config.js create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..055395e --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.env +package-lock.json \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..fb06a60 --- /dev/null +++ b/biome.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9479090 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/postcss.config.cjs b/postcss.config.cjs new file mode 100644 index 0000000..e48f63b --- /dev/null +++ b/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + tailwindcss: {}, + } +} \ No newline at end of file diff --git a/public/.gitkeep b/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..75b67e5 --- /dev/null +++ b/public/index.html @@ -0,0 +1,13 @@ + + + + + + + Пример React-приложения + + + +
+ + \ No newline at end of file diff --git a/rsbuild.config.ts b/rsbuild.config.ts new file mode 100644 index 0000000..08f6224 --- /dev/null +++ b/rsbuild.config.ts @@ -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()], +}); diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000..e69de29 diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..26cb0a0 --- /dev/null +++ b/src/App.tsx @@ -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 ( + + + + + + + + } /> + } /> + + + + + + + ); +} + +export default App; diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..29aa352 --- /dev/null +++ b/src/api/client.ts @@ -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 { + 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 { + try { + return parseErrorBody(await response.json()); + } catch { + // тело не JSON (например, ответ прокси) — показываем общий текст + return { code: undefined, message: FALLBACK_MESSAGE }; + } +} + +export async function apiFetch(path: string, init: RequestInit = {}): Promise { + 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: (path: string) => apiFetch(path), + post: (path: string, body?: unknown) => apiFetch(path, withJsonBody('POST', body)), + patch: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PATCH', body)), + put: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PUT', body)), + del: (path: string) => apiFetch(path, { method: 'DELETE' }), +}; diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..d153092 --- /dev/null +++ b/src/api/types.ts @@ -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 { + 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[]; +} diff --git a/src/assets/favicon.ico b/src/assets/favicon.ico new file mode 100755 index 0000000000000000000000000000000000000000..00067be545e46b6f8f601649375d2248c13fe76a GIT binary patch literal 40382 zcmeI2KWh|09EK-_+&4&Vq2On*%V)5)5G1W&V`Y(~vQDiK!9olF6+*x!0SkpdE*2`N z2%<)a#DrKF2#m8V;ojYOy~-m|CZ2x${gd_GuDH2&H7+b) zoQYJ$MtfsUraL#xNga`JQtQCA%ku~)aH8G?Cvc+i(9Yv0wIXl=Cv7&g3@30h?EaK> zR~;X40w-|7^RDXoCC?!Jnravyr)~HNeo`xdpPVw`{=*5Jz)9I>-~>+K1Wu~o`}sY- zez$`AlQIu(%~jRmC-@0|QsxIv-~>+Kgmu^8eOA_8Wgb{}=~Xk&^E+d$3Gc0VmRNVy z@c}1r@;@)(K7QaUZ~`ZA!h0*cx8m1R!`RR^)?KW-2Cf<-_a~kwIDr#5@oeBH8X0h+ zYTzfH8TC0cxLbuIDr$-27aQE0Vk>ke&U(IPv8VjJRA6lMh2Xy z8u*E420wulIPq-YCmI=WqH5qLo*DcEPT<6|fuCq(z=^7XpLk~Q6F7kr&jx;?kpU;F z27cn1!B5}>PCOgIqLBe7ss?`InZZxs1Wr5~_=!daoTwW3iDw2sffG3KY?S=u`F@P| z_eM*bhpN8MZ~p!oU;O$s@pN=@><1vVuRgqt>-{G)kv{IelaY}{0Td`H5M=A!mO{$* z(!pBO3pcbFdW}-rzias*+|ffiAKxBt*Rpn=@}2vi_VMlW9rt*$`EPd6S+DVrcS%Q- z#jiG|d>+ru9rE+J)z~3#Z#0Lj|E}lTb^m7f-Q#!M^IQ79vp&DGzP^k)A5%E~ALCnI AlK=n! literal 0 HcmV?d00001 diff --git a/src/auth/AuthGate.tsx b/src/auth/AuthGate.tsx new file mode 100644 index 0000000..79e2faa --- /dev/null +++ b/src/auth/AuthGate.tsx @@ -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 ; + } + if (!me) { + return ; + } + return {children}; +} diff --git a/src/auth/DevLogin.tsx b/src/auth/DevLogin.tsx new file mode 100644 index 0000000..ef5a76d --- /dev/null +++ b/src/auth/DevLogin.tsx @@ -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(null); + + const submit = async ({ email }: { email: string }) => { + setSubmitting(true); + setFailure(null); + try { + const { access_token } = await api.post('/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 ( + layout='vertical' onFinish={submit} style={{ width: 360 }}> +

Вход для разработки

+ {shownError && } + + + + + + ); +} + +// Production: вход выполняет внешний SSO; если загрузка профиля не удалась, показываем причину и даём повторить запрос +function ProdNotice({ onLogin, error }: Props) { + return ( +
+

Вход выполняется через РАИСА

+ {error && } + +
+ ); +} + +export function DevLogin(props: Props) { + return ( +
+ {/* Литерал process.env.NODE_ENV rsbuild подставляет при сборке: в production форма вырезается из бандла целиком */} + {process.env.NODE_ENV !== 'production' ? ( + + ) : ( + // TODO(OQ): параметры JWT РАИСА (издатель, claim с email) + + )} +
+ ); +} diff --git a/src/auth/MeContext.ts b/src/auth/MeContext.ts new file mode 100644 index 0000000..2bddad6 --- /dev/null +++ b/src/auth/MeContext.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; +import type { Me } from '../api/types'; + +export const MeContext = createContext(null); + +/** Текущий пользователь; вызывать только внутри `AuthGate` (вне него профиля нет — это ошибка программиста). */ +export function useCurrentUser(): Me { + const me = useContext(MeContext); + if (!me) { + throw new Error('useCurrentUser вызван вне AuthGate'); + } + return me; +} diff --git a/src/auth/permissions.ts b/src/auth/permissions.ts new file mode 100644 index 0000000..f6a49b8 --- /dev/null +++ b/src/auth/permissions.ts @@ -0,0 +1,3 @@ +// Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер: +// проверки на клиенте только прячут то, что пользователю всё равно не разрешат. +export const USERS_ADMIN = 'USERS_ADMIN'; diff --git a/src/auth/token.ts b/src/auth/token.ts new file mode 100644 index 0000000..70937aa --- /dev/null +++ b/src/auth/token.ts @@ -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 { + // хранилище недоступно: токен в памяти уже сброшен + } +} diff --git a/src/auth/useMe.ts b/src/auth/useMe.ts new file mode 100644 index 0000000..c949367 --- /dev/null +++ b/src/auth/useMe.ts @@ -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(null); + const [loading, setLoading] = useState(() => getToken() !== null); + const [error, setError] = useState(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') + .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 }; +} diff --git a/src/components/.gitkeep b/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/conf/config.ts b/src/conf/config.ts new file mode 100644 index 0000000..acd3f47 --- /dev/null +++ b/src/conf/config.ts @@ -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, +}; diff --git a/src/env.d.ts b/src/env.d.ts new file mode 100644 index 0000000..b0ac762 --- /dev/null +++ b/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..9200763 --- /dev/null +++ b/src/index.css @@ -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; +} diff --git a/src/index.tsx b/src/index.tsx new file mode 100644 index 0000000..ef98385 --- /dev/null +++ b/src/index.tsx @@ -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( + // + + {/* AntApp даёт message/notification через App.useApp() с темой ConfigProvider; component={false} — без лишнего div */} + + + + , + // , +); diff --git a/src/layouts/Admin.tsx b/src/layouts/Admin.tsx new file mode 100644 index 0000000..22499f6 --- /dev/null +++ b/src/layouts/Admin.tsx @@ -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 ; + } + + return ( +
+ {/* вкладка «Матрица прав» создаётся (и грузит данные) только при первом открытии */} + }, + { key: 'matrix', label: 'Матрица прав', children: }, + ]} + /> +
+ ); +} diff --git a/src/layouts/Main.tsx b/src/layouts/Main.tsx new file mode 100644 index 0000000..0ea7656 --- /dev/null +++ b/src/layouts/Main.tsx @@ -0,0 +1,21 @@ +import { useCurrentUser } from '../auth/MeContext'; + +// Каркас: стартовый экран. Содержимое — по docs/context/10-user-journey.md. +export function Main() { + const me = useCurrentUser(); + + return ( +
+

АРМ КА

+

{me.full_name}

+

Роли: {me.roles.length > 0 ? me.roles.join(', ') : 'нет'}

+
+ ); +} diff --git a/src/store/slices/.gitkeep b/src/store/slices/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..e3364ed --- /dev/null +++ b/src/store/store.ts @@ -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; +export type AppDispatch = typeof store.dispatch; +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/widgets/Admin/CreateUserModal.tsx b/src/widgets/Admin/CreateUserModal.tsx new file mode 100644 index 0000000..6db7af2 --- /dev/null +++ b/src/widgets/Admin/CreateUserModal.tsx @@ -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(); + 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('/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 ( + form.submit()} + onCancel={cancel} + confirmLoading={submitting} + cancelButtonProps={{ disabled: submitting }} + mask={{ closable: !submitting }} + // форма смонтирована всегда (forceRender), поэтому её можно очистить после закрытия окна + forceRender + afterClose={() => form.resetFields()}> + form={form} layout='vertical' onFinish={submit} disabled={submitting}> + + + + + + + + + + + + + + ({ + 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) => ( + void changeActive(user, checked)} + /> + ), + }, + ]; + + return ( + <> + + + {page && `Всего пользователей: ${page.total}`} + {page && page.total > page.items.length && ` (показаны первые ${page.items.length})`} + + + + + rowKey='id' + size='small' + columns={columns} + dataSource={page?.items ?? []} + loading={users.loading} + pagination={false} + /> + setCreateOpen(false)} + onCreated={onCreated} + /> + + ); +} diff --git a/src/widgets/Admin/hooks.ts b/src/widgets/Admin/hooks.ts new file mode 100644 index 0000000..6cdce77 --- /dev/null +++ b/src/widgets/Admin/hooks.ts @@ -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 = { + /** + * Последние успешно полученные данные. `null` — первая загрузка ещё идёт или не удалась; неудачный `reload()` + * оставляет прежние данные (ошибка показывается уведомлением). + */ + data: T | null; + /** Замена данных без запроса (например, строка из ответа `PUT`). */ + setData: Dispatch>; + loading: boolean; + /** Повторная загрузка; промис не отклоняется — ошибка уже показана уведомлением. */ + reload: () => Promise; +}; + +/** + * Загрузка данных при появлении компонента и по `reload()`. + * + * `loader` должен быть стабильным (функция уровня модуля или `useCallback`), иначе загрузка пойдёт на каждой отрисовке. + * Защита от устаревших ответов: у каждого запуска свой номер; применяется только ответ последнего запуска. Ответ, + * пришедший после более нового `reload()` или после ухода со страницы, отбрасывается. + */ +export function useAsyncData(loader: () => Promise): AsyncData { + const notifyError = useNotifyError(); + const [data, setData] = useState(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 }; +} diff --git a/src/widgets/Sidebar/Sidebar.tsx b/src/widgets/Sidebar/Sidebar.tsx new file mode 100644 index 0000000..95e6afa --- /dev/null +++ b/src/widgets/Sidebar/Sidebar.tsx @@ -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: , label: 'Главная' }], + }, + { + title: 'Система', + key: 'system', + children: [{ link: '/admin', icon: , 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 ( + + `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}> + {icon} + + + ); +} + +function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) { + return ( + `${styles.linkBase} ${isActive ? styles.linkActive : ''}`} to={to}> + {children} + + ); +} + +const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) { + return !isCollapsed ? ( +
+
{group.title}
+ {group.children.map((i) => ( + + {i.icon} + {i.label} + + ))} + +
+ ) : ( + <> + {group.children.map((i) => ( + + ))} + + ); +}); + +export function Sidebar() { + const [isCollapsed, setIsCollapsed] = useState(false); + const me = useCurrentUser(); + // useMemo: группы пересчитываются только при смене прав, иначе memo(Section) получал бы новый объект на каждой отрисовке + const groups = useMemo(() => visibleGroups(me.permissions), [me.permissions]); + + return ( + +
+
+ +
+ {groups.map((g) => ( +
+ ))} +
+
+ ); +} diff --git a/src/widgets/Sidebar/styles.module.css b/src/widgets/Sidebar/styles.module.css new file mode 100644 index 0000000..7f90c29 --- /dev/null +++ b/src/widgets/Sidebar/styles.module.css @@ -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; +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..9ef3fea --- /dev/null +++ b/tailwind.config.js @@ -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", + } + } + ] + } +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9b1f256 --- /dev/null +++ b/tsconfig.json @@ -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"] +}