remove admin

This commit is contained in:
2026-06-10 17:24:36 +03:00
parent 266dea2c86
commit 6c4f9a97a6
45 changed files with 1 additions and 2234 deletions

View File

@@ -18,8 +18,6 @@ import { PolitikaCookiePage } from '@pages/politika-cookie'
import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh' import { SoglasiePage } from '@pages/soglasie-personalnyh-dannyh'
import { ReestryPage } from '@pages/reestr-pd-rkn' import { ReestryPage } from '@pages/reestr-pd-rkn'
import { TransactionsPage } from '@pages/transactions' import { TransactionsPage } from '@pages/transactions'
import { AdminPage } from '@pages/admin'
import { AdminOrganizationPage } from '@pages/admin-organization'
import { WalletLayout } from '@widgets/wallet-layout' import { WalletLayout } from '@widgets/wallet-layout'
import { ROUTES } from '@shared/config/routes' import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop' import { ScrollToTop } from './ScrollToTop'
@@ -40,10 +38,6 @@ export function RouterProvider() {
<Route path={ROUTES.REGISTER_TEST} element={<RegisterTestPage />} /> <Route path={ROUTES.REGISTER_TEST} element={<RegisterTestPage />} />
<Route path={ROUTES.CONVERTER_TEST} element={<ConverterTestPage />} /> <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 path={ROUTES.ADMIN_ORGANIZATION} element={<AdminOrganizationPage />} />
<Route element={<GuestRoute />}> <Route element={<GuestRoute />}>
<Route path={ROUTES.LOGIN} element={<LoginPage />} /> <Route path={ROUTES.LOGIN} element={<LoginPage />} />
<Route path={ROUTES.REGISTER} element={<RegisterPage />} /> <Route path={ROUTES.REGISTER} element={<RegisterPage />} />

View File

@@ -1,219 +0,0 @@
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,
)
}

View File

@@ -1,19 +0,0 @@
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 }
}

View File

@@ -1,22 +0,0 @@
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,
})
},
})
}

View File

@@ -1,14 +0,0 @@
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)
},
})
}

View File

@@ -1,13 +0,0 @@
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

@@ -1,18 +0,0 @@
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) })
},
})
}

View File

@@ -1,12 +0,0 @@
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,
})
}

View File

@@ -1,12 +0,0 @@
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,
})
}

View File

@@ -1,12 +0,0 @@
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,
})
}

View File

@@ -1,11 +0,0 @@
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

@@ -1,15 +0,0 @@
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,
})
}

View File

@@ -1,16 +0,0 @@
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 })
},
})
}

View File

@@ -1,20 +0,0 @@
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) })
},
})
}

View File

@@ -1,43 +0,0 @@
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'

View File

@@ -1,151 +0,0 @@
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
}

View File

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

View File

@@ -1,90 +0,0 @@
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,
}
}

View File

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

View File

@@ -1,150 +0,0 @@
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>
)
}

View File

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

View File

@@ -1,84 +0,0 @@
.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

@@ -1,77 +0,0 @@
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>
)
}

View File

@@ -19,9 +19,4 @@ export const ROUTES = {
SOGLASIE_PERSONALNYH_DANNYH: '/soglasie-personalnyh-dannyh', SOGLASIE_PERSONALNYH_DANNYH: '/soglasie-personalnyh-dannyh',
REESTR_PD_RKN: '/reestr-pd-rkn', REESTR_PD_RKN: '/reestr-pd-rkn',
TRANSACTIONS: '/transactions', TRANSACTIONS: '/transactions',
ADMIN: '/sys-c7f29a4e-d81b-4630-ops-console',
ADMIN_ORGANIZATION: '/sys-c7f29a4e-d81b-4630-ops-console/organizations/:organizationId',
} as const } as const
export const adminOrganizationPath = (id: string) =>
`/sys-c7f29a4e-d81b-4630-ops-console/organizations/${id}`

View File

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

View File

@@ -1,86 +0,0 @@
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,
}
}

View File

@@ -1,101 +0,0 @@
@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

@@ -1,80 +0,0 @@
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="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

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

View File

@@ -1,35 +0,0 @@
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

@@ -1,49 +0,0 @@
.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

@@ -1,40 +0,0 @@
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

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

View File

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

View File

@@ -1,88 +0,0 @@
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>
)
}

View File

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

View File

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

View File

@@ -1,148 +0,0 @@
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)}
/>
</>
)
}

View File

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

View File

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

View File

@@ -1,63 +0,0 @@
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>
)
}

View File

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

View File

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

View File

@@ -1,53 +0,0 @@
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>
)
}

File diff suppressed because one or more lines are too long