admin page

This commit is contained in:
2026-06-05 12:46:05 +03:00
parent 6a399ea7ca
commit 0e92966a5d
35 changed files with 4498 additions and 414 deletions

161
dist/assets/index-CELGNVNm.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-Dz5BojOW.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bc6Kn5VS.css">
<script type="module" crossorigin src="/assets/index-CELGNVNm.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CVaTO0Sb.css">
</head>
<body>
<div id="root"></div>

View File

@@ -1,175 +0,0 @@
<!DOCTYPE html><html lang="ru"><head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ЭКСА — Сид Фраза</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0A0B2E;
color: #FFFFFF;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
min-height: 100vh;
-webkit-font-smoothing: antialiased;
}
/* Navbar */
.nav{display:flex;align-items:center;justify-content:space-between;padding:0 32px;height:60px;border-bottom:1px solid rgba(255,255,255,0.06);flex-shrink:0}
.nav-logo{display:flex;align-items:center}
.nav-logo img{height:32px}
.ticker{display:flex;gap:24px;font-size:13px;font-family:var(--mono)}
.tick{display:flex;align-items:center;gap:6px;color:#B5B0CC}
.tick b{color:#fff}
.tick .up{color:#00C48C}.tick .dn{color:#FF4D4D}
.nav-account{display:flex;align-items:center;gap:10px}
.avatar{width:34px;height:34px;border-radius:50%;background:#3D2A8E}
.nav-account span{color:#B5B0CC;font-size:14px;font-weight:500}
/* Content */
.content {
max-width: 960px;
margin: 0 auto;
padding: 40px 32px 60px;
}
/* Title row */
.title-row {
display: flex; align-items: flex-start; justify-content: space-between;
}
.title-row h1 {
font-size: 20px; font-weight: 700; letter-spacing: 0.04em;
}
.title-buttons {
display: flex; flex-direction: column; gap: 8px; align-items: flex-end;
}
.btn-outline {
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.1);
color: #fff;
border-radius: 10px;
width: 160px;
padding: 10px 0;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
cursor: pointer;
transition: background 0.2s, border-color 0.2s;
text-align: center;
}
.btn-outline:hover {
background: rgba(255,255,255,0.12);
border-color: rgba(255,255,255,0.18);
}
/* Subtitle */
.subtitle {
margin-top: 12px;
font-size: 12px;
color: #B5B0CC;
font-variant: all-small-caps;
letter-spacing: 0.08em;
}
.subtitle .countdown { color: #4A6DFF; font-weight: 700; }
/* Grid */
.seed-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 16px;
margin-top: 32px;
}
.seed-card {
background: rgba(255,255,255,0.04);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 14px;
height: 52px;
display: flex; align-items: center;
padding: 0 18px;
gap: 10px;
transition: border-color 0.25s, box-shadow 0.25s;
cursor: default;
user-select: none;
}
.seed-card:hover {
border-color: rgba(74,109,255,0.4);
box-shadow: 0 0 12px rgba(74,109,255,0.15);
}
.seed-num {
color: #B5B0CC;
font-size: 13px;
min-width: 22px;
flex-shrink: 0;
}
.seed-word {
flex: 1;
text-align: center;
font-size: 15px;
font-weight: 700;
color: #fff;
}
/* Warning */
.warning {
margin-top: 32px;
text-align: center;
display: flex;
flex-direction: column;
align-items: center;
}
.warning p {
max-width: 480px;
font-size: 13px;
color: #B5B0CC;
line-height: 1.6;
}
.warning .icon { color: #FF4D4D; font-size: 18px; margin-bottom: 8px; }
</style>
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>:root{--mono:'JetBrains Mono',monospace}</style></head>
<body data-cc-id="cc-4" style="cursor: crosshair; font-family: Manrope;">
<nav class="nav">
<div class="nav-logo">
<img src="logo-full-white.png" alt="ЭКСА">
</div>
<div class="ticker">
<div class="tick"><b>BTC</b> $66,916.00 <span class="up">+0.12%</span></div>
<div class="tick"><b>ETH</b> $2,053.97 <span class="dn">0.12%</span></div>
<div class="tick"><b>SOL</b> $163.84 <span class="dn">1.57%</span></div>
</div>
<div class="nav-account">
<div class="avatar"></div>
<span>Account 1</span>
</div>
</nav>
<main class="content" style="font-family: Manrope" data-cc-id="cc-5">
<div class="title-row" style="height: 70px; width: 896px" data-cc-id="cc-16">
<h1 style="width: 250px; font-size: 32px; font-family: Manrope;" data-cc-id="cc-17">СИД ФРАЗА</h1>
<div class="title-buttons">
<button class="btn-outline">СКРЫТЬ</button>
<button class="btn-outline" style="font-size: 13px">КОПИРОВАТЬ</button>
</div>
</div>
<div class="subtitle" style="font-size: 14px" data-cc-id="cc-15">АВТОМАТИЧЕСКОЕ СКРЫТИЕ ЧЕРЕЗ <span class="countdown" id="countdown">14</span>С</div>
<div class="seed-grid" id="seedGrid" data-cc-id="cc-9"><div class="seed-card"><span class="seed-num">1.</span><span class="seed-word">egg</span></div><div class="seed-card" data-cc-id="cc-13"><span class="seed-num">2.</span><span class="seed-word" data-cc-id="cc-14">phone</span></div><div class="seed-card"><span class="seed-num">3.</span><span class="seed-word">long</span></div><div class="seed-card"><span class="seed-num">4.</span><span class="seed-word">vibe</span></div><div class="seed-card" data-cc-id="cc-11"><span class="seed-num">5.</span><span class="seed-word" data-cc-id="cc-12">potato</span></div><div class="seed-card"><span class="seed-num">6.</span><span class="seed-word">soup</span></div><div class="seed-card" data-cc-id="cc-7"><span class="seed-num">7.</span><span class="seed-word" data-cc-id="cc-8">skirt</span></div><div class="seed-card" data-cc-id="cc-10"><span class="seed-num">8.</span><span class="seed-word">black</span></div><div class="seed-card"><span class="seed-num">9.</span><span class="seed-word">phase</span></div><div class="seed-card" data-cc-id="cc-6"><span class="seed-num">10.</span><span class="seed-word">word</span></div><div class="seed-card"><span class="seed-num">11.</span><span class="seed-word">num</span></div><div class="seed-card"><span class="seed-num">12.</span><span class="seed-word">cucumber</span></div></div>
<div class="warning" style="justify-content: center; flex-direction: row; align-items: flex-start">
<div class="icon" style="padding: 16px">⚠️</div>
<p>Никогда не передавайте сид-фразу третьим лицам. Тот, кто знает фразу — владеет кошельком.</p>
</div>
</main>
<script>
let sec = 52;
const el = document.getElementById('countdown');
setInterval(() => {
if (sec > 0) { sec--; el.textContent = sec; }
}, 1000);
</script>
</body></html>

View File

@@ -18,6 +18,7 @@ import { PolitikaCookiePage } from '@pages/politika-cookie'
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
import { ReestryPage } from '@pages/reestr-pd-rkn'
import { TransactionsPage } from '@pages/transactions'
import { AdminPage } from '@pages/admin'
import { WalletLayout } from '@widgets/wallet-layout'
import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop'
@@ -38,6 +39,9 @@ export function RouterProvider() {
<Route path={ROUTES.REGISTER_TEST} element={<RegisterTestPage />} />
<Route path={ROUTES.CONVERTER_TEST} element={<ConverterTestPage />} />
{/* Admin panel — own auth gate, independent of the user auth system */}
<Route path={ROUTES.ADMIN} element={<AdminPage />} />
<Route element={<GuestRoute />}>
<Route path={ROUTES.LOGIN} element={<LoginPage />} />
<Route path={ROUTES.REGISTER} element={<RegisterPage />} />

View File

@@ -0,0 +1,103 @@
import type {
AdminLoginRequest,
AdminLoginResponse,
AdminMeResponse,
CreateOrganizationRequest,
Organization,
OrganizationListResponse,
} from '../model/types'
const ADMIN_API_URL = 'https://app.admin.elcsa.ru/api'
// 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 },
}
async function doAdminRequest<T>(
path: string,
options: RequestInit,
allowRetry: boolean,
): Promise<T> {
const bearer = adminTokenStore.get()
const res = await fetch(`${ADMIN_API_URL}${path}`, {
...options,
credentials: 'include',
headers: {
'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
}
// Refresh by analogy with the main auth service: HttpOnly refresh cookie -> fresh access token.
// Exact path is isolated here — adjust if the backend differs (e.g. /v1/jwt/refresh).
export async function refreshAdminToken(): Promise<string> {
const res = await fetch(`${ADMIN_API_URL}/v1/auth/refresh`, {
method: 'POST',
credentials: 'include',
})
if (!res.ok) throw new Error('Unauthorized')
const data = await res.json()
if (data.access_token) adminTokenStore.set(data.access_token)
return (data.access_token ?? true) as string
}
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)
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()
}
}
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,
)
}

View File

@@ -0,0 +1,16 @@
import { useQuery } from '@tanstack/react-query'
import { refreshAdminToken } from '../api/adminApi'
export const ADMIN_AUTH_QUERY_KEY = ['admin-auth']
export function useAdminAuth(): { isAuthenticated: boolean; isLoading: boolean } {
const { data, isLoading, isError } = useQuery({
queryKey: ADMIN_AUTH_QUERY_KEY,
queryFn: refreshAdminToken,
retry: false,
staleTime: Infinity,
gcTime: Infinity,
refetchOnWindowFocus: false,
})
return { isAuthenticated: !!data && !isError, isLoading }
}

View File

@@ -0,0 +1,13 @@
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: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AUTH_QUERY_KEY })
},
})
}

View File

@@ -0,0 +1,13 @@
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: () => {
queryClient.invalidateQueries({ queryKey: ADMIN_AUTH_QUERY_KEY })
},
})
}

View 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 })
},
})
}

View 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(),
})
}

View File

@@ -0,0 +1,23 @@
export {
adminLogin,
adminLogout,
getAdminMe,
getOrganizations,
createOrganization,
refreshAdminToken,
adminTokenStore,
} from './api/adminApi'
export type {
AdminLoginRequest,
AdminLoginResponse,
AdminMeResponse,
Organization,
OrganizationListResponse,
CreateOrganizationRequest,
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 { useCreateOrganization } from './hooks/useCreateOrganization'

View File

@@ -0,0 +1,67 @@
export interface AdminLoginRequest {
login: string
password: string
}
export interface AdminLoginResponse {
access_token: string
token_type: string
id: string
login: string
first_name: string | null
last_name: string | null
role: 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 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
}

3106
src/openapi.json Normal file

File diff suppressed because it is too large Load Diff

1
src/pages/admin/index.ts Normal file
View File

@@ -0,0 +1 @@
export { AdminPage } from './ui/AdminPage'

View 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;
}
}

View File

@@ -0,0 +1,53 @@
import { useState } from 'react'
import { useAdminAuth, useAdminLogout } 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'
export function AdminPage() {
const { isAuthenticated, isLoading } = useAdminAuth()
const logout = useAdminLogout()
const [modalOpen, setModalOpen] = useState(false)
const [notification, setNotification] = useState<{ message: string; status: 'success' | 'error' } | null>(null)
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={() => setNotification({ status: 'success', message: 'Юридическое лицо добавлено' })}
/>
{notification && (
<Notification
status={notification.status}
message={notification.message}
onClose={() => setNotification(null)}
/>
)}
</div>
)
}

View File

@@ -19,4 +19,5 @@ export const ROUTES = {
SOGLASIE_PERSONALNYH_DANNYH: '/soglasie-personalnyh-dannyh',
REESTR_PD_RKN: '/reestr-pd-rkn',
TRANSACTIONS: '/transactions',
ADMIN: '/sys-c7f29a4e-d81b-4630-ops-console',
} as const

View File

@@ -0,0 +1 @@
export { AddLegalEntityModal } from './ui/AddLegalEntityModal'

View File

@@ -0,0 +1,86 @@
import { useState } from 'react'
import { useCreateOrganization } from '@features/admin'
import type { CreateOrganizationRequest } 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: () => 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: () => {
setForm(INITIAL)
onSuccess()
},
})
}
const error = mutation.isError ? extractErrorMessage(mutation.error) : null
return {
form,
setField,
handleSubmit,
isLoading: mutation.isPending,
error,
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,79 @@
import { useEffect } from 'react'
import { FormField, PrimaryButton } from '@shared/ui'
import { useAddLegalEntityForm } from '../model/useAddLegalEntityForm'
import styles from './AddLegalEntityModal.module.css'
interface Props {
open: boolean
onClose: () => void
onCreated: () => void
}
export function AddLegalEntityModal({ open, onClose, onCreated }: Props) {
const { form, setField, handleSubmit, isLoading, error } = useAddLegalEntityForm(() => {
onCreated()
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="1012 цифр" 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>
)
}

View File

@@ -0,0 +1 @@
export { AdminLoginForm } from './ui/AdminLoginForm'

View 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,
}
}

View 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;
}

View 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>
)
}

View File

@@ -0,0 +1 @@
export { LegalEntitiesTable } from './ui/LegalEntitiesTable'

View File

@@ -0,0 +1,89 @@
.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);
}
.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;
}

View File

@@ -0,0 +1,81 @@
import { useOrganizations } from '@features/admin'
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()
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}>
<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>
)
}

View File

@@ -0,0 +1,84 @@
import { FormField } from '@shared/ui'
import { PrimaryButton } from '@shared/ui'
import { Button } from '@shared/ui'
import { useRegisterForm } from '../model/useRegisterForm'
import styles from './RegisterForm.module.css'
export function IndividualForm() {
const {
email, setEmail,
password, setPassword,
confirmPassword, setConfirmPassword,
verificationCode, setVerificationCode,
codeSent,
isLoadingCode,
isLoadingSubmit,
error,
handleRequestCode,
handleSubmit,
} = useRegisterForm()
return (
<form onSubmit={handleSubmit}>
<div className={styles.twoCol}>
<div className={styles.leftCol}>
<FormField
label="Введите адрес электронной почты"
type="email"
value={email}
onChange={setEmail}
placeholder="example@mail.ru"
required
/>
<FormField
label="Придумайте пароль"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
required
/>
<FormField
label="Повторите пароль"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="••••••••"
required
/>
</div>
<div className={styles.rightCol}>
<Button
variant="ghost"
type="button"
onClick={handleRequestCode}
disabled={codeSent || isLoadingCode}
>
{isLoadingCode ? 'Отправка...' : codeSent ? 'Код отправлен' : 'Получить проверочный код'}
</Button>
<span className={styles.codeHint}>Код не пришёл</span>
<FormField
label="Ввести код"
type="text"
value={verificationCode}
onChange={setVerificationCode}
placeholder="000 000"
required
/>
</div>
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.submitWrapper}>
<PrimaryButton label={isLoadingSubmit ? 'Создание...' : 'Создать'} disabled={isLoadingSubmit} />
</div>
<p className={styles.legal}>
Нажимая «Создать», вы принимаете<br />
<a href="#">Пользовательское соглашение</a> и <a href="#">Политику конфиденциальности</a>
</p>
</form>
)
}

View File

@@ -0,0 +1,59 @@
import { useState } from 'react'
import { PrimaryButton } from '@shared/ui'
import styles from './RegisterForm.module.css'
const DOCS_EMAIL = 'company@elcsa.ru'
const REQUIRED_DOCUMENTS = [
'Устав организации в действующей редакции',
'Решение (протокол) о создании организации и о назначении руководителя',
'Выписка по расчётному счёту из банка за последние шесть месяцев',
'Выписка из Единого государственного реестра юридических лиц (ЕГРЮЛ)',
'Идентификатор электронного документооборота (ЭДО)',
'Реквизиты расчётного счёта: номер Р/С, БИК и наименование банка',
]
export function LegalRegisterInfo() {
const [sent, setSent] = useState(false)
if (sent) {
return (
<div className={styles.legalDone}>
<h2 className={styles.legalDoneTitle}>Спасибо!</h2>
<p className={styles.legalDoneText}>
Мы получили уведомление об отправке документов. После проверки мы свяжемся с вами
по указанному адресу электронной почты.
</p>
</div>
)
}
return (
<div className={styles.legalInfo}>
<p className={styles.legalIntro}>
Для регистрации юридического лица отправьте перечисленные ниже документы на нашу
электронную почту. После проверки мы свяжемся с вами для завершения регистрации.
</p>
<div className={styles.docsBlock}>
<span className={styles.docsLabel}>Необходимые документы</span>
<ul className={styles.docsList}>
{REQUIRED_DOCUMENTS.map((doc) => (
<li key={doc}>{doc}</li>
))}
</ul>
</div>
<div className={styles.emailBlock}>
<span className={styles.docsLabel}>Адрес для отправки документов</span>
<a href={`mailto:${DOCS_EMAIL}`} className={styles.emailLink}>
{DOCS_EMAIL}
</a>
</div>
<div className={styles.submitWrapper}>
<PrimaryButton label="Документы отправлены" type="button" onClick={() => setSent(true)} />
</div>
</div>
)
}

View File

@@ -26,6 +26,102 @@
line-height: 1.3;
}
.typeSelect {
display: flex;
flex-direction: column;
gap: 12px;
}
.typeSelect button {
width: 100%;
}
.back {
background: none;
border: none;
padding: 0;
margin-bottom: 20px;
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
cursor: pointer;
font-family: var(--font-sans);
}
.back:hover {
color: var(--text-primary);
}
.legalInfo {
display: flex;
flex-direction: column;
gap: 20px;
}
.legalIntro {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
}
.docsBlock,
.emailBlock {
display: flex;
flex-direction: column;
gap: 10px;
}
.docsLabel {
font-size: 13px;
font-weight: 700;
color: var(--text-primary);
}
.docsList {
margin: 0;
padding-left: 20px;
display: flex;
flex-direction: column;
gap: 8px;
}
.docsList li {
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
}
.emailLink {
font-size: 16px;
font-weight: 700;
color: var(--interactive);
text-decoration: none;
}
.emailLink:hover {
text-decoration: underline;
}
.legalDone {
display: flex;
flex-direction: column;
gap: 12px;
text-align: center;
padding: 12px 0;
}
.legalDoneTitle {
font-size: 20px;
font-weight: 700;
color: var(--text-primary);
}
.legalDoneText {
font-size: 14px;
line-height: 1.6;
color: var(--text-secondary);
}
.twoCol {
display: grid;
grid-template-columns: 1fr;

View File

@@ -1,90 +1,39 @@
import { FormField } from '@shared/ui'
import { PrimaryButton } from '@shared/ui'
import { useState } from 'react'
import { Button } from '@shared/ui'
import logo from '@shared/assets/logo-full-white.png'
import { useRegisterForm } from '../model/useRegisterForm'
import { IndividualForm } from './IndividualForm'
import { LegalRegisterInfo } from './LegalRegisterInfo'
import styles from './RegisterForm.module.css'
type RegisterType = 'individual' | 'legal'
export function RegisterForm() {
const {
email, setEmail,
password, setPassword,
confirmPassword, setConfirmPassword,
verificationCode, setVerificationCode,
codeSent,
isLoadingCode,
isLoadingSubmit,
error,
handleRequestCode,
handleSubmit,
} = useRegisterForm()
const [type, setType] = useState<RegisterType | null>(null)
return (
<form className={styles.card} onSubmit={handleSubmit}>
<div className={styles.card}>
<div className={styles.logo}>
<img src={logo} alt="ЭКСА" />
</div>
<h1 className={styles.title}>Создать кошелёк ЭКСА</h1>
<div className={styles.twoCol}>
<div className={styles.leftCol}>
<FormField
label="Введите адрес электронной почты"
type="email"
value={email}
onChange={setEmail}
placeholder="example@mail.ru"
required
/>
<FormField
label="Придумайте пароль"
type="password"
value={password}
onChange={setPassword}
placeholder="••••••••"
required
/>
<FormField
label="Повторите пароль"
type="password"
value={confirmPassword}
onChange={setConfirmPassword}
placeholder="••••••••"
required
/>
</div>
<div className={styles.rightCol}>
<Button
variant="ghost"
type="button"
onClick={handleRequestCode}
disabled={codeSent || isLoadingCode}
>
{isLoadingCode ? 'Отправка...' : codeSent ? 'Код отправлен' : 'Получить проверочный код'}
{type === null ? (
<div className={styles.typeSelect}>
<Button variant="primary" onClick={() => setType('individual')}>
Зарегистрироваться как физическое лицо
</Button>
<Button variant="outline" onClick={() => setType('legal')}>
Зарегистрироваться как юридическое лицо
</Button>
<span className={styles.codeHint}>Код не пришёл</span>
<FormField
label="Ввести код"
type="text"
value={verificationCode}
onChange={setVerificationCode}
placeholder="000 000"
required
/>
</div>
) : (
<>
<button type="button" className={styles.back} onClick={() => setType(null)}>
Назад к выбору
</button>
{type === 'individual' ? <IndividualForm /> : <LegalRegisterInfo />}
</>
)}
</div>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.submitWrapper}>
<PrimaryButton label={isLoadingSubmit ? 'Создание...' : 'Создать'} disabled={isLoadingSubmit} />
</div>
<p className={styles.legal}>
Нажимая «Создать», вы принимаете<br />
<a href="#">Пользовательское соглашение</a> и <a href="#">Политику конфиденциальности</a>
</p>
</form>
)
}

File diff suppressed because one or more lines are too long