This commit is contained in:
2026-06-10 19:07:29 +03:00
parent 01538b7e69
commit 2fdefeeadd
21 changed files with 883 additions and 43 deletions

View File

@@ -1,6 +1,7 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import { AdminPage } from '@pages/admin'
import { AdminOrganizationPage } from '@pages/admin-organization'
import { AdminCreatePurchaseRequestPage } from '@pages/admin-create-purchase-request'
import { ROUTES } from '@shared/config/routes'
import { ScrollToTop } from './ScrollToTop'
@@ -12,6 +13,10 @@ export function RouterProvider() {
{/* 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
path={ROUTES.ADMIN_ORGANIZATION_CREATE_REQUEST}
element={<AdminCreatePurchaseRequestPage />}
/>
</Routes>
</BrowserRouter>
)

View File

@@ -4,12 +4,14 @@ import type {
AdminMeResponse,
AdminRefreshResponse,
CreateOrganizationRequest,
CreatePurchaseRequestRequest,
CreateWalletsResponse,
WalletResponse,
DocumentResponse,
DocumentTypeSlug,
Organization,
OrganizationListResponse,
PurchaseRequestResponse,
PurchaseRequestListResponse,
UpdateOrganizationRequest,
} from '../model/types'
@@ -207,6 +209,16 @@ export async function getPurchaseRequests(params: {
return data
}
export function createPurchaseRequest(
payload: CreatePurchaseRequestRequest,
): Promise<PurchaseRequestResponse> {
return doAdminRequest<PurchaseRequestResponse>(
'/v1/purchase-requests',
{ method: 'POST', body: JSON.stringify(payload) },
true,
)
}
export function updateOrganization(
id: string,
payload: UpdateOrganizationRequest,

View File

@@ -0,0 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createPurchaseRequest } from '../api/adminApi'
import { PURCHASE_REQUESTS_QUERY_KEY } from './usePurchaseRequests'
export function useCreatePurchaseRequest(orgId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: createPurchaseRequest,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: PURCHASE_REQUESTS_QUERY_KEY(orgId) })
},
})
}

View File

@@ -11,6 +11,7 @@ export {
getDocuments,
uploadDocument,
getPurchaseRequests,
createPurchaseRequest,
refreshAdminToken,
adminTokenStore,
} from './api/adminApi'
@@ -21,6 +22,7 @@ export type {
Organization,
OrganizationListResponse,
CreateOrganizationRequest,
CreatePurchaseRequestRequest,
UpdateOrganizationRequest,
WalletResponse,
DocumentResponse,
@@ -41,3 +43,4 @@ export { useOrganizationWallets, WALLETS_QUERY_KEY } from './hooks/useOrganizati
export { useDocuments, DOCUMENTS_QUERY_KEY } from './hooks/useDocuments'
export { useUploadDocument } from './hooks/useUploadDocument'
export { usePurchaseRequests, PURCHASE_REQUESTS_QUERY_KEY } from './hooks/usePurchaseRequests'
export { useCreatePurchaseRequest } from './hooks/useCreatePurchaseRequest'

View File

@@ -121,6 +121,24 @@ export interface PurchaseRequestListResponse {
total: number
}
// Body of POST /v1/purchase-requests. Only organization_id and usdt_amount are
// required; the rest are optional and sent only when filled. Monetary fields
// accept number or string (the backend coerces to its decimal type).
export interface CreatePurchaseRequestRequest {
organization_id: string
usdt_amount: number | string
status?: string
rub_amount?: number | string | null
exchange_rate?: number | string | null
service_fee_percent?: number | 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
}
export interface UpdateOrganizationRequest {
name?: string | null
short_name?: string | null

View File

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

View File

@@ -0,0 +1,170 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useCreatePurchaseRequest } from '@features/admin'
import type { CreatePurchaseRequestRequest, Organization } from '@features/admin'
import { adminOrganizationPath } from '@shared/config/routes'
export const MIN_ORDER = 500_000
// Чем дольше клиент готов ждать, тем ниже комиссия сервиса. Скопировано с
// страницы конвертации для юр. лиц (LegalConverterPage), чтобы условия совпадали.
export const TERM_OPTIONS = [
{ days: 3, rate: 0.05 },
{ days: 4, rate: 0.04636 },
{ days: 5, rate: 0.04273 },
{ days: 6, rate: 0.03909 },
{ days: 7, rate: 0.03545 },
{ days: 8, rate: 0.03182 },
{ days: 9, rate: 0.02818 },
{ days: 10, rate: 0.02455 },
{ days: 11, rate: 0.02091 },
{ days: 12, rate: 0.01727 },
{ days: 13, rate: 0.01364 },
{ days: 14, rate: 0.01 },
] as const
export const ru = (n: number) => n.toLocaleString('ru-RU', { maximumFractionDigits: 0 })
export const dayLabel = (days: number) => {
const mod10 = days % 10
const mod100 = days % 100
if (mod10 === 1 && mod100 !== 11) return `${days} день`
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return `${days} дня`
return `${days} дней`
}
interface FormState {
amount: string
name: string
contact: string
status: string
rubAmount: string
exchangeRate: string
serviceFee: string
targetChain: string
targetAddress: string
txHash: string
assignedTo: string
adminComment: string
}
const INITIAL: FormState = {
amount: '',
name: '',
contact: '',
status: 'submitted',
rubAmount: '',
exchangeRate: '',
serviceFee: (TERM_OPTIONS[0].rate * 100).toFixed(3),
targetChain: '',
targetAddress: '',
txHash: '',
assignedTo: '',
adminComment: '',
}
export function useCreatePurchaseRequestForm(
org: Organization | undefined,
orgId: string,
) {
const navigate = useNavigate()
const [form, setForm] = useState<FormState>(INITIAL)
const [days, setDaysState] = useState<number>(TERM_OPTIONS[0].days)
const [error, setError] = useState(false)
const { mutate, isPending } = useCreatePurchaseRequest(orgId)
// Префилл имени/контакта из реквизитов организации — один раз, когда данные
// подгрузились. Эти поля можно изменить вручную.
const [prefilled, setPrefilled] = useState(false)
useEffect(() => {
if (prefilled || !org) return
setForm((prev) => ({
...prev,
name: org.contact_person ?? prev.name,
contact: org.contact_phone ?? prev.contact,
}))
setPrefilled(true)
}, [prefilled, org])
const setField = (key: keyof FormState) => (value: string) =>
setForm((prev) => ({ ...prev, [key]: value }))
// Объём вводится цифрами и форматируется как в конвертере (число → usdt_amount).
const setAmount = (value: string) => {
const digits = value.replace(/\D/g, '')
setForm((prev) => ({ ...prev, amount: digits ? ru(Number(digits)) : '' }))
}
// Смена срока подставляет соответствующую ставку комиссии (её можно переопределить).
const setDays = (value: number) => {
setDaysState(value)
const r = TERM_OPTIONS.find((o) => o.days === value)?.rate ?? TERM_OPTIONS[0].rate
setForm((prev) => ({ ...prev, serviceFee: (r * 100).toFixed(3) }))
}
const numAmount = Number(form.amount.replace(/\D/g, '')) || 0
const belowMin = numAmount > 0 && numAmount < MIN_ORDER
const rate = TERM_OPTIONS.find((o) => o.days === days)?.rate ?? TERM_OPTIONS[0].rate
const commission = numAmount * rate
const total = numAmount + commission
const canSubmit = numAmount >= MIN_ORDER && !isPending
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (numAmount === 0 || belowMin || isPending) return
// Отдельных полей под имя/контакт/срок/сумму в ₽ исторически нет — собираем
// человекочитаемый comment ровно как на странице конвертации.
const comment = [
form.name && `Имя: ${form.name}`,
form.contact && `Контакт: ${form.contact}`,
`Срок ожидания: ${dayLabel(days)}`,
`Сумма: ${ru(numAmount)}`,
`Комиссия: ≈${ru(commission)}`,
`Итого: ≈${ru(total)}`,
]
.filter(Boolean)
.join('; ')
const payload: CreatePurchaseRequestRequest = {
organization_id: orgId,
usdt_amount: numAmount,
status: form.status.trim() || 'submitted',
comment,
}
if (form.serviceFee.trim()) payload.service_fee_percent = form.serviceFee.trim()
if (form.rubAmount.trim()) payload.rub_amount = form.rubAmount.replace(/\D/g, '')
if (form.exchangeRate.trim()) payload.exchange_rate = form.exchangeRate.trim()
if (form.targetChain.trim()) payload.target_wallet_chain = form.targetChain.trim()
if (form.targetAddress.trim()) payload.target_wallet_address = form.targetAddress.trim()
if (form.txHash.trim()) payload.tx_hash = form.txHash.trim()
if (form.assignedTo.trim()) payload.assigned_to = form.assignedTo.trim()
if (form.adminComment.trim()) payload.admin_comment = form.adminComment.trim()
mutate(payload, {
onSuccess: () =>
navigate(adminOrganizationPath(orgId), { state: { tab: 'requests' } }),
onError: () => setError(true),
})
}
return {
form,
setField,
setAmount,
days,
setDays,
numAmount,
belowMin,
rate,
commission,
total,
canSubmit,
isPending,
handleSubmit,
error,
clearError: () => setError(false),
}
}

View File

@@ -0,0 +1,128 @@
.page {
min-height: 100vh;
background: var(--bg-deep);
padding: 40px 48px;
}
.header {
max-width: 760px;
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(22px, 3.2vw, 30px);
font-weight: 700;
color: var(--text-primary, #fff);
margin: 0;
}
.form {
max-width: 760px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 24px;
}
.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;
}
.amountField {
display: flex;
flex-direction: column;
gap: 6px;
}
.hint {
color: #ff8a5a;
font-size: 12px;
margin: 0;
}
.summary {
margin-top: 20px;
padding-top: 18px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
display: flex;
flex-direction: column;
gap: 10px;
}
.summaryRow {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
}
.summaryLabel {
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
}
.summaryValue {
color: var(--text-primary, #fff);
font-weight: 600;
}
.summaryRow[data-accent] .summaryValue {
color: var(--interactive, #4a6dff);
font-size: 16px;
}
.state {
max-width: 760px;
margin: 0 auto;
padding: 40px 16px;
text-align: center;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
}
.actions {
max-width: 320px;
margin: 0 auto;
width: 100%;
}
@media (max-width: 768px) {
.page {
padding: 28px 20px;
}
.grid {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,233 @@
import { useNavigate, useParams } from 'react-router-dom'
import { useAdminAuth, useOrganization } from '@features/admin'
import { adminOrganizationPath } from '@shared/config/routes'
import { FormField, Notification, PrimaryButton, Select } from '@shared/ui'
import { AdminLoginForm } from '@widgets/admin-login-form'
import {
TERM_OPTIONS,
dayLabel,
ru,
useCreatePurchaseRequestForm,
} from '../model/useCreatePurchaseRequestForm'
import styles from './AdminCreatePurchaseRequestPage.module.css'
const STATUS_OPTIONS = [
{ value: 'submitted', label: 'submitted' },
{ value: 'quoted', label: 'quoted' },
{ value: 'paid', label: 'paid' },
{ value: 'processing', label: 'processing' },
{ value: 'completed', label: 'completed' },
{ value: 'cancelled', label: 'cancelled' },
]
const CHAIN_OPTIONS = [
{ value: '', label: '— не выбрано —' },
{ value: 'ETH', label: 'ETH' },
{ value: 'TRON', label: 'TRON' },
{ value: 'BSC', label: 'BSC' },
{ value: 'POLYGON', label: 'POLYGON' },
]
export function AdminCreatePurchaseRequestPage() {
const { isAuthenticated, isLoading: isAuthLoading } = useAdminAuth()
const { organizationId } = useParams<{ organizationId: string }>()
const navigate = useNavigate()
const orgId = organizationId ?? ''
const { data: org, isLoading, isError } = useOrganization(orgId)
const {
form,
setField,
setAmount,
days,
setDays,
numAmount,
belowMin,
rate,
commission,
total,
canSubmit,
isPending,
handleSubmit,
error,
clearError,
} = useCreatePurchaseRequestForm(org, orgId)
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(adminOrganizationPath(orgId), { state: { tab: 'requests' } })}
>
Назад к организации
</button>
<h1 className={styles.title}>
{org ? `Новая заявка — ${org.name}` : 'Новая заявка'}
</h1>
</header>
{isLoading && <div className={styles.state}>Загрузка...</div>}
{isError && <div className={styles.state}>Не удалось загрузить организацию</div>}
{org && (
<form className={styles.form} onSubmit={handleSubmit}>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Основные</h2>
<div className={styles.grid}>
<div className={styles.amountField}>
<FormField
label="Объём заявки, ₽"
type="text"
value={form.amount}
onChange={setAmount}
placeholder="от 500 000"
required
/>
{belowMin && (
<p className={styles.hint}>Минимальный объём заявки {ru(500_000)} </p>
)}
</div>
<Select
id="term"
label="Срок ожидания операции"
value={days}
onChange={setDays}
options={TERM_OPTIONS.map((o) => ({
value: o.days,
label: `${dayLabel(o.days)} — комиссия ${(o.rate * 100).toFixed(3)} %`,
}))}
/>
<FormField
label="Имя"
type="text"
value={form.name}
onChange={setField('name')}
placeholder="Имя (опционально)"
filter="letters"
/>
<FormField
label="Email или телефон для связи"
type="text"
value={form.contact}
onChange={setField('contact')}
placeholder="example@mail.ru / +7 900 000-00-00 (опционально)"
/>
</div>
<div className={styles.summary}>
<div className={styles.summaryRow}>
<span className={styles.summaryLabel}>Ставка комиссии</span>
<span className={styles.summaryValue}>{(rate * 100).toFixed(3)} %</span>
</div>
<div className={styles.summaryRow}>
<span className={styles.summaryLabel}>Сумма комиссии</span>
<span className={styles.summaryValue}>
{numAmount > 0 ? `${ru(commission)}` : '—'}
</span>
</div>
<div className={styles.summaryRow} data-accent>
<span className={styles.summaryLabel}>Итого</span>
<span className={styles.summaryValue}>
{numAmount > 0 ? `${ru(total)}` : '—'}
</span>
</div>
</div>
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Дополнительно (опционально)</h2>
<div className={styles.grid}>
<Select
id="status"
label="Статус"
value={form.status}
onChange={setField('status')}
options={STATUS_OPTIONS}
/>
<FormField
label="Комиссия, %"
type="text"
value={form.serviceFee}
onChange={setField('serviceFee')}
placeholder="напр. 5.000 (опционально)"
/>
<FormField
label="Сумма, ₽"
type="text"
value={form.rubAmount}
onChange={setField('rubAmount')}
placeholder="Сумма в рублях (опционально)"
filter="digits"
/>
<FormField
label="Курс"
type="text"
value={form.exchangeRate}
onChange={setField('exchangeRate')}
placeholder="напр. 95.5 (опционально)"
/>
<Select
id="chain"
label="Сеть кошелька"
value={form.targetChain}
onChange={setField('targetChain')}
options={CHAIN_OPTIONS}
/>
<FormField
label="Адрес кошелька"
type="text"
value={form.targetAddress}
onChange={setField('targetAddress')}
placeholder="Адрес кошелька (опционально)"
/>
<FormField
label="Хеш транзакции"
type="text"
value={form.txHash}
onChange={setField('txHash')}
placeholder="Хеш транзакции (опционально)"
/>
<FormField
label="Назначить (ID менеджера)"
type="text"
value={form.assignedTo}
onChange={setField('assignedTo')}
placeholder="ID менеджера (опционально)"
/>
<FormField
label="Внутренний комментарий"
type="text"
value={form.adminComment}
onChange={setField('adminComment')}
placeholder="Внутренний комментарий (опционально)"
/>
</div>
</section>
<div className={styles.actions}>
<PrimaryButton
label={isPending ? 'Добавляем...' : 'Добавить операцию'}
disabled={!canSubmit}
/>
</div>
</form>
)}
{error && (
<Notification
status="error"
message="Не удалось создать заявку"
onClose={clearError}
/>
)}
</div>
)
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useLocation, 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'
@@ -30,9 +30,13 @@ export function AdminOrganizationPage() {
const { isAuthenticated, isLoading: isAuthLoading } = useAdminAuth()
const { organizationId } = useParams<{ organizationId: string }>()
const navigate = useNavigate()
const location = useLocation()
const { data: org, isLoading, isError } = useOrganization(organizationId)
const [notice, setNotice] = useState(false)
const [activeTab, setActiveTab] = useState<Tab>('info')
// После создания заявки страница открывается сразу на вкладке «Заявки»
// (вкладка передаётся через location.state при навигации назад).
const initialTab = (location.state as { tab?: Tab } | null)?.tab ?? 'info'
const [activeTab, setActiveTab] = useState<Tab>(initialTab)
const { form, setField, handleSubmit, isSaving, error } = useOrganizationForm(
org,
organizationId ?? '',
@@ -77,8 +81,8 @@ export function AdminOrganizationPage() {
<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.ogrn} onChange={setField('ogrn')} placeholder="1027700132195" filter="digits" maxLength={13} />
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="770801001" filter="digits" maxLength={9} />
<FormField label="Статус" value={form.status} onChange={setField('status')} placeholder="active" />
</div>
</section>
@@ -94,8 +98,8 @@ export function AdminOrganizationPage() {
<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" />
<FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="Иванов Иван Иванович" filter="letters" />
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" filter="phone" />
</div>
</section>

View File

@@ -1,7 +1,12 @@
export const ROUTES = {
ADMIN: '/sys-c7f29a4e-d81b-4630-ops-console',
ADMIN_ORGANIZATION: '/sys-c7f29a4e-d81b-4630-ops-console/organizations/:organizationId',
ADMIN_ORGANIZATION_CREATE_REQUEST:
'/sys-c7f29a4e-d81b-4630-ops-console/organizations/:organizationId/purchase-requests/new',
} as const
export const adminOrganizationPath = (id: string) =>
`/sys-c7f29a4e-d81b-4630-ops-console/organizations/${id}`
export const adminOrganizationCreateRequestPath = (id: string) =>
`/sys-c7f29a4e-d81b-4630-ops-console/organizations/${id}/purchase-requests/new`

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import styles from './FormField.module.css'
import { applyFilter, type FieldFilter } from './filters'
interface Props {
label: string
@@ -11,9 +12,11 @@ interface Props {
readOnly?: boolean
required?: boolean
icon?: 'check' | 'lock'
filter?: FieldFilter
maxLength?: number
}
export function FormField({ label, value, placeholder, type = 'text', onChange, onBlur, readOnly, required, icon }: Props) {
export function FormField({ label, value, placeholder, type = 'text', onChange, onBlur, readOnly, required, icon, filter, maxLength }: Props) {
const [copied, setCopied] = useState(false)
const [isVisible, setIsVisible] = useState(false)
@@ -36,12 +39,13 @@ export function FormField({ label, value, placeholder, type = 'text', onChange,
className={`${styles.input} ${isPassword ? styles.withToggle : ''} ${readOnly ? styles.readonly : ''} ${copied ? styles.copied : ''}`}
type={inputType}
{...(onChange
? { value, onChange: (e) => onChange(e.target.value) }
? { value, onChange: (e) => onChange(applyFilter(e.target.value, filter)) }
: { defaultValue: value }
)}
placeholder={placeholder}
readOnly={readOnly}
required={required}
maxLength={maxLength}
onBlur={onBlur}
/>
{isPassword && (

View File

@@ -0,0 +1,18 @@
export type FieldFilter = 'digits' | 'letters' | 'phone' | 'email'
// Ограничивает символы, которые можно ввести в поле, в зависимости от типа данных.
const FILTERS: Record<FieldFilter, RegExp> = {
// Только цифры: ИНН, ОГРН, КПП, БИК, счета.
digits: /[^0-9]/g,
// Только буквы (рус./лат.), пробел и дефис: ФИО, наименования.
letters: /[^a-zA-Zа-яА-ЯёЁ\s-]/g,
// Телефон: цифры, плюс, скобки, пробел, дефис.
phone: /[^0-9+()\s-]/g,
// Email: латиница, цифры и допустимые символы адреса.
email: /[^a-zA-Z0-9@._+-]/g,
}
export function applyFilter(value: string, filter?: FieldFilter): string {
if (!filter) return value
return value.replace(FILTERS[filter], '')
}

View File

@@ -0,0 +1,101 @@
.field {
position: relative;
display: flex;
flex-direction: column;
gap: 6px;
}
.label {
font-size: 12px;
color: var(--text-secondary);
font-weight: 600;
letter-spacing: 0.08em;
}
.trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 100%;
height: 48px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
color: var(--text-primary);
font-size: 14px;
font-family: var(--font-sans);
text-align: left;
padding: 0 16px;
cursor: pointer;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.trigger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.trigger:focus-visible,
.triggerOpen {
border-color: var(--interactive);
box-shadow: 0 0 0 3px rgba(74, 109, 255, 0.15);
}
.value {
color: var(--text-primary);
}
.placeholder {
color: var(--text-secondary);
}
.arrow {
flex-shrink: 0;
color: var(--text-secondary);
font-size: 12px;
transition: transform 0.2s;
}
.arrowOpen {
transform: rotate(180deg);
}
.dropdown {
position: absolute;
top: calc(100% + 6px);
left: 0;
right: 0;
z-index: 20;
margin: 0;
padding: 6px;
list-style: none;
background: var(--bg-mid);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 10px;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
max-height: 280px;
overflow-y: auto;
}
.option {
padding: 11px 12px;
border-radius: 8px;
font-size: 14px;
color: var(--text-primary);
cursor: pointer;
transition: background-color 0.15s;
}
.option:hover {
background: rgba(255, 255, 255, 0.08);
}
.optionSelected {
background: rgba(91, 61, 184, 0.35);
}
.optionSelected:hover {
background: rgba(91, 61, 184, 0.45);
}

View File

@@ -0,0 +1,92 @@
import { useEffect, useRef, useState } from 'react'
import styles from './Select.module.css'
export interface SelectOption<T extends string | number = string> {
value: T
label: string
}
interface Props<T extends string | number> {
value: T
options: SelectOption<T>[]
onChange: (value: T) => void
label?: string
placeholder?: string
id?: string
disabled?: boolean
}
export function Select<T extends string | number>({
value,
options,
onChange,
label,
placeholder = 'Выберите',
id,
disabled,
}: Props<T>) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const selected = options.find((o) => o.value === value)
useEffect(() => {
if (!open) return
const handlePointer = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('mousedown', handlePointer)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handlePointer)
document.removeEventListener('keydown', handleKey)
}
}, [open])
return (
<div className={styles.field} ref={ref}>
{label && (
<label className={styles.label} htmlFor={id}>
{label}
</label>
)}
<button
type="button"
id={id}
className={`${styles.trigger} ${open ? styles.triggerOpen : ''}`}
onClick={() => !disabled && setOpen((v) => !v)}
disabled={disabled}
aria-haspopup="listbox"
aria-expanded={open}
>
<span className={selected ? styles.value : styles.placeholder}>
{selected ? selected.label : placeholder}
</span>
<span className={`${styles.arrow} ${open ? styles.arrowOpen : ''}`} aria-hidden>
</span>
</button>
{open && (
<ul className={styles.dropdown} role="listbox">
{options.map((o) => (
<li
key={String(o.value)}
role="option"
aria-selected={o.value === value}
className={`${styles.option} ${o.value === value ? styles.optionSelected : ''}`}
onClick={() => {
onChange(o.value)
setOpen(false)
}}
>
{o.label}
</li>
))}
</ul>
)}
</div>
)
}

View File

@@ -0,0 +1,2 @@
export { Select } from './Select'
export type { SelectOption } from './Select'

View File

@@ -2,3 +2,5 @@ export { Button } from './Button'
export { FormField } from './FormField'
export { Notification } from './Notification'
export { PrimaryButton } from './PrimaryButton'
export { Select } from './Select'
export type { SelectOption } from './Select'

View File

@@ -42,30 +42,30 @@ export function AddLegalEntityModal({ open, onClose, onCreated }: Props) {
<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="Email" type="email" value={form.email} onChange={setField('email')} placeholder="org@mail.ru" filter="email" 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 />
<FormField label="ИНН" value={form.inn} onChange={setField('inn')} placeholder="1012 цифр" filter="digits" maxLength={12} required />
</div>
<p className={styles.groupLabel}>Дополнительные поля</p>
<div className={styles.grid}>
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="—" />
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="—" />
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="—" filter="digits" maxLength={13} />
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="—" filter="digits" maxLength={9} />
<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" />
<FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="—" filter="letters" />
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" filter="phone" />
</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="—" />
<FormField label="БИК" value={form.bik} onChange={setField('bik')} placeholder="—" filter="digits" maxLength={9} />
<FormField label="Расчётный счёт" value={form.account} onChange={setField('account')} placeholder="—" filter="digits" maxLength={20} />
<FormField label="Корр. счёт" value={form.corr_account} onChange={setField('corr_account')} placeholder="—" filter="digits" maxLength={20} />
</div>
{error && <p className={styles.error}>{error}</p>}

View File

@@ -48,3 +48,9 @@
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
font-size: 14px;
}
.actions {
margin-top: 20px;
display: flex;
justify-content: flex-start;
}

View File

@@ -1,4 +1,7 @@
import { useNavigate } from 'react-router-dom'
import { usePurchaseRequests } from '@features/admin'
import { adminOrganizationCreateRequestPath } from '@shared/config/routes'
import { Button } from '@shared/ui'
import styles from './OrganizationPurchaseRequests.module.css'
interface Props {
@@ -19,6 +22,18 @@ function formatDate(value: string | null): string {
export function OrganizationPurchaseRequests({ orgId }: Props) {
const { data, isLoading, isError } = usePurchaseRequests(orgId)
const navigate = useNavigate()
const createButton = (
<div className={styles.actions}>
<Button
variant="primary"
onClick={() => navigate(adminOrganizationCreateRequestPath(orgId))}
>
Создать заявку
</Button>
</div>
)
if (isLoading) {
return <div className={styles.state}>Загрузка...</div>
@@ -29,35 +44,43 @@ export function OrganizationPurchaseRequests({ orgId }: Props) {
}
if (!data || data.items.length === 0) {
return <div className={styles.state}>Заявок пока нет</div>
return (
<div>
<div className={styles.state}>Заявок пока нет</div>
{createButton}
</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>
<div>
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>USDT</th>
<th>Сумма </th>
<th>Курс</th>
<th>Статус</th>
<th>Создана</th>
</tr>
))}
</tbody>
</table>
</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>
{createButton}
</div>
)
}

View File

@@ -1 +1 @@
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/app.tsx","./src/app/providers/queryprovider.tsx","./src/app/providers/routerprovider.tsx","./src/app/providers/scrolltotop.tsx","./src/app/providers/index.ts","./src/features/admin/index.ts","./src/features/admin/api/adminapi.ts","./src/features/admin/hooks/useadminauth.ts","./src/features/admin/hooks/useadminlogin.ts","./src/features/admin/hooks/useadminlogout.ts","./src/features/admin/hooks/usecreateorganization.ts","./src/features/admin/hooks/usecreateorganizationwallets.ts","./src/features/admin/hooks/usedocuments.ts","./src/features/admin/hooks/useorganization.ts","./src/features/admin/hooks/useorganizationwallets.ts","./src/features/admin/hooks/useorganizations.ts","./src/features/admin/hooks/usepurchaserequests.ts","./src/features/admin/hooks/useupdateorganization.ts","./src/features/admin/hooks/useuploaddocument.ts","./src/features/admin/model/types.ts","./src/pages/admin/index.ts","./src/pages/admin/ui/adminpage.tsx","./src/pages/admin-organization/index.ts","./src/pages/admin-organization/model/useorganizationform.ts","./src/pages/admin-organization/ui/adminorganizationpage.tsx","./src/shared/config/routes.ts","./src/shared/ui/index.ts","./src/shared/ui/button/button.tsx","./src/shared/ui/button/index.ts","./src/shared/ui/formfield/formfield.tsx","./src/shared/ui/formfield/index.ts","./src/shared/ui/notification/notification.tsx","./src/shared/ui/notification/index.ts","./src/shared/ui/primarybutton/primarybutton.tsx","./src/shared/ui/primarybutton/index.ts","./src/widgets/add-legal-entity-modal/index.ts","./src/widgets/add-legal-entity-modal/model/useaddlegalentityform.ts","./src/widgets/add-legal-entity-modal/ui/addlegalentitymodal.tsx","./src/widgets/admin-login-form/index.ts","./src/widgets/admin-login-form/model/useadminloginform.ts","./src/widgets/admin-login-form/ui/adminloginform.tsx","./src/widgets/legal-entities-table/index.ts","./src/widgets/legal-entities-table/ui/legalentitiestable.tsx","./src/widgets/organization-documents/index.ts","./src/widgets/organization-documents/ui/organizationdocuments.tsx","./src/widgets/organization-purchase-requests/index.ts","./src/widgets/organization-purchase-requests/ui/organizationpurchaserequests.tsx","./src/widgets/organization-wallets/index.ts","./src/widgets/organization-wallets/ui/organizationwallets.tsx"],"version":"5.6.3"}
{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/app.tsx","./src/app/providers/queryprovider.tsx","./src/app/providers/routerprovider.tsx","./src/app/providers/scrolltotop.tsx","./src/app/providers/index.ts","./src/features/admin/index.ts","./src/features/admin/api/adminapi.ts","./src/features/admin/hooks/useadminauth.ts","./src/features/admin/hooks/useadminlogin.ts","./src/features/admin/hooks/useadminlogout.ts","./src/features/admin/hooks/usecreateorganization.ts","./src/features/admin/hooks/usecreateorganizationwallets.ts","./src/features/admin/hooks/usecreatepurchaserequest.ts","./src/features/admin/hooks/usedocuments.ts","./src/features/admin/hooks/useorganization.ts","./src/features/admin/hooks/useorganizationwallets.ts","./src/features/admin/hooks/useorganizations.ts","./src/features/admin/hooks/usepurchaserequests.ts","./src/features/admin/hooks/useupdateorganization.ts","./src/features/admin/hooks/useuploaddocument.ts","./src/features/admin/model/types.ts","./src/pages/admin/index.ts","./src/pages/admin/ui/adminpage.tsx","./src/pages/admin-create-purchase-request/index.ts","./src/pages/admin-create-purchase-request/model/usecreatepurchaserequestform.ts","./src/pages/admin-create-purchase-request/ui/admincreatepurchaserequestpage.tsx","./src/pages/admin-organization/index.ts","./src/pages/admin-organization/model/useorganizationform.ts","./src/pages/admin-organization/ui/adminorganizationpage.tsx","./src/shared/config/routes.ts","./src/shared/ui/index.ts","./src/shared/ui/button/button.tsx","./src/shared/ui/button/index.ts","./src/shared/ui/formfield/formfield.tsx","./src/shared/ui/formfield/filters.ts","./src/shared/ui/formfield/index.ts","./src/shared/ui/notification/notification.tsx","./src/shared/ui/notification/index.ts","./src/shared/ui/primarybutton/primarybutton.tsx","./src/shared/ui/primarybutton/index.ts","./src/shared/ui/select/select.tsx","./src/shared/ui/select/index.ts","./src/widgets/add-legal-entity-modal/index.ts","./src/widgets/add-legal-entity-modal/model/useaddlegalentityform.ts","./src/widgets/add-legal-entity-modal/ui/addlegalentitymodal.tsx","./src/widgets/admin-login-form/index.ts","./src/widgets/admin-login-form/model/useadminloginform.ts","./src/widgets/admin-login-form/ui/adminloginform.tsx","./src/widgets/legal-entities-table/index.ts","./src/widgets/legal-entities-table/ui/legalentitiestable.tsx","./src/widgets/organization-documents/index.ts","./src/widgets/organization-documents/ui/organizationdocuments.tsx","./src/widgets/organization-purchase-requests/index.ts","./src/widgets/organization-purchase-requests/ui/organizationpurchaserequests.tsx","./src/widgets/organization-wallets/index.ts","./src/widgets/organization-wallets/ui/organizationwallets.tsx"],"version":"5.6.3"}