deploy
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.claude
|
||||||
|
.planning
|
||||||
|
.env.*
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
.dockerignore
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.claude
|
||||||
|
.planning
|
||||||
|
.env
|
||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
FROM public.ecr.aws/docker/library/node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
FROM public.ecr.aws/docker/library/node:20-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM public.ecr.aws/nginx/nginx:1.27-alpine AS runtime
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
28
README.md
Normal file
28
README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# ЭКСА — Admin Frontend
|
||||||
|
|
||||||
|
Standalone admin panel for ЭКСА, extracted from the main `elcsa-gitea2/frontend` app
|
||||||
|
into its own microservice. React 19 + Vite, Feature-Sliced Design.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install # installs deps (lockfile committed)
|
||||||
|
npm run dev # dev server on port 3001
|
||||||
|
npm run build # tsc -b && vite build
|
||||||
|
npm run preview # preview production build
|
||||||
|
npm run lint # ESLint
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- **Auth is fully independent** of the user app. The admin API lives at
|
||||||
|
`https://app.admin.elcsa.ru` (hardcoded in `src/features/admin/api/adminApi.ts`),
|
||||||
|
with its own in-memory access token + localStorage-backed refresh token. No CSRF.
|
||||||
|
- **Routes** are preserved from the original app: the panel is served under the
|
||||||
|
obscured path `/sys-c7f29a4e-d81b-4630-ops-console`. See `src/shared/config/routes.ts`.
|
||||||
|
On a dedicated admin host this could be simplified to `/` — left unchanged to
|
||||||
|
preserve existing behavior.
|
||||||
|
- Path aliases (`@app/*`, `@pages/*`, `@widgets/*`, `@features/*`, `@shared/*`) match
|
||||||
|
the FSD layers, same as the parent project.
|
||||||
|
- Only the shared UI primitives the admin actually uses were copied
|
||||||
|
(`Button`, `FormField`, `Notification`, `PrimaryButton`).
|
||||||
10
docker-compose.yml
Normal file
10
docker-compose.yml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
admin-frontend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
image: elcsa-admin-frontend:latest
|
||||||
|
container_name: elcsa-admin-frontend
|
||||||
|
ports:
|
||||||
|
- "3001:80"
|
||||||
|
restart: unless-stopped
|
||||||
28
eslint.config.js
Normal file
28
eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': [
|
||||||
|
'warn',
|
||||||
|
{ allowConstantExport: true },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
14
index.html
Normal file
14
index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
|
<title>ЭКСА — Панель администрирования</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
21
nginx.conf
Normal file
21
nginx.conf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
3382
package-lock.json
generated
Normal file
3382
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "elcsa-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint ."
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.100.9",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.5"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/react": "^19.0.2",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"typescript": "~5.6.2",
|
||||||
|
"typescript-eslint": "^8.18.2",
|
||||||
|
"vite": "^6.0.5",
|
||||||
|
"vite-tsconfig-paths": "^5.1.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/app/App.tsx
Normal file
9
src/app/App.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { RouterProvider, QueryProvider } from './providers'
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
return (
|
||||||
|
<QueryProvider>
|
||||||
|
<RouterProvider />
|
||||||
|
</QueryProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
10
src/app/providers/QueryProvider.tsx
Normal file
10
src/app/providers/QueryProvider.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
|
||||||
|
export function QueryProvider({ children }: { children: ReactNode }) {
|
||||||
|
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||||
|
}
|
||||||
18
src/app/providers/RouterProvider.tsx
Normal file
18
src/app/providers/RouterProvider.tsx
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
|
import { AdminPage } from '@pages/admin'
|
||||||
|
import { AdminOrganizationPage } from '@pages/admin-organization'
|
||||||
|
import { ROUTES } from '@shared/config/routes'
|
||||||
|
import { ScrollToTop } from './ScrollToTop'
|
||||||
|
|
||||||
|
export function RouterProvider() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<ScrollToTop />
|
||||||
|
<Routes>
|
||||||
|
{/* Admin panel — own auth gate, independent of the user auth system */}
|
||||||
|
<Route path={ROUTES.ADMIN} element={<AdminPage />} />
|
||||||
|
<Route path={ROUTES.ADMIN_ORGANIZATION} element={<AdminOrganizationPage />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/app/providers/ScrollToTop.tsx
Normal file
12
src/app/providers/ScrollToTop.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function ScrollToTop() {
|
||||||
|
const { pathname } = useLocation()
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.scrollTo(0, 0)
|
||||||
|
}, [pathname])
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
2
src/app/providers/index.ts
Normal file
2
src/app/providers/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { RouterProvider } from './RouterProvider'
|
||||||
|
export { QueryProvider } from './QueryProvider'
|
||||||
1
src/app/styles/fonts.css
Normal file
1
src/app/styles/fonts.css
Normal file
@@ -0,0 +1 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap');
|
||||||
32
src/app/styles/global.css
Normal file
32
src/app/styles/global.css
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--bg-deep);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow-x: hidden;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
scrollbar-color: var(--grad-center) var(--bg-mid);
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-mid);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--grad-center);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--highlight);
|
||||||
|
}
|
||||||
34
src/app/styles/reset.css
Normal file
34
src/app/styles/reset.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
img,
|
||||||
|
svg {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
20
src/app/styles/variables.css
Normal file
20
src/app/styles/variables.css
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
:root {
|
||||||
|
--bg-deep: #0a0b2e;
|
||||||
|
--bg-mid: #1b1547;
|
||||||
|
--grad-edge: #3d2a8e;
|
||||||
|
--grad-center: #5b3db8;
|
||||||
|
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #b5b0cc;
|
||||||
|
|
||||||
|
--interactive: #4a6dff;
|
||||||
|
--highlight: #00d4ff;
|
||||||
|
--success: #26a17b;
|
||||||
|
--error: #ff4466;
|
||||||
|
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.08);
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.04);
|
||||||
|
|
||||||
|
--font-sans: 'Manrope', system-ui, sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
219
src/features/admin/api/adminApi.ts
Normal file
219
src/features/admin/api/adminApi.ts
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import type {
|
||||||
|
AdminLoginRequest,
|
||||||
|
AdminLoginResponse,
|
||||||
|
AdminMeResponse,
|
||||||
|
AdminRefreshResponse,
|
||||||
|
CreateOrganizationRequest,
|
||||||
|
CreateWalletsResponse,
|
||||||
|
WalletResponse,
|
||||||
|
DocumentResponse,
|
||||||
|
DocumentTypeSlug,
|
||||||
|
Organization,
|
||||||
|
OrganizationListResponse,
|
||||||
|
PurchaseRequestListResponse,
|
||||||
|
UpdateOrganizationRequest,
|
||||||
|
} from '../model/types'
|
||||||
|
|
||||||
|
const ADMIN_API_URL = 'https://app.admin.elcsa.ru'
|
||||||
|
|
||||||
|
// In-memory admin access token — deliberately separate from the user `tokenStore`
|
||||||
|
// so the two independent auth systems never collide. No CSRF on the admin API.
|
||||||
|
let adminToken: string | null = null
|
||||||
|
|
||||||
|
export const adminTokenStore = {
|
||||||
|
get: () => adminToken,
|
||||||
|
set: (token: string) => { adminToken = token },
|
||||||
|
clear: () => { adminToken = null },
|
||||||
|
}
|
||||||
|
|
||||||
|
// The refresh token is body-based (sent in the `/v1/auth/refresh` request body),
|
||||||
|
// so it must persist across reloads to restore a session. localStorage-backed.
|
||||||
|
const ADMIN_REFRESH_KEY = 'admin_refresh_token'
|
||||||
|
|
||||||
|
export const adminRefreshStore = {
|
||||||
|
get: (): string | null => {
|
||||||
|
try { return localStorage.getItem(ADMIN_REFRESH_KEY) } catch { return null }
|
||||||
|
},
|
||||||
|
set: (token: string) => {
|
||||||
|
try { localStorage.setItem(ADMIN_REFRESH_KEY, token) } catch { /* ignore */ }
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
try { localStorage.removeItem(ADMIN_REFRESH_KEY) } catch { /* ignore */ }
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAdminRequest<T>(
|
||||||
|
path: string,
|
||||||
|
options: RequestInit,
|
||||||
|
allowRetry: boolean,
|
||||||
|
): Promise<T> {
|
||||||
|
const bearer = adminTokenStore.get()
|
||||||
|
// For multipart uploads we must NOT set Content-Type — the browser adds the
|
||||||
|
// boundary itself. Detect FormData bodies and skip the JSON header.
|
||||||
|
const isFormData = options.body instanceof FormData
|
||||||
|
|
||||||
|
const res = await fetch(`${ADMIN_API_URL}${path}`, {
|
||||||
|
...options,
|
||||||
|
credentials: 'include',
|
||||||
|
headers: {
|
||||||
|
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||||
|
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401 && allowRetry) {
|
||||||
|
try {
|
||||||
|
await refreshAdminToken()
|
||||||
|
return doAdminRequest<T>(path, options, false)
|
||||||
|
} catch {
|
||||||
|
adminTokenStore.clear()
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json().catch(() => null)
|
||||||
|
if (!res.ok) throw data
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
// Body-based refresh: the API expects the refresh token in the request body and
|
||||||
|
// returns a fresh access + refresh token pair (the refresh token rotates).
|
||||||
|
export async function refreshAdminToken(): Promise<string> {
|
||||||
|
const refreshToken = adminRefreshStore.get()
|
||||||
|
if (!refreshToken) throw new Error('Unauthorized')
|
||||||
|
|
||||||
|
const res = await fetch(`${ADMIN_API_URL}/v1/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
adminRefreshStore.clear()
|
||||||
|
adminTokenStore.clear()
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
const data = (await res.json()) as AdminRefreshResponse
|
||||||
|
if (data.access_token) adminTokenStore.set(data.access_token)
|
||||||
|
if (data.refresh_token) adminRefreshStore.set(data.refresh_token)
|
||||||
|
return data.access_token
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLogin(payload: AdminLoginRequest): Promise<AdminLoginResponse> {
|
||||||
|
const data = await doAdminRequest<AdminLoginResponse>(
|
||||||
|
'/v1/auth/login',
|
||||||
|
{ method: 'POST', body: JSON.stringify(payload) },
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
if (data.access_token) adminTokenStore.set(data.access_token)
|
||||||
|
if (data.refresh_token) adminRefreshStore.set(data.refresh_token)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminMe(): Promise<AdminMeResponse> {
|
||||||
|
return doAdminRequest<AdminMeResponse>('/v1/auth/me', {}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLogout(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await doAdminRequest<unknown>('/v1/auth/logout', { method: 'POST' }, false)
|
||||||
|
} finally {
|
||||||
|
adminTokenStore.clear()
|
||||||
|
adminRefreshStore.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizations(limit = 50, offset = 0): Promise<OrganizationListResponse> {
|
||||||
|
return doAdminRequest<OrganizationListResponse>(
|
||||||
|
`/v1/organizations?limit=${limit}&offset=${offset}`,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrganization(payload: CreateOrganizationRequest): Promise<Organization> {
|
||||||
|
return doAdminRequest<Organization>(
|
||||||
|
'/v1/organizations',
|
||||||
|
{ method: 'POST', body: JSON.stringify(payload) },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganization(id: string): Promise<Organization> {
|
||||||
|
return doAdminRequest<Organization>(`/v1/organizations/${id}`, {}, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createOrganizationWallets(id: string): Promise<CreateWalletsResponse> {
|
||||||
|
return doAdminRequest<CreateWalletsResponse>(
|
||||||
|
`/v1/organizations/${id}/wallets/create`,
|
||||||
|
{ method: 'POST', body: JSON.stringify({ id }) },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOrganizationWallets(id: string): Promise<WalletResponse[]> {
|
||||||
|
return doAdminRequest<WalletResponse[]>(
|
||||||
|
`/v1/organizations/${id}/wallets`,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocuments(orgId: string): Promise<DocumentResponse[]> {
|
||||||
|
return doAdminRequest<DocumentResponse[]>(
|
||||||
|
`/v1/organizations/${orgId}/documents`,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload/replace a single typed document. The type is part of the path and
|
||||||
|
// the body carries only the file — PUT acts as an upsert for that slot.
|
||||||
|
export function uploadDocument(
|
||||||
|
orgId: string,
|
||||||
|
type: DocumentTypeSlug,
|
||||||
|
file: File,
|
||||||
|
): Promise<DocumentResponse> {
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('file', file)
|
||||||
|
|
||||||
|
return doAdminRequest<DocumentResponse>(
|
||||||
|
`/v1/organizations/${orgId}/documents/${type}`,
|
||||||
|
{ method: 'PUT', body },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPurchaseRequests(params: {
|
||||||
|
organizationId?: string
|
||||||
|
status?: string
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}): Promise<PurchaseRequestListResponse> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params.organizationId) query.set('organization_id', params.organizationId)
|
||||||
|
if (params.status) query.set('status', params.status)
|
||||||
|
query.set('limit', String(params.limit ?? 50))
|
||||||
|
query.set('offset', String(params.offset ?? 0))
|
||||||
|
|
||||||
|
const data = await doAdminRequest<PurchaseRequestListResponse>(
|
||||||
|
`/v1/purchase-requests?${query.toString()}`,
|
||||||
|
{},
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
// TEMP: inspect real backend shape — especially which `status` values appear.
|
||||||
|
console.log('[purchase-requests] list response:', data)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateOrganization(
|
||||||
|
id: string,
|
||||||
|
payload: UpdateOrganizationRequest,
|
||||||
|
): Promise<Organization> {
|
||||||
|
return doAdminRequest<Organization>(
|
||||||
|
`/v1/organizations/${id}`,
|
||||||
|
{ method: 'PATCH', body: JSON.stringify(payload) },
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
19
src/features/admin/hooks/useAdminAuth.ts
Normal file
19
src/features/admin/hooks/useAdminAuth.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getAdminMe } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const ADMIN_AUTH_QUERY_KEY = ['admin-auth']
|
||||||
|
|
||||||
|
export function useAdminAuth(): { isAuthenticated: boolean; isLoading: boolean } {
|
||||||
|
// `getAdminMe` is the real "is the session valid" check. It runs through
|
||||||
|
// `doAdminRequest` with retry, so a 401 transparently triggers a body-based
|
||||||
|
// refresh (using the persisted refresh token) and replays the request.
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ADMIN_AUTH_QUERY_KEY,
|
||||||
|
queryFn: getAdminMe,
|
||||||
|
retry: false,
|
||||||
|
staleTime: Infinity,
|
||||||
|
gcTime: Infinity,
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
})
|
||||||
|
return { isAuthenticated: !!data && !isError, isLoading }
|
||||||
|
}
|
||||||
22
src/features/admin/hooks/useAdminLogin.ts
Normal file
22
src/features/admin/hooks/useAdminLogin.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { adminLogin } from '../api/adminApi'
|
||||||
|
import { ADMIN_AUTH_QUERY_KEY } from './useAdminAuth'
|
||||||
|
|
||||||
|
export function useAdminLogin() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: adminLogin,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
// Tokens are already stored by adminLogin. Seed the gate's cache with the
|
||||||
|
// AdminMeResponse-shaped profile (login response carries all these fields)
|
||||||
|
// so we flip to "authenticated" without an extra /auth/me round-trip.
|
||||||
|
queryClient.setQueryData(ADMIN_AUTH_QUERY_KEY, {
|
||||||
|
id: data.id,
|
||||||
|
login: data.login,
|
||||||
|
first_name: data.first_name,
|
||||||
|
last_name: data.last_name,
|
||||||
|
role: data.role,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
14
src/features/admin/hooks/useAdminLogout.ts
Normal file
14
src/features/admin/hooks/useAdminLogout.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { adminLogout } from '../api/adminApi'
|
||||||
|
import { ADMIN_AUTH_QUERY_KEY } from './useAdminAuth'
|
||||||
|
|
||||||
|
export function useAdminLogout() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: adminLogout,
|
||||||
|
onSuccess: () => {
|
||||||
|
// Flip the gate back to "not authenticated" without triggering a /refresh refetch.
|
||||||
|
queryClient.setQueryData(ADMIN_AUTH_QUERY_KEY, null)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
13
src/features/admin/hooks/useCreateOrganization.ts
Normal file
13
src/features/admin/hooks/useCreateOrganization.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createOrganization } from '../api/adminApi'
|
||||||
|
import { ORGANIZATIONS_QUERY_KEY } from './useOrganizations'
|
||||||
|
|
||||||
|
export function useCreateOrganization() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createOrganization,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ORGANIZATIONS_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
18
src/features/admin/hooks/useCreateOrganizationWallets.ts
Normal file
18
src/features/admin/hooks/useCreateOrganizationWallets.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { createOrganizationWallets } from '../api/adminApi'
|
||||||
|
import { ORGANIZATIONS_QUERY_KEY } from './useOrganizations'
|
||||||
|
import { ORGANIZATION_QUERY_KEY } from './useOrganization'
|
||||||
|
import { WALLETS_QUERY_KEY } from './useOrganizationWallets'
|
||||||
|
|
||||||
|
export function useCreateOrganizationWallets() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (organizationId: string) => createOrganizationWallets(organizationId),
|
||||||
|
onSuccess: (_result, organizationId) => {
|
||||||
|
// `has_wallets` flips to true server-side — refresh list and detail view.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ORGANIZATIONS_QUERY_KEY })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ORGANIZATION_QUERY_KEY(organizationId) })
|
||||||
|
queryClient.invalidateQueries({ queryKey: WALLETS_QUERY_KEY(organizationId) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/features/admin/hooks/useDocuments.ts
Normal file
12
src/features/admin/hooks/useDocuments.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getDocuments } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const DOCUMENTS_QUERY_KEY = (orgId: string) => ['admin-documents', orgId]
|
||||||
|
|
||||||
|
export function useDocuments(orgId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: DOCUMENTS_QUERY_KEY(orgId ?? ''),
|
||||||
|
queryFn: () => getDocuments(orgId as string),
|
||||||
|
enabled: !!orgId,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/features/admin/hooks/useOrganization.ts
Normal file
12
src/features/admin/hooks/useOrganization.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getOrganization } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const ORGANIZATION_QUERY_KEY = (id: string) => ['admin-organization', id]
|
||||||
|
|
||||||
|
export function useOrganization(id: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ORGANIZATION_QUERY_KEY(id ?? ''),
|
||||||
|
queryFn: () => getOrganization(id as string),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
12
src/features/admin/hooks/useOrganizationWallets.ts
Normal file
12
src/features/admin/hooks/useOrganizationWallets.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getOrganizationWallets } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const WALLETS_QUERY_KEY = (orgId: string) => ['admin-wallets', orgId]
|
||||||
|
|
||||||
|
export function useOrganizationWallets(orgId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: WALLETS_QUERY_KEY(orgId ?? ''),
|
||||||
|
queryFn: () => getOrganizationWallets(orgId as string),
|
||||||
|
enabled: !!orgId,
|
||||||
|
})
|
||||||
|
}
|
||||||
11
src/features/admin/hooks/useOrganizations.ts
Normal file
11
src/features/admin/hooks/useOrganizations.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getOrganizations } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const ORGANIZATIONS_QUERY_KEY = ['admin-organizations']
|
||||||
|
|
||||||
|
export function useOrganizations() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ORGANIZATIONS_QUERY_KEY,
|
||||||
|
queryFn: () => getOrganizations(),
|
||||||
|
})
|
||||||
|
}
|
||||||
15
src/features/admin/hooks/usePurchaseRequests.ts
Normal file
15
src/features/admin/hooks/usePurchaseRequests.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { getPurchaseRequests } from '../api/adminApi'
|
||||||
|
|
||||||
|
export const PURCHASE_REQUESTS_QUERY_KEY = (orgId: string) => [
|
||||||
|
'admin-purchase-requests',
|
||||||
|
orgId,
|
||||||
|
]
|
||||||
|
|
||||||
|
export function usePurchaseRequests(orgId: string | undefined) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: PURCHASE_REQUESTS_QUERY_KEY(orgId ?? ''),
|
||||||
|
queryFn: () => getPurchaseRequests({ organizationId: orgId }),
|
||||||
|
enabled: !!orgId,
|
||||||
|
})
|
||||||
|
}
|
||||||
16
src/features/admin/hooks/useUpdateOrganization.ts
Normal file
16
src/features/admin/hooks/useUpdateOrganization.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { updateOrganization } from '../api/adminApi'
|
||||||
|
import type { UpdateOrganizationRequest } from '../model/types'
|
||||||
|
import { ORGANIZATIONS_QUERY_KEY } from './useOrganizations'
|
||||||
|
import { ORGANIZATION_QUERY_KEY } from './useOrganization'
|
||||||
|
|
||||||
|
export function useUpdateOrganization(id: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateOrganizationRequest) => updateOrganization(id, payload),
|
||||||
|
onSuccess: (data) => {
|
||||||
|
queryClient.setQueryData(ORGANIZATION_QUERY_KEY(id), data)
|
||||||
|
queryClient.invalidateQueries({ queryKey: ORGANIZATIONS_QUERY_KEY })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
20
src/features/admin/hooks/useUploadDocument.ts
Normal file
20
src/features/admin/hooks/useUploadDocument.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { uploadDocument } from '../api/adminApi'
|
||||||
|
import type { DocumentTypeSlug } from '../model/types'
|
||||||
|
import { DOCUMENTS_QUERY_KEY } from './useDocuments'
|
||||||
|
|
||||||
|
interface UploadArgs {
|
||||||
|
type: DocumentTypeSlug
|
||||||
|
file: File
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUploadDocument(orgId: string) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: ({ type, file }: UploadArgs) =>
|
||||||
|
uploadDocument(orgId, type, file),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: DOCUMENTS_QUERY_KEY(orgId) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
43
src/features/admin/index.ts
Normal file
43
src/features/admin/index.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
export {
|
||||||
|
adminLogin,
|
||||||
|
adminLogout,
|
||||||
|
getAdminMe,
|
||||||
|
getOrganizations,
|
||||||
|
getOrganization,
|
||||||
|
createOrganization,
|
||||||
|
createOrganizationWallets,
|
||||||
|
getOrganizationWallets,
|
||||||
|
updateOrganization,
|
||||||
|
getDocuments,
|
||||||
|
uploadDocument,
|
||||||
|
getPurchaseRequests,
|
||||||
|
refreshAdminToken,
|
||||||
|
adminTokenStore,
|
||||||
|
} from './api/adminApi'
|
||||||
|
export type {
|
||||||
|
AdminLoginRequest,
|
||||||
|
AdminLoginResponse,
|
||||||
|
AdminMeResponse,
|
||||||
|
Organization,
|
||||||
|
OrganizationListResponse,
|
||||||
|
CreateOrganizationRequest,
|
||||||
|
UpdateOrganizationRequest,
|
||||||
|
WalletResponse,
|
||||||
|
DocumentResponse,
|
||||||
|
DocumentTypeSlug,
|
||||||
|
PurchaseRequestResponse,
|
||||||
|
PurchaseRequestListResponse,
|
||||||
|
BankDetails,
|
||||||
|
} from './model/types'
|
||||||
|
export { useAdminAuth, ADMIN_AUTH_QUERY_KEY } from './hooks/useAdminAuth'
|
||||||
|
export { useAdminLogin } from './hooks/useAdminLogin'
|
||||||
|
export { useAdminLogout } from './hooks/useAdminLogout'
|
||||||
|
export { useOrganizations, ORGANIZATIONS_QUERY_KEY } from './hooks/useOrganizations'
|
||||||
|
export { useOrganization, ORGANIZATION_QUERY_KEY } from './hooks/useOrganization'
|
||||||
|
export { useCreateOrganization } from './hooks/useCreateOrganization'
|
||||||
|
export { useCreateOrganizationWallets } from './hooks/useCreateOrganizationWallets'
|
||||||
|
export { useUpdateOrganization } from './hooks/useUpdateOrganization'
|
||||||
|
export { useOrganizationWallets, WALLETS_QUERY_KEY } from './hooks/useOrganizationWallets'
|
||||||
|
export { useDocuments, DOCUMENTS_QUERY_KEY } from './hooks/useDocuments'
|
||||||
|
export { useUploadDocument } from './hooks/useUploadDocument'
|
||||||
|
export { usePurchaseRequests, PURCHASE_REQUESTS_QUERY_KEY } from './hooks/usePurchaseRequests'
|
||||||
151
src/features/admin/model/types.ts
Normal file
151
src/features/admin/model/types.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
export interface AdminLoginRequest {
|
||||||
|
login: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminLoginResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
token_type: string
|
||||||
|
id: string
|
||||||
|
login: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminRefreshResponse {
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
token_type?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminMeResponse {
|
||||||
|
id: string
|
||||||
|
login: string
|
||||||
|
first_name: string | null
|
||||||
|
last_name: string | null
|
||||||
|
role: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BankDetails = Record<string, unknown>
|
||||||
|
|
||||||
|
export interface Organization {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
name: string
|
||||||
|
short_name: string | null
|
||||||
|
inn: string
|
||||||
|
ogrn: string | null
|
||||||
|
kpp: string | null
|
||||||
|
legal_address: string | null
|
||||||
|
actual_address: string | null
|
||||||
|
bank_details: BankDetails | null
|
||||||
|
contact_person: string | null
|
||||||
|
contact_phone: string | null
|
||||||
|
status: string
|
||||||
|
kyc_verified: boolean
|
||||||
|
kyc_verified_at: string | null
|
||||||
|
has_wallets: boolean
|
||||||
|
created_by: string | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OrganizationListResponse {
|
||||||
|
items: Organization[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WalletResponse {
|
||||||
|
id: string
|
||||||
|
chain: string
|
||||||
|
address: string
|
||||||
|
derivation_path: string
|
||||||
|
created_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWalletsRequest {
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateWalletsResponse {
|
||||||
|
wallets: WalletResponse[]
|
||||||
|
mnemonic: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fixed document type slugs — match the path segments of the per-type
|
||||||
|
// PUT/GET endpoints (`/documents/{slug}`). The API no longer accepts a
|
||||||
|
// free-text document type.
|
||||||
|
export type DocumentTypeSlug =
|
||||||
|
| 'charter'
|
||||||
|
| 'inn-certificate'
|
||||||
|
| 'ogrn-certificate'
|
||||||
|
| 'bank-details'
|
||||||
|
| 'kyc-representative'
|
||||||
|
| 'power-of-attorney'
|
||||||
|
| 'other'
|
||||||
|
|
||||||
|
export interface DocumentResponse {
|
||||||
|
organization_id: string
|
||||||
|
document_type: string
|
||||||
|
s3_key: string | null
|
||||||
|
file_name: string | null
|
||||||
|
content_type: string | null
|
||||||
|
file_size_bytes: number | null
|
||||||
|
download_url: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monetary fields are strings to preserve decimal precision — do not coerce to number.
|
||||||
|
export interface PurchaseRequestResponse {
|
||||||
|
id: string
|
||||||
|
organization_id: string
|
||||||
|
status: string
|
||||||
|
usdt_amount: string
|
||||||
|
rub_amount: string | null
|
||||||
|
exchange_rate: string | null
|
||||||
|
service_fee_percent: string | null
|
||||||
|
comment: string | null
|
||||||
|
admin_comment: string | null
|
||||||
|
target_wallet_chain: string | null
|
||||||
|
target_wallet_address: string | null
|
||||||
|
tx_hash: string | null
|
||||||
|
assigned_to: string | null
|
||||||
|
created_at: string | null
|
||||||
|
updated_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseRequestListResponse {
|
||||||
|
items: PurchaseRequestResponse[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateOrganizationRequest {
|
||||||
|
name?: string | null
|
||||||
|
short_name?: string | null
|
||||||
|
ogrn?: string | null
|
||||||
|
kpp?: string | null
|
||||||
|
legal_address?: string | null
|
||||||
|
actual_address?: string | null
|
||||||
|
bank_details?: BankDetails | null
|
||||||
|
contact_person?: string | null
|
||||||
|
contact_phone?: string | null
|
||||||
|
status?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateOrganizationRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
name: string
|
||||||
|
inn: string
|
||||||
|
short_name?: string | null
|
||||||
|
ogrn?: string | null
|
||||||
|
kpp?: string | null
|
||||||
|
legal_address?: string | null
|
||||||
|
actual_address?: string | null
|
||||||
|
bank_details?: BankDetails | null
|
||||||
|
contact_person?: string | null
|
||||||
|
contact_phone?: string | null
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
15
src/main.tsx
Normal file
15
src/main.tsx
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import './app/styles/fonts.css'
|
||||||
|
import './app/styles/reset.css'
|
||||||
|
import './app/styles/variables.css'
|
||||||
|
import './app/styles/global.css'
|
||||||
|
|
||||||
|
import { App } from './app/App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
1
src/pages/admin-organization/index.ts
Normal file
1
src/pages/admin-organization/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AdminOrganizationPage } from './ui/AdminOrganizationPage'
|
||||||
90
src/pages/admin-organization/model/useOrganizationForm.ts
Normal file
90
src/pages/admin-organization/model/useOrganizationForm.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useUpdateOrganization } from '@features/admin'
|
||||||
|
import type { Organization, UpdateOrganizationRequest } from '@features/admin'
|
||||||
|
|
||||||
|
interface FormState {
|
||||||
|
name: string
|
||||||
|
short_name: string
|
||||||
|
ogrn: string
|
||||||
|
kpp: string
|
||||||
|
legal_address: string
|
||||||
|
actual_address: string
|
||||||
|
contact_person: string
|
||||||
|
contact_phone: string
|
||||||
|
status: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function toForm(org: Organization): FormState {
|
||||||
|
return {
|
||||||
|
name: org.name ?? '',
|
||||||
|
short_name: org.short_name ?? '',
|
||||||
|
ogrn: org.ogrn ?? '',
|
||||||
|
kpp: org.kpp ?? '',
|
||||||
|
legal_address: org.legal_address ?? '',
|
||||||
|
actual_address: org.actual_address ?? '',
|
||||||
|
contact_person: org.contact_person ?? '',
|
||||||
|
contact_phone: org.contact_phone ?? '',
|
||||||
|
status: org.status ?? '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
const e = error as { detail?: unknown }
|
||||||
|
if (typeof e?.detail === 'string') return e.detail
|
||||||
|
if (Array.isArray(e?.detail) && (e.detail[0] as { msg?: string })?.msg) {
|
||||||
|
return (e.detail[0] as { msg: string }).msg
|
||||||
|
}
|
||||||
|
return 'Не удалось сохранить изменения'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useOrganizationForm(
|
||||||
|
org: Organization | undefined,
|
||||||
|
id: string,
|
||||||
|
onSaved?: () => void,
|
||||||
|
) {
|
||||||
|
const [form, setForm] = useState<FormState>(() =>
|
||||||
|
org ? toForm(org) : {
|
||||||
|
name: '', short_name: '', ogrn: '', kpp: '', legal_address: '',
|
||||||
|
actual_address: '', contact_person: '', contact_phone: '', status: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const mutation = useUpdateOrganization(id)
|
||||||
|
|
||||||
|
// Sync local form state once the organization loads / changes.
|
||||||
|
useEffect(() => {
|
||||||
|
if (org) setForm(toForm(org))
|
||||||
|
}, [org])
|
||||||
|
|
||||||
|
const setField = (key: keyof FormState) => (value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmedOrNull = (v: string) => (v.trim() ? v.trim() : null)
|
||||||
|
|
||||||
|
const payload: UpdateOrganizationRequest = {
|
||||||
|
name: form.name.trim(),
|
||||||
|
short_name: trimmedOrNull(form.short_name),
|
||||||
|
ogrn: trimmedOrNull(form.ogrn),
|
||||||
|
kpp: trimmedOrNull(form.kpp),
|
||||||
|
legal_address: trimmedOrNull(form.legal_address),
|
||||||
|
actual_address: trimmedOrNull(form.actual_address),
|
||||||
|
contact_person: trimmedOrNull(form.contact_person),
|
||||||
|
contact_phone: trimmedOrNull(form.contact_phone),
|
||||||
|
status: trimmedOrNull(form.status),
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(payload, { onSuccess: () => onSaved?.() })
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = mutation.isError ? extractErrorMessage(mutation.error) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
setField,
|
||||||
|
handleSubmit,
|
||||||
|
isSaving: mutation.isPending,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/pages/admin-organization/ui/AdminOrganizationPage.module.css
Normal file
154
src/pages/admin-organization/ui/AdminOrganizationPage.module.css
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
padding: 40px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back:hover {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: clamp(24px, 3.5vw, 34px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 12px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabActive {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
border-bottom-color: var(--interactive, #4a6dff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabPanel {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bankLabel {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--glass-bg, rgba(255, 255, 255, 0.06));
|
||||||
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
resize: vertical;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--interactive, #4a6dff);
|
||||||
|
box-shadow: 0 0 0 3px rgba(74, 109, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff5a5a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
max-width: 320px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page {
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
150
src/pages/admin-organization/ui/AdminOrganizationPage.tsx
Normal file
150
src/pages/admin-organization/ui/AdminOrganizationPage.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useAdminAuth, useOrganization } from '@features/admin'
|
||||||
|
import { ROUTES } from '@shared/config/routes'
|
||||||
|
import { FormField, Notification, PrimaryButton } from '@shared/ui'
|
||||||
|
import { AdminLoginForm } from '@widgets/admin-login-form'
|
||||||
|
import { OrganizationDocuments } from '@widgets/organization-documents'
|
||||||
|
import { OrganizationPurchaseRequests } from '@widgets/organization-purchase-requests'
|
||||||
|
import { OrganizationWallets } from '@widgets/organization-wallets'
|
||||||
|
import { useOrganizationForm } from '../model/useOrganizationForm'
|
||||||
|
import styles from './AdminOrganizationPage.module.css'
|
||||||
|
|
||||||
|
type Tab = 'info' | 'wallets' | 'documents' | 'requests'
|
||||||
|
|
||||||
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
|
{ id: 'info', label: 'Общая информация' },
|
||||||
|
{ id: 'wallets', label: 'Кошельки' },
|
||||||
|
{ id: 'documents', label: 'Документы' },
|
||||||
|
{ id: 'requests', label: 'Заявки' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminOrganizationPage() {
|
||||||
|
const { isAuthenticated, isLoading: isAuthLoading } = useAdminAuth()
|
||||||
|
const { organizationId } = useParams<{ organizationId: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { data: org, isLoading, isError } = useOrganization(organizationId)
|
||||||
|
const [notice, setNotice] = useState(false)
|
||||||
|
const [activeTab, setActiveTab] = useState<Tab>('info')
|
||||||
|
const { form, setField, handleSubmit, isSaving, error } = useOrganizationForm(
|
||||||
|
org,
|
||||||
|
organizationId ?? '',
|
||||||
|
() => setNotice(true),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isAuthLoading) return null
|
||||||
|
if (!isAuthenticated) return <AdminLoginForm />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<button className={styles.back} type="button" onClick={() => navigate(ROUTES.ADMIN)}>
|
||||||
|
← Назад к списку
|
||||||
|
</button>
|
||||||
|
<h1 className={styles.title}>{org ? org.name : 'Юридическое лицо'}</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{isLoading && <div className={styles.state}>Загрузка...</div>}
|
||||||
|
{isError && <div className={styles.state}>Не удалось загрузить организацию</div>}
|
||||||
|
|
||||||
|
{org && (
|
||||||
|
<div className={styles.tabs}>
|
||||||
|
{TABS.map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tab} ${activeTab === tab.id ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org && activeTab === 'info' && (
|
||||||
|
<form className={styles.form} onSubmit={handleSubmit}>
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Реквизиты</h2>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Наименование" value={form.name} onChange={setField('name')} placeholder="ООО «Ромашка»" required />
|
||||||
|
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
|
||||||
|
<FormField label="ИНН" value={org.inn} readOnly icon="lock" />
|
||||||
|
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="1027700132195" />
|
||||||
|
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="770801001" />
|
||||||
|
<FormField label="Статус" value={form.status} onChange={setField('status')} placeholder="active" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Адреса</h2>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Юридический адрес" value={form.legal_address} onChange={setField('legal_address')} placeholder="г. Москва, ул. Тверская, д. 1" />
|
||||||
|
<FormField label="Фактический адрес" value={form.actual_address} onChange={setField('actual_address')} placeholder="г. Москва, ул. Тверская, д. 1" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Контакты</h2>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="Иванов Иван Иванович" />
|
||||||
|
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Системная информация</h2>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="ID организации" value={org.id} readOnly icon="lock" />
|
||||||
|
<FormField label="ID пользователя" value={org.user_id} readOnly icon="lock" />
|
||||||
|
<FormField label="KYC" value={org.kyc_verified ? 'Подтверждён' : 'Не подтверждён'} readOnly />
|
||||||
|
<FormField label="Дата KYC" value={formatDateTime(org.kyc_verified_at)} readOnly />
|
||||||
|
<FormField label="Кошельки" value={org.has_wallets ? 'Есть' : 'Нет'} readOnly />
|
||||||
|
<FormField label="Создано" value={formatDateTime(org.created_at)} readOnly />
|
||||||
|
<FormField label="Обновлено" value={formatDateTime(org.updated_at)} readOnly />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<PrimaryButton label={isSaving ? 'Сохранение...' : 'Сохранить изменения'} disabled={isSaving} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org && activeTab === 'wallets' && (
|
||||||
|
<div className={styles.tabPanel}>
|
||||||
|
<OrganizationWallets orgId={org.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org && activeTab === 'documents' && (
|
||||||
|
<div className={styles.tabPanel}>
|
||||||
|
<OrganizationDocuments orgId={org.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{org && activeTab === 'requests' && (
|
||||||
|
<div className={styles.tabPanel}>
|
||||||
|
<OrganizationPurchaseRequests orgId={org.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notice && (
|
||||||
|
<Notification
|
||||||
|
status="success"
|
||||||
|
message="Изменения сохранены"
|
||||||
|
onClose={() => setNotice(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/pages/admin/index.ts
Normal file
1
src/pages/admin/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AdminPage } from './ui/AdminPage'
|
||||||
84
src/pages/admin/ui/AdminPage.module.css
Normal file
84
src/pages/admin/ui/AdminPage.module.css
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
padding: 40px 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.greeting {
|
||||||
|
font-size: clamp(28px, 4vw, 40px);
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||||
|
border-radius: 10px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, color 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout:hover {
|
||||||
|
background: rgba(255, 90, 90, 0.12);
|
||||||
|
border-color: rgba(255, 90, 90, 0.3);
|
||||||
|
color: #ff5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn {
|
||||||
|
background: linear-gradient(135deg, var(--grad-edge, #4a6dff), var(--grad-center, #6f4aff));
|
||||||
|
border: none;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: filter 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.addBtn:hover {
|
||||||
|
filter: brightness(1.12);
|
||||||
|
box-shadow: 0 6px 20px rgba(74, 109, 255, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.page {
|
||||||
|
padding: 28px 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
src/pages/admin/ui/AdminPage.tsx
Normal file
77
src/pages/admin/ui/AdminPage.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAdminAuth, useAdminLogout, useCreateOrganizationWallets } from '@features/admin'
|
||||||
|
import type { Organization } from '@features/admin'
|
||||||
|
import { Notification } from '@shared/ui'
|
||||||
|
import { AdminLoginForm } from '@widgets/admin-login-form'
|
||||||
|
import { LegalEntitiesTable } from '@widgets/legal-entities-table'
|
||||||
|
import { AddLegalEntityModal } from '@widgets/add-legal-entity-modal'
|
||||||
|
import styles from './AdminPage.module.css'
|
||||||
|
|
||||||
|
type NotificationState = { message: string; status: 'success' | 'error' | 'warning' }
|
||||||
|
|
||||||
|
export function AdminPage() {
|
||||||
|
const { isAuthenticated, isLoading } = useAdminAuth()
|
||||||
|
const logout = useAdminLogout()
|
||||||
|
const createWallets = useCreateOrganizationWallets()
|
||||||
|
const [modalOpen, setModalOpen] = useState(false)
|
||||||
|
const [notification, setNotification] = useState<NotificationState | null>(null)
|
||||||
|
|
||||||
|
// After a legal entity is created we immediately provision its wallets.
|
||||||
|
// The page stays mounted (unlike the modal), so these mutate callbacks fire reliably.
|
||||||
|
function handleCreated(organization: Organization) {
|
||||||
|
setNotification({ status: 'success', message: 'Юридическое лицо добавлено' })
|
||||||
|
createWallets.mutate(organization.id, {
|
||||||
|
onSuccess: (result) => {
|
||||||
|
setNotification({
|
||||||
|
status: 'success',
|
||||||
|
message: `Кошельки созданы (${result.wallets.length})`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setNotification({
|
||||||
|
status: 'warning',
|
||||||
|
message: 'Юридическое лицо создано, но кошельки создать не удалось',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return null
|
||||||
|
if (!isAuthenticated) return <AdminLoginForm />
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.page}>
|
||||||
|
<header className={styles.header}>
|
||||||
|
<h1 className={styles.greeting}>Привет, Марк!</h1>
|
||||||
|
<button className={styles.logout} type="button" onClick={() => logout.mutate()}>
|
||||||
|
Выйти
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className={styles.content}>
|
||||||
|
<div className={styles.toolbar}>
|
||||||
|
<h2 className={styles.sectionTitle}>Юридические лица</h2>
|
||||||
|
<button className={styles.addBtn} type="button" onClick={() => setModalOpen(true)}>
|
||||||
|
+ Добавить юридическое лицо
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LegalEntitiesTable />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AddLegalEntityModal
|
||||||
|
open={modalOpen}
|
||||||
|
onClose={() => setModalOpen(false)}
|
||||||
|
onCreated={handleCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{notification && (
|
||||||
|
<Notification
|
||||||
|
status={notification.status}
|
||||||
|
message={notification.message}
|
||||||
|
onClose={() => setNotification(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
7
src/shared/config/routes.ts
Normal file
7
src/shared/config/routes.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const ROUTES = {
|
||||||
|
ADMIN: '/sys-c7f29a4e-d81b-4630-ops-console',
|
||||||
|
ADMIN_ORGANIZATION: '/sys-c7f29a4e-d81b-4630-ops-console/organizations/:organizationId',
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export const adminOrganizationPath = (id: string) =>
|
||||||
|
`/sys-c7f29a4e-d81b-4630-ops-console/organizations/${id}`
|
||||||
56
src/shared/ui/Button/Button.module.css
Normal file
56
src/shared/ui/Button/Button.module.css
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
.btn {
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
transition: background 0.2s, opacity 0.2s, border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary {
|
||||||
|
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
background: rgba(255, 77, 77, 0.1);
|
||||||
|
border: 1px solid rgba(255, 77, 77, 0.3);
|
||||||
|
color: #ff4d4d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger:hover {
|
||||||
|
background: rgba(255, 77, 77, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost {
|
||||||
|
background: rgba(74, 109, 255, 0.15);
|
||||||
|
border: 1px solid rgba(74, 109, 255, 0.3);
|
||||||
|
color: var(--interactive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ghost:hover {
|
||||||
|
background: rgba(74, 109, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.outline:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
17
src/shared/ui/Button/Button.tsx
Normal file
17
src/shared/ui/Button/Button.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import styles from './Button.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
variant: 'primary' | 'danger' | 'ghost' | 'outline'
|
||||||
|
children: React.ReactNode
|
||||||
|
onClick?: () => void
|
||||||
|
type?: 'button' | 'submit'
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({ variant, children, onClick, type = 'button', disabled }: Props) {
|
||||||
|
return (
|
||||||
|
<button type={type} className={`${styles.btn} ${styles[variant]}`} onClick={onClick} disabled={disabled}>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/shared/ui/Button/index.ts
Normal file
1
src/shared/ui/Button/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Button } from './Button'
|
||||||
97
src/shared/ui/FormField/FormField.module.css
Normal file
97
src/shared/ui/FormField/FormField.module.css
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
.field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wrap {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: rgba(255, 255, 255, 0.06);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
padding: 0 16px;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.2s, box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input:focus {
|
||||||
|
border-color: var(--interactive);
|
||||||
|
box-shadow: 0 0 0 3px rgba(74, 109, 255, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.readonly {
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
border-style: dashed;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copied {
|
||||||
|
border-color: var(--success);
|
||||||
|
border-style: solid;
|
||||||
|
box-shadow: 0 0 0 3px rgba(38, 161, 123, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconCopied {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconCheck {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
color: var(--success);
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconLock {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.withToggle {
|
||||||
|
padding-right: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.togglePw {
|
||||||
|
position: absolute;
|
||||||
|
right: 14px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
transition: color 0.2s;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.togglePw:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
70
src/shared/ui/FormField/FormField.tsx
Normal file
70
src/shared/ui/FormField/FormField.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import styles from './FormField.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label: string
|
||||||
|
value: string
|
||||||
|
placeholder?: string
|
||||||
|
type?: 'text' | 'email' | 'tel' | 'password'
|
||||||
|
onChange?: (value: string) => void
|
||||||
|
onBlur?: () => void
|
||||||
|
readOnly?: boolean
|
||||||
|
required?: boolean
|
||||||
|
icon?: 'check' | 'lock'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormField({ label, value, placeholder, type = 'text', onChange, onBlur, readOnly, required, icon }: Props) {
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [isVisible, setIsVisible] = useState(false)
|
||||||
|
|
||||||
|
const isPassword = type === 'password'
|
||||||
|
const inputType = isPassword ? (isVisible ? 'text' : 'password') : type
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (!readOnly) return
|
||||||
|
navigator.clipboard.writeText(value).then(() => {
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 1500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.field}>
|
||||||
|
<label className={styles.label}>{label}</label>
|
||||||
|
<div className={styles.wrap} onClick={handleClick}>
|
||||||
|
<input
|
||||||
|
className={`${styles.input} ${isPassword ? styles.withToggle : ''} ${readOnly ? styles.readonly : ''} ${copied ? styles.copied : ''}`}
|
||||||
|
type={inputType}
|
||||||
|
{...(onChange
|
||||||
|
? { value, onChange: (e) => onChange(e.target.value) }
|
||||||
|
: { defaultValue: value }
|
||||||
|
)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readOnly={readOnly}
|
||||||
|
required={required}
|
||||||
|
onBlur={onBlur}
|
||||||
|
/>
|
||||||
|
{isPassword && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.togglePw}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setIsVisible((v) => !v) }}
|
||||||
|
aria-label={isVisible ? 'Скрыть пароль' : 'Показать пароль'}
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94" /><path d="M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19" /><line x1="1" y1="1" x2="23" y2="23" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8S1 12 1 12z" /><circle cx="12" cy="12" r="3" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{icon === 'check' && <span className={styles.iconCheck}>✓</span>}
|
||||||
|
{icon === 'lock' && <span className={styles.iconLock}>🔒</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/shared/ui/FormField/index.ts
Normal file
1
src/shared/ui/FormField/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { FormField } from './FormField'
|
||||||
108
src/shared/ui/Notification/Notification.module.css
Normal file
108
src/shared/ui/Notification/Notification.module.css
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
.notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 360px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--bg-mid);
|
||||||
|
border: 1px solid var(--glass-border);
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||||
|
animation: slideIn 0.3s cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notificationWrapper {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification.closing {
|
||||||
|
animation: slideOut 0.25s cubic-bezier(0.55, 0, 1, 0.45) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
transform: translateX(calc(100% + 24px));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideOut {
|
||||||
|
from {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: translateX(calc(100% + 24px));
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-top: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.success .icon {
|
||||||
|
background: var(--success);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error .icon {
|
||||||
|
background: var(--error);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info .icon {
|
||||||
|
background: var(--interactive);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.warning .icon {
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
line-height: 1.5;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
line-height: 1;
|
||||||
|
margin-top: 2px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
42
src/shared/ui/Notification/Notification.tsx
Normal file
42
src/shared/ui/Notification/Notification.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import styles from './Notification.module.css'
|
||||||
|
|
||||||
|
type Status = 'success' | 'error' | 'info' | 'warning'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
message: string
|
||||||
|
status: Status
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICONS: Record<Status, string> = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
info: 'i',
|
||||||
|
warning: '!',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Notification({ message, status, onClose }: Props) {
|
||||||
|
const [closing, setClosing] = useState(false)
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
setClosing(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAnimationEnd() {
|
||||||
|
if (closing) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${styles.notification} ${styles[status]} ${closing ? styles.closing : ''}`}
|
||||||
|
onAnimationEnd={handleAnimationEnd}
|
||||||
|
>
|
||||||
|
<div className={styles.notificationWrapper}>
|
||||||
|
<span className={styles.icon}>{ICONS[status]}</span>
|
||||||
|
<p className={styles.message}>{message}</p>
|
||||||
|
</div>
|
||||||
|
<button className={styles.close} onClick={handleClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/shared/ui/Notification/index.ts
Normal file
1
src/shared/ui/Notification/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Notification } from './Notification'
|
||||||
19
src/shared/ui/PrimaryButton/PrimaryButton.module.css
Normal file
19
src/shared/ui/PrimaryButton/PrimaryButton.module.css
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 56px;
|
||||||
|
background: linear-gradient(135deg, var(--grad-edge), var(--grad-center));
|
||||||
|
border: none;
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
transition: filter 0.25s, box-shadow 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
filter: brightness(1.15);
|
||||||
|
box-shadow: 0 0 24px rgba(91, 61, 184, 0.5);
|
||||||
|
}
|
||||||
16
src/shared/ui/PrimaryButton/PrimaryButton.tsx
Normal file
16
src/shared/ui/PrimaryButton/PrimaryButton.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import styles from './PrimaryButton.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
label?: string
|
||||||
|
onClick?: () => void
|
||||||
|
type?: 'button' | 'submit'
|
||||||
|
disabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrimaryButton({ label = 'Подтвердить своп', onClick, type = 'submit', disabled }: Props) {
|
||||||
|
return (
|
||||||
|
<button type={type} className={styles.btn} onClick={onClick} disabled={disabled}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/shared/ui/PrimaryButton/index.ts
Normal file
1
src/shared/ui/PrimaryButton/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PrimaryButton } from './PrimaryButton'
|
||||||
4
src/shared/ui/index.ts
Normal file
4
src/shared/ui/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { Button } from './Button'
|
||||||
|
export { FormField } from './FormField'
|
||||||
|
export { Notification } from './Notification'
|
||||||
|
export { PrimaryButton } from './PrimaryButton'
|
||||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
1
src/widgets/add-legal-entity-modal/index.ts
Normal file
1
src/widgets/add-legal-entity-modal/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AddLegalEntityModal } from './ui/AddLegalEntityModal'
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useCreateOrganization } from '@features/admin'
|
||||||
|
import type { CreateOrganizationRequest, Organization } from '@features/admin'
|
||||||
|
|
||||||
|
const INITIAL = {
|
||||||
|
email: '',
|
||||||
|
password: '',
|
||||||
|
name: '',
|
||||||
|
inn: '',
|
||||||
|
short_name: '',
|
||||||
|
ogrn: '',
|
||||||
|
kpp: '',
|
||||||
|
legal_address: '',
|
||||||
|
actual_address: '',
|
||||||
|
contact_person: '',
|
||||||
|
contact_phone: '',
|
||||||
|
status: 'active',
|
||||||
|
bank_name: '',
|
||||||
|
bik: '',
|
||||||
|
account: '',
|
||||||
|
corr_account: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
type FormState = typeof INITIAL
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
const e = error as { detail?: unknown }
|
||||||
|
if (typeof e?.detail === 'string') return e.detail
|
||||||
|
if (Array.isArray(e?.detail) && (e.detail[0] as { msg?: string })?.msg) {
|
||||||
|
return (e.detail[0] as { msg: string }).msg
|
||||||
|
}
|
||||||
|
return 'Не удалось добавить юридическое лицо'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAddLegalEntityForm(onSuccess: (organization: Organization) => void) {
|
||||||
|
const [form, setForm] = useState<FormState>(INITIAL)
|
||||||
|
const mutation = useCreateOrganization()
|
||||||
|
|
||||||
|
const setField = (key: keyof FormState) => (value: string) =>
|
||||||
|
setForm((prev) => ({ ...prev, [key]: value }))
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
|
||||||
|
const trimmedOrNull = (v: string) => (v.trim() ? v.trim() : null)
|
||||||
|
|
||||||
|
const bankEntries: Record<string, string> = {}
|
||||||
|
if (form.bank_name.trim()) bankEntries.bank_name = form.bank_name.trim()
|
||||||
|
if (form.bik.trim()) bankEntries.bik = form.bik.trim()
|
||||||
|
if (form.account.trim()) bankEntries.account = form.account.trim()
|
||||||
|
if (form.corr_account.trim()) bankEntries.corr_account = form.corr_account.trim()
|
||||||
|
|
||||||
|
const payload: CreateOrganizationRequest = {
|
||||||
|
email: form.email.trim(),
|
||||||
|
password: form.password,
|
||||||
|
name: form.name.trim(),
|
||||||
|
inn: form.inn.trim(),
|
||||||
|
short_name: trimmedOrNull(form.short_name),
|
||||||
|
ogrn: trimmedOrNull(form.ogrn),
|
||||||
|
kpp: trimmedOrNull(form.kpp),
|
||||||
|
legal_address: trimmedOrNull(form.legal_address),
|
||||||
|
actual_address: trimmedOrNull(form.actual_address),
|
||||||
|
contact_person: trimmedOrNull(form.contact_person),
|
||||||
|
contact_phone: trimmedOrNull(form.contact_phone),
|
||||||
|
bank_details: Object.keys(bankEntries).length ? bankEntries : null,
|
||||||
|
status: form.status.trim() || 'active',
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation.mutate(payload, {
|
||||||
|
onSuccess: (organization) => {
|
||||||
|
setForm(INITIAL)
|
||||||
|
onSuccess(organization)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = mutation.isError ? extractErrorMessage(mutation.error) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
form,
|
||||||
|
setField,
|
||||||
|
handleSubmit,
|
||||||
|
isLoading: mutation.isPending,
|
||||||
|
error,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
@keyframes dialogIn {
|
||||||
|
from { opacity: 0; transform: scale(0.96); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog {
|
||||||
|
background: var(--bg-mid, #151520);
|
||||||
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 20px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 720px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
animation: dialogIn 0.18s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px 24px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.4));
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.closeBtn:hover {
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
padding: 16px 24px 24px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 16px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.groupLabel:first-child {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff5a5a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect } from 'react'
|
||||||
|
import type { Organization } from '@features/admin'
|
||||||
|
import { FormField, PrimaryButton } from '@shared/ui'
|
||||||
|
import { useAddLegalEntityForm } from '../model/useAddLegalEntityForm'
|
||||||
|
import styles from './AddLegalEntityModal.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: (organization: Organization) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddLegalEntityModal({ open, onClose, onCreated }: Props) {
|
||||||
|
const { form, setField, handleSubmit, isLoading, error } = useAddLegalEntityForm((organization) => {
|
||||||
|
onCreated(organization)
|
||||||
|
onClose()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', onKey)
|
||||||
|
return () => window.removeEventListener('keydown', onKey)
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
function handleOverlay(e: React.MouseEvent<HTMLDivElement>) {
|
||||||
|
if (e.target === e.currentTarget) onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.overlay} onMouseDown={handleOverlay}>
|
||||||
|
<div className={styles.dialog}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<span className={styles.title}>Добавить юридическое лицо</span>
|
||||||
|
<button className={styles.closeBtn} type="button" onClick={onClose} aria-label="Закрыть">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form className={styles.body} onSubmit={handleSubmit}>
|
||||||
|
<p className={styles.groupLabel}>Обязательные поля</p>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Email" type="email" value={form.email} onChange={setField('email')} placeholder="org@mail.ru" required />
|
||||||
|
<FormField label="Пароль" type="password" value={form.password} onChange={setField('password')} placeholder="Минимум 8 символов" required />
|
||||||
|
<FormField label="Наименование" value={form.name} onChange={setField('name')} placeholder="ООО «Ромашка»" required />
|
||||||
|
<FormField label="ИНН" value={form.inn} onChange={setField('inn')} placeholder="10–12 цифр" required />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.groupLabel}>Дополнительные поля</p>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
|
||||||
|
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="—" />
|
||||||
|
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="—" />
|
||||||
|
<FormField label="Статус" value={form.status} onChange={setField('status')} placeholder="active" />
|
||||||
|
<FormField label="Юридический адрес" value={form.legal_address} onChange={setField('legal_address')} placeholder="—" />
|
||||||
|
<FormField label="Фактический адрес" value={form.actual_address} onChange={setField('actual_address')} placeholder="—" />
|
||||||
|
<FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="—" />
|
||||||
|
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className={styles.groupLabel}>Банковские реквизиты</p>
|
||||||
|
<div className={styles.grid}>
|
||||||
|
<FormField label="Банк" value={form.bank_name} onChange={setField('bank_name')} placeholder="—" />
|
||||||
|
<FormField label="БИК" value={form.bik} onChange={setField('bik')} placeholder="—" />
|
||||||
|
<FormField label="Расчётный счёт" value={form.account} onChange={setField('account')} placeholder="—" />
|
||||||
|
<FormField label="Корр. счёт" value={form.corr_account} onChange={setField('corr_account')} placeholder="—" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<PrimaryButton label={isLoading ? 'Сохранение...' : 'Сохранить'} disabled={isLoading} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/widgets/admin-login-form/index.ts
Normal file
1
src/widgets/admin-login-form/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AdminLoginForm } from './ui/AdminLoginForm'
|
||||||
35
src/widgets/admin-login-form/model/useAdminLoginForm.ts
Normal file
35
src/widgets/admin-login-form/model/useAdminLoginForm.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useAdminLogin } from '@features/admin'
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
const e = error as { detail?: unknown }
|
||||||
|
if (typeof e?.detail === 'string') return e.detail
|
||||||
|
if (Array.isArray(e?.detail) && (e.detail[0] as { msg?: string })?.msg) {
|
||||||
|
return (e.detail[0] as { msg: string }).msg
|
||||||
|
}
|
||||||
|
return 'Неверный логин или пароль'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAdminLoginForm() {
|
||||||
|
const [login, setLogin] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const loginMutation = useAdminLogin()
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!login || !password) return
|
||||||
|
loginMutation.mutate({ login, password })
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = loginMutation.isError ? extractErrorMessage(loginMutation.error) : null
|
||||||
|
|
||||||
|
return {
|
||||||
|
login,
|
||||||
|
setLogin,
|
||||||
|
password,
|
||||||
|
setPassword,
|
||||||
|
isLoading: loginMutation.isPending,
|
||||||
|
error,
|
||||||
|
handleSubmit,
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/widgets/admin-login-form/ui/AdminLoginForm.module.css
Normal file
49
src/widgets/admin-login-form/ui/AdminLoginForm.module.css
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
.wrap {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px;
|
||||||
|
background: var(--bg-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--glass-bg, rgba(255, 255, 255, 0.06));
|
||||||
|
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 36px 32px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
margin: 0 0 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff5a5a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit {
|
||||||
|
margin-top: 26px;
|
||||||
|
}
|
||||||
40
src/widgets/admin-login-form/ui/AdminLoginForm.tsx
Normal file
40
src/widgets/admin-login-form/ui/AdminLoginForm.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { FormField, PrimaryButton } from '@shared/ui'
|
||||||
|
import { useAdminLoginForm } from '../model/useAdminLoginForm'
|
||||||
|
import styles from './AdminLoginForm.module.css'
|
||||||
|
|
||||||
|
export function AdminLoginForm() {
|
||||||
|
const { login, setLogin, password, setPassword, isLoading, error, handleSubmit } = useAdminLoginForm()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.wrap}>
|
||||||
|
<form className={styles.card} onSubmit={handleSubmit}>
|
||||||
|
<h1 className={styles.title}>Панель администратора</h1>
|
||||||
|
<p className={styles.subtitle}>Войдите, чтобы продолжить</p>
|
||||||
|
|
||||||
|
<div className={styles.fields}>
|
||||||
|
<FormField
|
||||||
|
label="Логин"
|
||||||
|
value={login}
|
||||||
|
onChange={setLogin}
|
||||||
|
placeholder="Введите логин"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
label="Пароль"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={setPassword}
|
||||||
|
placeholder="Введите пароль"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|
||||||
|
<div className={styles.submit}>
|
||||||
|
<PrimaryButton label={isLoading ? 'Вход...' : 'Войти'} disabled={isLoading} />
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/widgets/legal-entities-table/index.ts
Normal file
1
src/widgets/legal-entities-table/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LegalEntitiesTable } from './ui/LegalEntitiesTable'
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
.tableWrap {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 24px;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 16px 18px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
vertical-align: middle;
|
||||||
|
transition: background 0.2s ease;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table tr:hover td {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.row {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name {
|
||||||
|
font-weight: 600;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subname {
|
||||||
|
display: block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(74, 109, 255, 0.12);
|
||||||
|
color: #7c95ff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kyc {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kycOk {
|
||||||
|
background: rgba(0, 196, 140, 0.14);
|
||||||
|
color: #00c48c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kycNo {
|
||||||
|
background: rgba(255, 90, 90, 0.14);
|
||||||
|
color: #ff5a5a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
88
src/widgets/legal-entities-table/ui/LegalEntitiesTable.tsx
Normal file
88
src/widgets/legal-entities-table/ui/LegalEntitiesTable.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
import { useOrganizations } from '@features/admin'
|
||||||
|
import { adminOrganizationPath } from '@shared/config/routes'
|
||||||
|
import styles from './LegalEntitiesTable.module.css'
|
||||||
|
|
||||||
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
active: 'Активно',
|
||||||
|
blocked: 'Заблокировано',
|
||||||
|
inactive: 'Неактивно',
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleDateString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LegalEntitiesTable() {
|
||||||
|
const { data, isLoading, isError } = useOrganizations()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className={styles.tableWrap}><div className={styles.state}>Загрузка...</div></div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<div className={styles.state}>Не удалось загрузить список юридических лиц</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.items.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<div className={styles.state}>Юридические лица ещё не добавлены</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>ИНН</th>
|
||||||
|
<th>КПП</th>
|
||||||
|
<th>Контактное лицо</th>
|
||||||
|
<th>Телефон</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>KYC</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((org) => (
|
||||||
|
<tr
|
||||||
|
key={org.id}
|
||||||
|
className={styles.row}
|
||||||
|
onClick={() => navigate(adminOrganizationPath(org.id))}
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<span className={styles.name}>{org.name}</span>
|
||||||
|
{org.short_name && <span className={styles.subname}>{org.short_name}</span>}
|
||||||
|
</td>
|
||||||
|
<td className={styles.mono}>{org.inn}</td>
|
||||||
|
<td className={styles.mono}>{org.kpp ?? '—'}</td>
|
||||||
|
<td>{org.contact_person ?? '—'}</td>
|
||||||
|
<td className={styles.mono}>{org.contact_phone ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.status}>{STATUS_LABELS[org.status] ?? org.status}</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span className={`${styles.kyc} ${org.kyc_verified ? styles.kycOk : styles.kycNo}`}>
|
||||||
|
{org.kyc_verified ? 'Да' : 'Нет'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(org.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/widgets/organization-documents/index.ts
Normal file
1
src/widgets/organization-documents/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { OrganizationDocuments } from './ui/OrganizationDocuments'
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
.section {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slots {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: 16px 0;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot:first-child {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotInfo {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotLabel {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotFile {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
|
||||||
|
}
|
||||||
|
|
||||||
|
.slotActions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hiddenInput {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadBtn {
|
||||||
|
background: var(--interactive, #4a6dff);
|
||||||
|
border: none;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 11px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.uploadBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadBtn {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(74, 109, 255, 0.12);
|
||||||
|
border: 1px solid rgba(74, 109, 255, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #7c95ff;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 7px 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadBtn:hover {
|
||||||
|
background: rgba(74, 109, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.4));
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
color: #ff5a5a;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
148
src/widgets/organization-documents/ui/OrganizationDocuments.tsx
Normal file
148
src/widgets/organization-documents/ui/OrganizationDocuments.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { useRef, useState } from 'react'
|
||||||
|
import { useDocuments, useUploadDocument } from '@features/admin'
|
||||||
|
import type { DocumentResponse, DocumentTypeSlug } from '@features/admin'
|
||||||
|
import styles from './OrganizationDocuments.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOCUMENT_SLOTS: { type: DocumentTypeSlug; label: string }[] = [
|
||||||
|
{ type: 'charter', label: 'Устав' },
|
||||||
|
{ type: 'inn-certificate', label: 'Свидетельство ИНН' },
|
||||||
|
{ type: 'ogrn-certificate', label: 'Свидетельство ОГРН' },
|
||||||
|
{ type: 'bank-details', label: 'Банковские реквизиты' },
|
||||||
|
{ type: 'kyc-representative', label: 'Документ представителя (KYC)' },
|
||||||
|
{ type: 'power-of-attorney', label: 'Доверенность' },
|
||||||
|
{ type: 'other', label: 'Прочее' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function formatSize(bytes: number | null): string {
|
||||||
|
if (bytes == null) return '—'
|
||||||
|
if (bytes < 1024) return `${bytes} Б`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrorMessage(error: unknown): string {
|
||||||
|
const e = error as { detail?: unknown; message?: unknown }
|
||||||
|
if (typeof e?.detail === 'string') return e.detail
|
||||||
|
if (Array.isArray(e?.detail) && (e.detail[0] as { msg?: string })?.msg) {
|
||||||
|
return (e.detail[0] as { msg: string }).msg
|
||||||
|
}
|
||||||
|
if (typeof e?.message === 'string') return e.message
|
||||||
|
return 'Не удалось выполнить операцию'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationDocuments({ orgId }: Props) {
|
||||||
|
const { data: documents, isLoading, isError } = useDocuments(orgId)
|
||||||
|
const upload = useUploadDocument(orgId)
|
||||||
|
|
||||||
|
// Which slot's file picker is currently being uploaded — drives the per-row
|
||||||
|
// "Загрузка..." state and disables only the active row.
|
||||||
|
const [activeType, setActiveType] = useState<DocumentTypeSlug | null>(null)
|
||||||
|
|
||||||
|
// Map list items onto fixed slots. `document_type` may come back in
|
||||||
|
// underscore form (e.g. `inn_certificate`) while our slugs use hyphens.
|
||||||
|
const byType = new Map<string, DocumentResponse>()
|
||||||
|
for (const doc of documents ?? []) {
|
||||||
|
byType.set(doc.document_type.replace(/_/g, '-'), doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSelect(type: DocumentTypeSlug, input: HTMLInputElement) {
|
||||||
|
const file = input.files?.[0]
|
||||||
|
// Reset so picking the same file again still fires onChange.
|
||||||
|
input.value = ''
|
||||||
|
if (!file) return
|
||||||
|
setActiveType(type)
|
||||||
|
upload.mutate(
|
||||||
|
{ type, file },
|
||||||
|
{ onSettled: () => setActiveType(null) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadError = upload.isError ? extractErrorMessage(upload.error) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Документы</h2>
|
||||||
|
|
||||||
|
{uploadError && <p className={styles.error}>{uploadError}</p>}
|
||||||
|
|
||||||
|
{isLoading && <div className={styles.state}>Загрузка...</div>}
|
||||||
|
{isError && <div className={styles.state}>Не удалось загрузить документы</div>}
|
||||||
|
|
||||||
|
{!isLoading && !isError && (
|
||||||
|
<ul className={styles.slots}>
|
||||||
|
{DOCUMENT_SLOTS.map(({ type, label }) => {
|
||||||
|
const doc = byType.get(type)
|
||||||
|
const isUploading = upload.isPending && activeType === type
|
||||||
|
return (
|
||||||
|
<li key={type} className={styles.slot}>
|
||||||
|
<div className={styles.slotInfo}>
|
||||||
|
<span className={styles.slotLabel}>{label}</span>
|
||||||
|
{doc ? (
|
||||||
|
<span className={styles.slotFile}>
|
||||||
|
{doc.file_name ?? '—'}
|
||||||
|
<span className={styles.mono}> · {formatSize(doc.file_size_bytes)}</span>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={styles.muted}>Не загружен</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.slotActions}>
|
||||||
|
{doc?.download_url && (
|
||||||
|
<a
|
||||||
|
className={styles.downloadBtn}
|
||||||
|
href={doc.download_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<UploadButton
|
||||||
|
label={doc ? 'Заменить' : 'Загрузить'}
|
||||||
|
busy={isUploading}
|
||||||
|
disabled={upload.isPending}
|
||||||
|
onSelect={(input) => handleSelect(type, input)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UploadButtonProps {
|
||||||
|
label: string
|
||||||
|
busy: boolean
|
||||||
|
disabled: boolean
|
||||||
|
onSelect: (input: HTMLInputElement) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function UploadButton({ label, busy, disabled, onSelect }: UploadButtonProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.uploadBtn}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
>
|
||||||
|
{busy ? 'Загрузка...' : label}
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
className={styles.hiddenInput}
|
||||||
|
onChange={(e) => onSelect(e.currentTarget)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/widgets/organization-purchase-requests/index.ts
Normal file
1
src/widgets/organization-purchase-requests/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { OrganizationPurchaseRequests } from './ui/OrganizationPurchaseRequests'
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
.tableWrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 16px 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
background: rgba(74, 109, 255, 0.12);
|
||||||
|
color: #7c95ff;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { usePurchaseRequests } from '@features/admin'
|
||||||
|
import styles from './OrganizationPurchaseRequests.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAmount(value: string | null, suffix: string): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
return `${value} ${suffix}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationPurchaseRequests({ orgId }: Props) {
|
||||||
|
const { data, isLoading, isError } = usePurchaseRequests(orgId)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <div className={styles.state}>Загрузка...</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return <div className={styles.state}>Не удалось загрузить заявки</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.items.length === 0) {
|
||||||
|
return <div className={styles.state}>Заявок пока нет</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.tableWrap}>
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>USDT</th>
|
||||||
|
<th>Сумма ₽</th>
|
||||||
|
<th>Курс</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Создана</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.items.map((req) => (
|
||||||
|
<tr key={req.id}>
|
||||||
|
<td className={styles.mono}>{formatAmount(req.usdt_amount, 'USDT')}</td>
|
||||||
|
<td className={styles.mono}>{formatAmount(req.rub_amount, '₽')}</td>
|
||||||
|
<td className={styles.mono}>{req.exchange_rate ?? '—'}</td>
|
||||||
|
<td>
|
||||||
|
<span className={styles.status}>{req.status}</span>
|
||||||
|
</td>
|
||||||
|
<td>{formatDate(req.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
1
src/widgets/organization-wallets/index.ts
Normal file
1
src/widgets/organization-wallets/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { OrganizationWallets } from './ui/OrganizationWallets'
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
.section {
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table th {
|
||||||
|
text-align: left;
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 1.5px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0 16px 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table td {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
|
vertical-align: middle;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state {
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
53
src/widgets/organization-wallets/ui/OrganizationWallets.tsx
Normal file
53
src/widgets/organization-wallets/ui/OrganizationWallets.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { useOrganizationWallets } from '@features/admin'
|
||||||
|
import styles from './OrganizationWallets.module.css'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
orgId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | null): string {
|
||||||
|
if (!value) return '—'
|
||||||
|
const d = new Date(value)
|
||||||
|
if (Number.isNaN(d.getTime())) return '—'
|
||||||
|
return d.toLocaleString('ru-RU')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizationWallets({ orgId }: Props) {
|
||||||
|
const { data: wallets, isLoading, isError } = useOrganizationWallets(orgId)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className={styles.section}>
|
||||||
|
<h2 className={styles.sectionTitle}>Кошельки</h2>
|
||||||
|
|
||||||
|
{isLoading && <div className={styles.state}>Загрузка...</div>}
|
||||||
|
{isError && <div className={styles.state}>Не удалось загрузить кошельки</div>}
|
||||||
|
|
||||||
|
{wallets && wallets.length === 0 && (
|
||||||
|
<div className={styles.state}>Кошельки ещё не созданы</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{wallets && wallets.length > 0 && (
|
||||||
|
<table className={styles.table}>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Сеть</th>
|
||||||
|
<th>Адрес</th>
|
||||||
|
<th>Derivation path</th>
|
||||||
|
<th>Создано</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{wallets.map((wallet) => (
|
||||||
|
<tr key={wallet.id}>
|
||||||
|
<td>{wallet.chain}</td>
|
||||||
|
<td className={styles.mono}>{wallet.address}</td>
|
||||||
|
<td className={styles.mono}>{wallet.derivation_path}</td>
|
||||||
|
<td>{formatDate(wallet.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@app/*": ["src/app/*"],
|
||||||
|
"@pages/*": ["src/pages/*"],
|
||||||
|
"@widgets/*": ["src/widgets/*"],
|
||||||
|
"@features/*": ["src/features/*"],
|
||||||
|
"@entities/*": ["src/entities/*"],
|
||||||
|
"@shared/*": ["src/shared/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
1
tsconfig.tsbuildinfo
Normal file
1
tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/app.tsx","./src/app/providers/queryprovider.tsx","./src/app/providers/routerprovider.tsx","./src/app/providers/scrolltotop.tsx","./src/app/providers/index.ts","./src/features/admin/index.ts","./src/features/admin/api/adminapi.ts","./src/features/admin/hooks/useadminauth.ts","./src/features/admin/hooks/useadminlogin.ts","./src/features/admin/hooks/useadminlogout.ts","./src/features/admin/hooks/usecreateorganization.ts","./src/features/admin/hooks/usecreateorganizationwallets.ts","./src/features/admin/hooks/usedocuments.ts","./src/features/admin/hooks/useorganization.ts","./src/features/admin/hooks/useorganizationwallets.ts","./src/features/admin/hooks/useorganizations.ts","./src/features/admin/hooks/usepurchaserequests.ts","./src/features/admin/hooks/useupdateorganization.ts","./src/features/admin/hooks/useuploaddocument.ts","./src/features/admin/model/types.ts","./src/pages/admin/index.ts","./src/pages/admin/ui/adminpage.tsx","./src/pages/admin-organization/index.ts","./src/pages/admin-organization/model/useorganizationform.ts","./src/pages/admin-organization/ui/adminorganizationpage.tsx","./src/shared/config/routes.ts","./src/shared/ui/index.ts","./src/shared/ui/button/button.tsx","./src/shared/ui/button/index.ts","./src/shared/ui/formfield/formfield.tsx","./src/shared/ui/formfield/index.ts","./src/shared/ui/notification/notification.tsx","./src/shared/ui/notification/index.ts","./src/shared/ui/primarybutton/primarybutton.tsx","./src/shared/ui/primarybutton/index.ts","./src/widgets/add-legal-entity-modal/index.ts","./src/widgets/add-legal-entity-modal/model/useaddlegalentityform.ts","./src/widgets/add-legal-entity-modal/ui/addlegalentitymodal.tsx","./src/widgets/admin-login-form/index.ts","./src/widgets/admin-login-form/model/useadminloginform.ts","./src/widgets/admin-login-form/ui/adminloginform.tsx","./src/widgets/legal-entities-table/index.ts","./src/widgets/legal-entities-table/ui/legalentitiestable.tsx","./src/widgets/organization-documents/index.ts","./src/widgets/organization-documents/ui/organizationdocuments.tsx","./src/widgets/organization-purchase-requests/index.ts","./src/widgets/organization-purchase-requests/ui/organizationpurchaserequests.tsx","./src/widgets/organization-wallets/index.ts","./src/widgets/organization-wallets/ui/organizationwallets.tsx"],"version":"5.6.3"}
|
||||||
3
vercel.json
Normal file
3
vercel.json
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||||
|
}
|
||||||
10
vite.config.ts
Normal file
10
vite.config.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tsconfigPaths from 'vite-tsconfig-paths'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tsconfigPaths()],
|
||||||
|
server: {
|
||||||
|
port: 3001,
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user