deploy
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
import { AdminPage } from '@pages/admin'
|
import { AdminPage } from '@pages/admin'
|
||||||
import { AdminOrganizationPage } from '@pages/admin-organization'
|
import { AdminOrganizationPage } from '@pages/admin-organization'
|
||||||
|
import { AdminCreatePurchaseRequestPage } from '@pages/admin-create-purchase-request'
|
||||||
import { ROUTES } from '@shared/config/routes'
|
import { ROUTES } from '@shared/config/routes'
|
||||||
import { ScrollToTop } from './ScrollToTop'
|
import { ScrollToTop } from './ScrollToTop'
|
||||||
|
|
||||||
@@ -12,6 +13,10 @@ export function RouterProvider() {
|
|||||||
{/* Admin panel — own auth gate, independent of the user auth system */}
|
{/* Admin panel — own auth gate, independent of the user auth system */}
|
||||||
<Route path={ROUTES.ADMIN} element={<AdminPage />} />
|
<Route path={ROUTES.ADMIN} element={<AdminPage />} />
|
||||||
<Route path={ROUTES.ADMIN_ORGANIZATION} element={<AdminOrganizationPage />} />
|
<Route path={ROUTES.ADMIN_ORGANIZATION} element={<AdminOrganizationPage />} />
|
||||||
|
<Route
|
||||||
|
path={ROUTES.ADMIN_ORGANIZATION_CREATE_REQUEST}
|
||||||
|
element={<AdminCreatePurchaseRequestPage />}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import type {
|
|||||||
AdminMeResponse,
|
AdminMeResponse,
|
||||||
AdminRefreshResponse,
|
AdminRefreshResponse,
|
||||||
CreateOrganizationRequest,
|
CreateOrganizationRequest,
|
||||||
|
CreatePurchaseRequestRequest,
|
||||||
CreateWalletsResponse,
|
CreateWalletsResponse,
|
||||||
WalletResponse,
|
WalletResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
DocumentTypeSlug,
|
DocumentTypeSlug,
|
||||||
Organization,
|
Organization,
|
||||||
OrganizationListResponse,
|
OrganizationListResponse,
|
||||||
|
PurchaseRequestResponse,
|
||||||
PurchaseRequestListResponse,
|
PurchaseRequestListResponse,
|
||||||
UpdateOrganizationRequest,
|
UpdateOrganizationRequest,
|
||||||
} from '../model/types'
|
} from '../model/types'
|
||||||
@@ -207,6 +209,16 @@ export async function getPurchaseRequests(params: {
|
|||||||
return data
|
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(
|
export function updateOrganization(
|
||||||
id: string,
|
id: string,
|
||||||
payload: UpdateOrganizationRequest,
|
payload: UpdateOrganizationRequest,
|
||||||
|
|||||||
13
src/features/admin/hooks/useCreatePurchaseRequest.ts
Normal file
13
src/features/admin/hooks/useCreatePurchaseRequest.ts
Normal 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) })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ export {
|
|||||||
getDocuments,
|
getDocuments,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
getPurchaseRequests,
|
getPurchaseRequests,
|
||||||
|
createPurchaseRequest,
|
||||||
refreshAdminToken,
|
refreshAdminToken,
|
||||||
adminTokenStore,
|
adminTokenStore,
|
||||||
} from './api/adminApi'
|
} from './api/adminApi'
|
||||||
@@ -21,6 +22,7 @@ export type {
|
|||||||
Organization,
|
Organization,
|
||||||
OrganizationListResponse,
|
OrganizationListResponse,
|
||||||
CreateOrganizationRequest,
|
CreateOrganizationRequest,
|
||||||
|
CreatePurchaseRequestRequest,
|
||||||
UpdateOrganizationRequest,
|
UpdateOrganizationRequest,
|
||||||
WalletResponse,
|
WalletResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
@@ -41,3 +43,4 @@ export { useOrganizationWallets, WALLETS_QUERY_KEY } from './hooks/useOrganizati
|
|||||||
export { useDocuments, DOCUMENTS_QUERY_KEY } from './hooks/useDocuments'
|
export { useDocuments, DOCUMENTS_QUERY_KEY } from './hooks/useDocuments'
|
||||||
export { useUploadDocument } from './hooks/useUploadDocument'
|
export { useUploadDocument } from './hooks/useUploadDocument'
|
||||||
export { usePurchaseRequests, PURCHASE_REQUESTS_QUERY_KEY } from './hooks/usePurchaseRequests'
|
export { usePurchaseRequests, PURCHASE_REQUESTS_QUERY_KEY } from './hooks/usePurchaseRequests'
|
||||||
|
export { useCreatePurchaseRequest } from './hooks/useCreatePurchaseRequest'
|
||||||
|
|||||||
@@ -121,6 +121,24 @@ export interface PurchaseRequestListResponse {
|
|||||||
total: number
|
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 {
|
export interface UpdateOrganizationRequest {
|
||||||
name?: string | null
|
name?: string | null
|
||||||
short_name?: string | null
|
short_name?: string | null
|
||||||
|
|||||||
1
src/pages/admin-create-purchase-request/index.ts
Normal file
1
src/pages/admin-create-purchase-request/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { AdminCreatePurchaseRequestPage } from './ui/AdminCreatePurchaseRequestPage'
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 { useAdminAuth, useOrganization } from '@features/admin'
|
||||||
import { ROUTES } from '@shared/config/routes'
|
import { ROUTES } from '@shared/config/routes'
|
||||||
import { FormField, Notification, PrimaryButton } from '@shared/ui'
|
import { FormField, Notification, PrimaryButton } from '@shared/ui'
|
||||||
@@ -30,9 +30,13 @@ export function AdminOrganizationPage() {
|
|||||||
const { isAuthenticated, isLoading: isAuthLoading } = useAdminAuth()
|
const { isAuthenticated, isLoading: isAuthLoading } = useAdminAuth()
|
||||||
const { organizationId } = useParams<{ organizationId: string }>()
|
const { organizationId } = useParams<{ organizationId: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
const { data: org, isLoading, isError } = useOrganization(organizationId)
|
const { data: org, isLoading, isError } = useOrganization(organizationId)
|
||||||
const [notice, setNotice] = useState(false)
|
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(
|
const { form, setField, handleSubmit, isSaving, error } = useOrganizationForm(
|
||||||
org,
|
org,
|
||||||
organizationId ?? '',
|
organizationId ?? '',
|
||||||
@@ -77,8 +81,8 @@ export function AdminOrganizationPage() {
|
|||||||
<FormField label="Наименование" value={form.name} onChange={setField('name')} placeholder="ООО «Ромашка»" required />
|
<FormField label="Наименование" value={form.name} onChange={setField('name')} placeholder="ООО «Ромашка»" required />
|
||||||
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
|
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
|
||||||
<FormField label="ИНН" value={org.inn} readOnly icon="lock" />
|
<FormField label="ИНН" value={org.inn} readOnly icon="lock" />
|
||||||
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="1027700132195" />
|
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="1027700132195" filter="digits" maxLength={13} />
|
||||||
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="770801001" />
|
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="770801001" filter="digits" maxLength={9} />
|
||||||
<FormField label="Статус" value={form.status} onChange={setField('status')} placeholder="active" />
|
<FormField label="Статус" value={form.status} onChange={setField('status')} placeholder="active" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -94,8 +98,8 @@ export function AdminOrganizationPage() {
|
|||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Контакты</h2>
|
<h2 className={styles.sectionTitle}>Контакты</h2>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
<FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="Иванов Иван Иванович" />
|
<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" />
|
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" filter="phone" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
export const ROUTES = {
|
export const ROUTES = {
|
||||||
ADMIN: '/sys-c7f29a4e-d81b-4630-ops-console',
|
ADMIN: '/sys-c7f29a4e-d81b-4630-ops-console',
|
||||||
ADMIN_ORGANIZATION: '/sys-c7f29a4e-d81b-4630-ops-console/organizations/:organizationId',
|
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
|
} as const
|
||||||
|
|
||||||
export const adminOrganizationPath = (id: string) =>
|
export const adminOrganizationPath = (id: string) =>
|
||||||
`/sys-c7f29a4e-d81b-4630-ops-console/organizations/${id}`
|
`/sys-c7f29a4e-d81b-4630-ops-console/organizations/${id}`
|
||||||
|
|
||||||
|
export const adminOrganizationCreateRequestPath = (id: string) =>
|
||||||
|
`/sys-c7f29a4e-d81b-4630-ops-console/organizations/${id}/purchase-requests/new`
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import styles from './FormField.module.css'
|
import styles from './FormField.module.css'
|
||||||
|
import { applyFilter, type FieldFilter } from './filters'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
label: string
|
label: string
|
||||||
@@ -11,9 +12,11 @@ interface Props {
|
|||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
required?: boolean
|
required?: boolean
|
||||||
icon?: 'check' | 'lock'
|
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 [copied, setCopied] = useState(false)
|
||||||
const [isVisible, setIsVisible] = 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 : ''}`}
|
className={`${styles.input} ${isPassword ? styles.withToggle : ''} ${readOnly ? styles.readonly : ''} ${copied ? styles.copied : ''}`}
|
||||||
type={inputType}
|
type={inputType}
|
||||||
{...(onChange
|
{...(onChange
|
||||||
? { value, onChange: (e) => onChange(e.target.value) }
|
? { value, onChange: (e) => onChange(applyFilter(e.target.value, filter)) }
|
||||||
: { defaultValue: value }
|
: { defaultValue: value }
|
||||||
)}
|
)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
readOnly={readOnly}
|
readOnly={readOnly}
|
||||||
required={required}
|
required={required}
|
||||||
|
maxLength={maxLength}
|
||||||
onBlur={onBlur}
|
onBlur={onBlur}
|
||||||
/>
|
/>
|
||||||
{isPassword && (
|
{isPassword && (
|
||||||
|
|||||||
18
src/shared/ui/FormField/filters.ts
Normal file
18
src/shared/ui/FormField/filters.ts
Normal 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], '')
|
||||||
|
}
|
||||||
101
src/shared/ui/Select/Select.module.css
Normal file
101
src/shared/ui/Select/Select.module.css
Normal 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);
|
||||||
|
}
|
||||||
92
src/shared/ui/Select/Select.tsx
Normal file
92
src/shared/ui/Select/Select.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
2
src/shared/ui/Select/index.ts
Normal file
2
src/shared/ui/Select/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { Select } from './Select'
|
||||||
|
export type { SelectOption } from './Select'
|
||||||
@@ -2,3 +2,5 @@ export { Button } from './Button'
|
|||||||
export { FormField } from './FormField'
|
export { FormField } from './FormField'
|
||||||
export { Notification } from './Notification'
|
export { Notification } from './Notification'
|
||||||
export { PrimaryButton } from './PrimaryButton'
|
export { PrimaryButton } from './PrimaryButton'
|
||||||
|
export { Select } from './Select'
|
||||||
|
export type { SelectOption } from './Select'
|
||||||
|
|||||||
@@ -42,30 +42,30 @@ export function AddLegalEntityModal({ open, onClose, onCreated }: Props) {
|
|||||||
<form className={styles.body} onSubmit={handleSubmit}>
|
<form className={styles.body} onSubmit={handleSubmit}>
|
||||||
<p className={styles.groupLabel}>Обязательные поля</p>
|
<p className={styles.groupLabel}>Обязательные поля</p>
|
||||||
<div className={styles.grid}>
|
<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="Пароль" 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.name} onChange={setField('name')} placeholder="ООО «Ромашка»" required />
|
||||||
<FormField label="ИНН" value={form.inn} onChange={setField('inn')} placeholder="10–12 цифр" required />
|
<FormField label="ИНН" value={form.inn} onChange={setField('inn')} placeholder="10–12 цифр" filter="digits" maxLength={12} required />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={styles.groupLabel}>Дополнительные поля</p>
|
<p className={styles.groupLabel}>Дополнительные поля</p>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
|
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} placeholder="Ромашка" />
|
||||||
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="—" />
|
<FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="—" filter="digits" maxLength={13} />
|
||||||
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="—" />
|
<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.status} onChange={setField('status')} placeholder="active" />
|
||||||
<FormField label="Юридический адрес" value={form.legal_address} onChange={setField('legal_address')} placeholder="—" />
|
<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.actual_address} onChange={setField('actual_address')} placeholder="—" />
|
||||||
<FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="—" />
|
<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" />
|
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" filter="phone" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className={styles.groupLabel}>Банковские реквизиты</p>
|
<p className={styles.groupLabel}>Банковские реквизиты</p>
|
||||||
<div className={styles.grid}>
|
<div className={styles.grid}>
|
||||||
<FormField label="Банк" value={form.bank_name} onChange={setField('bank_name')} placeholder="—" />
|
<FormField label="Банк" value={form.bank_name} onChange={setField('bank_name')} placeholder="—" />
|
||||||
<FormField label="БИК" value={form.bik} onChange={setField('bik')} placeholder="—" />
|
<FormField label="БИК" value={form.bik} onChange={setField('bik')} placeholder="—" filter="digits" maxLength={9} />
|
||||||
<FormField label="Расчётный счёт" value={form.account} onChange={setField('account')} placeholder="—" />
|
<FormField label="Расчётный счёт" value={form.account} onChange={setField('account')} placeholder="—" filter="digits" maxLength={20} />
|
||||||
<FormField label="Корр. счёт" value={form.corr_account} onChange={setField('corr_account')} placeholder="—" />
|
<FormField label="Корр. счёт" value={form.corr_account} onChange={setField('corr_account')} placeholder="—" filter="digits" maxLength={20} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && <p className={styles.error}>{error}</p>}
|
{error && <p className={styles.error}>{error}</p>}
|
||||||
|
|||||||
@@ -48,3 +48,9 @@
|
|||||||
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
margin-top: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { usePurchaseRequests } from '@features/admin'
|
import { usePurchaseRequests } from '@features/admin'
|
||||||
|
import { adminOrganizationCreateRequestPath } from '@shared/config/routes'
|
||||||
|
import { Button } from '@shared/ui'
|
||||||
import styles from './OrganizationPurchaseRequests.module.css'
|
import styles from './OrganizationPurchaseRequests.module.css'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -19,6 +22,18 @@ function formatDate(value: string | null): string {
|
|||||||
|
|
||||||
export function OrganizationPurchaseRequests({ orgId }: Props) {
|
export function OrganizationPurchaseRequests({ orgId }: Props) {
|
||||||
const { data, isLoading, isError } = usePurchaseRequests(orgId)
|
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) {
|
if (isLoading) {
|
||||||
return <div className={styles.state}>Загрузка...</div>
|
return <div className={styles.state}>Загрузка...</div>
|
||||||
@@ -29,10 +44,16 @@ export function OrganizationPurchaseRequests({ orgId }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!data || data.items.length === 0) {
|
if (!data || data.items.length === 0) {
|
||||||
return <div className={styles.state}>Заявок пока нет</div>
|
return (
|
||||||
|
<div>
|
||||||
|
<div className={styles.state}>Заявок пока нет</div>
|
||||||
|
{createButton}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div>
|
||||||
<div className={styles.tableWrap}>
|
<div className={styles.tableWrap}>
|
||||||
<table className={styles.table}>
|
<table className={styles.table}>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -59,5 +80,7 @@ export function OrganizationPurchaseRequests({ orgId }: Props) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{createButton}
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
Reference in New Issue
Block a user