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 ( + + + navigate(adminOrganizationPath(orgId), { state: { tab: 'requests' } })} + > + ← Назад к организации + + + {org ? `Новая заявка — ${org.name}` : 'Новая заявка'} + + + + {isLoading && Загрузка...} + {isError && Не удалось загрузить организацию} + + {org && ( + + + Основные + + + + {belowMin && ( + Минимальный объём заявки — {ru(500_000)} ₽ + )} + + + ({ + value: o.days, + label: `${dayLabel(o.days)} — комиссия ${(o.rate * 100).toFixed(3)} %`, + }))} + /> + + + + + + + + + Ставка комиссии + {(rate * 100).toFixed(3)} % + + + Сумма комиссии + + {numAmount > 0 ? `≈ ${ru(commission)} ₽` : '—'} + + + + Итого + + {numAmount > 0 ? `≈ ${ru(total)} ₽` : '—'} + + + + + + + Дополнительно (опционально) + + + + + + + + + + + + + + + + + + )} + + {error && ( + + )} + + ) +} diff --git a/src/pages/admin-organization/ui/AdminOrganizationPage.tsx b/src/pages/admin-organization/ui/AdminOrganizationPage.tsx index b5a2760..9b58d10 100644 --- a/src/pages/admin-organization/ui/AdminOrganizationPage.tsx +++ b/src/pages/admin-organization/ui/AdminOrganizationPage.tsx @@ -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('info') + // После создания заявки страница открывается сразу на вкладке «Заявки» + // (вкладка передаётся через location.state при навигации назад). + const initialTab = (location.state as { tab?: Tab } | null)?.tab ?? 'info' + const [activeTab, setActiveTab] = useState(initialTab) const { form, setField, handleSubmit, isSaving, error } = useOrganizationForm( org, organizationId ?? '', @@ -77,8 +81,8 @@ export function AdminOrganizationPage() { - - + + @@ -94,8 +98,8 @@ export function AdminOrganizationPage() { Контакты - - + + diff --git a/src/shared/config/routes.ts b/src/shared/config/routes.ts index 358fca1..2f7a6a4 100644 --- a/src/shared/config/routes.ts +++ b/src/shared/config/routes.ts @@ -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` diff --git a/src/shared/ui/FormField/FormField.tsx b/src/shared/ui/FormField/FormField.tsx index af1a341..b120bcf 100644 --- a/src/shared/ui/FormField/FormField.tsx +++ b/src/shared/ui/FormField/FormField.tsx @@ -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 && ( diff --git a/src/shared/ui/FormField/filters.ts b/src/shared/ui/FormField/filters.ts new file mode 100644 index 0000000..768471a --- /dev/null +++ b/src/shared/ui/FormField/filters.ts @@ -0,0 +1,18 @@ +export type FieldFilter = 'digits' | 'letters' | 'phone' | 'email' + +// Ограничивает символы, которые можно ввести в поле, в зависимости от типа данных. +const FILTERS: Record = { + // Только цифры: ИНН, ОГРН, КПП, БИК, счета. + 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], '') +} diff --git a/src/shared/ui/Select/Select.module.css b/src/shared/ui/Select/Select.module.css new file mode 100644 index 0000000..7790ec9 --- /dev/null +++ b/src/shared/ui/Select/Select.module.css @@ -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); +} diff --git a/src/shared/ui/Select/Select.tsx b/src/shared/ui/Select/Select.tsx new file mode 100644 index 0000000..4e1e1f4 --- /dev/null +++ b/src/shared/ui/Select/Select.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from 'react' +import styles from './Select.module.css' + +export interface SelectOption { + value: T + label: string +} + +interface Props { + value: T + options: SelectOption[] + onChange: (value: T) => void + label?: string + placeholder?: string + id?: string + disabled?: boolean +} + +export function Select({ + value, + options, + onChange, + label, + placeholder = 'Выберите', + id, + disabled, +}: Props) { + const [open, setOpen] = useState(false) + const ref = useRef(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 ( + + {label && ( + + {label} + + )} + !disabled && setOpen((v) => !v)} + disabled={disabled} + aria-haspopup="listbox" + aria-expanded={open} + > + + {selected ? selected.label : placeholder} + + + ▾ + + + {open && ( + + {options.map((o) => ( + { + onChange(o.value) + setOpen(false) + }} + > + {o.label} + + ))} + + )} + + ) +} diff --git a/src/shared/ui/Select/index.ts b/src/shared/ui/Select/index.ts new file mode 100644 index 0000000..948b2ea --- /dev/null +++ b/src/shared/ui/Select/index.ts @@ -0,0 +1,2 @@ +export { Select } from './Select' +export type { SelectOption } from './Select' diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 63fcd73..f144231 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -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' diff --git a/src/widgets/add-legal-entity-modal/ui/AddLegalEntityModal.tsx b/src/widgets/add-legal-entity-modal/ui/AddLegalEntityModal.tsx index edc0e13..5fcb83c 100644 --- a/src/widgets/add-legal-entity-modal/ui/AddLegalEntityModal.tsx +++ b/src/widgets/add-legal-entity-modal/ui/AddLegalEntityModal.tsx @@ -42,30 +42,30 @@ export function AddLegalEntityModal({ open, onClose, onCreated }: Props) { Обязательные поля - + - + Дополнительные поля - - + + - - + + Банковские реквизиты - - - + + + {error && {error}} diff --git a/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.module.css b/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.module.css index 67d19cd..b03fc26 100644 --- a/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.module.css +++ b/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.module.css @@ -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; +} diff --git a/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.tsx b/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.tsx index 257a663..571fc4d 100644 --- a/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.tsx +++ b/src/widgets/organization-purchase-requests/ui/OrganizationPurchaseRequests.tsx @@ -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 = ( + + navigate(adminOrganizationCreateRequestPath(orgId))} + > + Создать заявку + + + ) if (isLoading) { return Загрузка... @@ -29,35 +44,43 @@ export function OrganizationPurchaseRequests({ orgId }: Props) { } if (!data || data.items.length === 0) { - return Заявок пока нет + return ( + + Заявок пока нет + {createButton} + + ) } return ( - - - - - USDT - Сумма ₽ - Курс - Статус - Создана - - - - {data.items.map((req) => ( - - {formatAmount(req.usdt_amount, 'USDT')} - {formatAmount(req.rub_amount, '₽')} - {req.exchange_rate ?? '—'} - - {req.status} - - {formatDate(req.created_at)} + + + + + + USDT + Сумма ₽ + Курс + Статус + Создана - ))} - - + + + {data.items.map((req) => ( + + {formatAmount(req.usdt_amount, 'USDT')} + {formatAmount(req.rub_amount, '₽')} + {req.exchange_rate ?? '—'} + + {req.status} + + {formatDate(req.created_at)} + + ))} + + + + {createButton} ) } diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo index 435bb18..16416e7 100644 --- a/tsconfig.tsbuildinfo +++ b/tsconfig.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file
Минимальный объём заявки — {ru(500_000)} ₽
Обязательные поля
Дополнительные поля
Банковские реквизиты
{error}