diff --git a/src/app/providers/RouterProvider.tsx b/src/app/providers/RouterProvider.tsx index 804c8ac..1b219ed 100644 --- a/src/app/providers/RouterProvider.tsx +++ b/src/app/providers/RouterProvider.tsx @@ -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 */} } /> } /> + } + /> ) diff --git a/src/features/admin/api/adminApi.ts b/src/features/admin/api/adminApi.ts index 2c89c78..722582a 100644 --- a/src/features/admin/api/adminApi.ts +++ b/src/features/admin/api/adminApi.ts @@ -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 { + return doAdminRequest( + '/v1/purchase-requests', + { method: 'POST', body: JSON.stringify(payload) }, + true, + ) +} + export function updateOrganization( id: string, payload: UpdateOrganizationRequest, diff --git a/src/features/admin/hooks/useCreatePurchaseRequest.ts b/src/features/admin/hooks/useCreatePurchaseRequest.ts new file mode 100644 index 0000000..e72f87c --- /dev/null +++ b/src/features/admin/hooks/useCreatePurchaseRequest.ts @@ -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) }) + }, + }) +} diff --git a/src/features/admin/index.ts b/src/features/admin/index.ts index 603c344..bd5aba5 100644 --- a/src/features/admin/index.ts +++ b/src/features/admin/index.ts @@ -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' diff --git a/src/features/admin/model/types.ts b/src/features/admin/model/types.ts index 0c8be2c..9f1fd19 100644 --- a/src/features/admin/model/types.ts +++ b/src/features/admin/model/types.ts @@ -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 diff --git a/src/pages/admin-create-purchase-request/index.ts b/src/pages/admin-create-purchase-request/index.ts new file mode 100644 index 0000000..9861617 --- /dev/null +++ b/src/pages/admin-create-purchase-request/index.ts @@ -0,0 +1 @@ +export { AdminCreatePurchaseRequestPage } from './ui/AdminCreatePurchaseRequestPage' diff --git a/src/pages/admin-create-purchase-request/model/useCreatePurchaseRequestForm.ts b/src/pages/admin-create-purchase-request/model/useCreatePurchaseRequestForm.ts new file mode 100644 index 0000000..8547c94 --- /dev/null +++ b/src/pages/admin-create-purchase-request/model/useCreatePurchaseRequestForm.ts @@ -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(INITIAL) + const [days, setDaysState] = useState(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), + } +} diff --git a/src/pages/admin-create-purchase-request/ui/AdminCreatePurchaseRequestPage.module.css b/src/pages/admin-create-purchase-request/ui/AdminCreatePurchaseRequestPage.module.css new file mode 100644 index 0000000..d70a10e --- /dev/null +++ b/src/pages/admin-create-purchase-request/ui/AdminCreatePurchaseRequestPage.module.css @@ -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; + } +} diff --git a/src/pages/admin-create-purchase-request/ui/AdminCreatePurchaseRequestPage.tsx b/src/pages/admin-create-purchase-request/ui/AdminCreatePurchaseRequestPage.tsx new file mode 100644 index 0000000..8d80be6 --- /dev/null +++ b/src/pages/admin-create-purchase-request/ui/AdminCreatePurchaseRequestPage.tsx @@ -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 + + return ( +
+
+ +

+ {org ? `Новая заявка — ${org.name}` : 'Новая заявка'} +

+
+ + {isLoading &&
Загрузка...
} + {isError &&
Не удалось загрузить организацию
} + + {org && ( +
+
+

Основные

+
+
+ + {belowMin && ( +

Минимальный объём заявки — {ru(500_000)} ₽

+ )} +
+ + + + + +