admin page

This commit is contained in:
2026-06-05 22:52:52 +03:00
parent f4af2fd137
commit da55c61edd
13 changed files with 275 additions and 100 deletions

View File

@@ -6,6 +6,7 @@ import type {
DocumentResponse, DocumentResponse,
Organization, Organization,
OrganizationListResponse, OrganizationListResponse,
PurchaseRequestListResponse,
UpdateOrganizationRequest, UpdateOrganizationRequest,
WalletResponse, WalletResponse,
} from '../model/types' } from '../model/types'
@@ -120,18 +121,15 @@ export function createOrganizationWallets(id: string): Promise<WalletResponse[]>
) )
} }
export async function getDocuments(orgId: string): Promise<DocumentResponse[]> { export function getDocuments(orgId: string): Promise<DocumentResponse[]> {
const data = await doAdminRequest<DocumentResponse[]>( return doAdminRequest<DocumentResponse[]>(
`/v1/organizations/${orgId}/documents`, `/v1/organizations/${orgId}/documents`,
{}, {},
true, true,
) )
// TEMP: inspect the real backend shape (download_url presence, fields).
console.log('[documents] list response:', data)
return data
} }
export async function uploadDocument( export function uploadDocument(
orgId: string, orgId: string,
documentType: string, documentType: string,
file: File, file: File,
@@ -140,27 +138,32 @@ export async function uploadDocument(
body.append('document_type', documentType) body.append('document_type', documentType)
body.append('file', file) body.append('file', file)
const data = await doAdminRequest<DocumentResponse>( return doAdminRequest<DocumentResponse>(
`/v1/organizations/${orgId}/documents`, `/v1/organizations/${orgId}/documents`,
{ method: 'POST', body }, { method: 'POST', body },
true, true,
) )
// TEMP: inspect the real backend shape after upload.
console.log('[documents] upload response:', data)
return data
} }
export async function getDocument( export async function getPurchaseRequests(params: {
orgId: string, organizationId?: string
documentId: string, status?: string
): Promise<DocumentResponse> { limit?: number
const data = await doAdminRequest<DocumentResponse>( offset?: number
`/v1/organizations/${orgId}/documents/${documentId}`, }): Promise<PurchaseRequestListResponse> {
const query = new URLSearchParams()
if (params.organizationId) query.set('organization_id', params.organizationId)
if (params.status) query.set('status', params.status)
query.set('limit', String(params.limit ?? 50))
query.set('offset', String(params.offset ?? 0))
const data = await doAdminRequest<PurchaseRequestListResponse>(
`/v1/purchase-requests?${query.toString()}`,
{}, {},
true, true,
) )
// TEMP: inspect single-document shape (this is where download_url should appear). // TEMP: inspect real backend shape — especially which `status` values appear.
console.log('[documents] get-one response:', data) console.log('[purchase-requests] list response:', data)
return data return data
} }

View File

@@ -1,32 +0,0 @@
import { useState } from 'react'
import { getDocument } from '../api/adminApi'
import type { DocumentResponse } from '../model/types'
/**
* Resolves a document's download URL and opens it. The list endpoint may not
* include a fresh `download_url`, so we fall back to fetching the single
* document (which is where the presigned URL is expected to appear).
*/
export function useDownloadDocument(orgId: string) {
const [downloadingId, setDownloadingId] = useState<string | null>(null)
async function download(doc: DocumentResponse) {
setDownloadingId(doc.id)
try {
let url = doc.download_url
if (!url) {
const fresh = await getDocument(orgId, doc.id)
url = fresh.download_url
}
if (url) {
window.open(url, '_blank', 'noopener,noreferrer')
} else {
throw new Error('Сервер не вернул ссылку для скачивания')
}
} finally {
setDownloadingId(null)
}
}
return { download, downloadingId }
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query'
import { getPurchaseRequests } from '../api/adminApi'
export const PURCHASE_REQUESTS_QUERY_KEY = (orgId: string) => [
'admin-purchase-requests',
orgId,
]
export function usePurchaseRequests(orgId: string | undefined) {
return useQuery({
queryKey: PURCHASE_REQUESTS_QUERY_KEY(orgId ?? ''),
queryFn: () => getPurchaseRequests({ organizationId: orgId }),
enabled: !!orgId,
})
}

View File

@@ -9,7 +9,7 @@ export {
updateOrganization, updateOrganization,
getDocuments, getDocuments,
uploadDocument, uploadDocument,
getDocument, getPurchaseRequests,
refreshAdminToken, refreshAdminToken,
adminTokenStore, adminTokenStore,
} from './api/adminApi' } from './api/adminApi'
@@ -23,6 +23,8 @@ export type {
UpdateOrganizationRequest, UpdateOrganizationRequest,
WalletResponse, WalletResponse,
DocumentResponse, DocumentResponse,
PurchaseRequestResponse,
PurchaseRequestListResponse,
BankDetails, BankDetails,
} from './model/types' } from './model/types'
export { useAdminAuth, ADMIN_AUTH_QUERY_KEY } from './hooks/useAdminAuth' export { useAdminAuth, ADMIN_AUTH_QUERY_KEY } from './hooks/useAdminAuth'
@@ -35,4 +37,4 @@ export { useCreateOrganizationWallets } from './hooks/useCreateOrganizationWalle
export { useUpdateOrganization } from './hooks/useUpdateOrganization' export { useUpdateOrganization } from './hooks/useUpdateOrganization'
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 { useDownloadDocument } from './hooks/useDownloadDocument' export { usePurchaseRequests, PURCHASE_REQUESTS_QUERY_KEY } from './hooks/usePurchaseRequests'

View File

@@ -70,6 +70,31 @@ export interface DocumentResponse {
download_url: string | null download_url: string | null
} }
// Monetary fields are strings to preserve decimal precision — do not coerce to number.
export interface PurchaseRequestResponse {
id: string
organization_id: string
status: string
usdt_amount: string
rub_amount: string | null
exchange_rate: string | null
service_fee_percent: string | null
comment: string | null
admin_comment: string | null
target_wallet_chain: string | null
target_wallet_address: string | null
tx_hash: string | null
assigned_to: string | null
created_at: string | null
updated_at: string | null
completed_at: string | null
}
export interface PurchaseRequestListResponse {
items: PurchaseRequestResponse[]
total: number
}
export interface UpdateOrganizationRequest { export interface UpdateOrganizationRequest {
name?: string | null name?: string | null
short_name?: string | null short_name?: string | null

View File

@@ -39,9 +39,38 @@
gap: 24px; gap: 24px;
} }
.documents { .tabs {
max-width: 900px; max-width: 900px;
margin: 24px auto 0; margin: 0 auto 24px;
display: flex;
gap: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.tab {
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
font-size: 15px;
font-weight: 600;
padding: 12px 16px;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.tab:hover {
color: var(--text-primary, #fff);
}
.tabActive {
color: var(--text-primary, #fff);
border-bottom-color: var(--interactive, #4a6dff);
}
.tabPanel {
max-width: 900px;
margin: 0 auto;
} }
.section { .section {

View File

@@ -5,9 +5,18 @@ import { ROUTES } from '@shared/config/routes'
import { FormField, Notification, PrimaryButton } from '@shared/ui' import { FormField, Notification, PrimaryButton } from '@shared/ui'
import { AdminLoginForm } from '@widgets/admin-login-form' import { AdminLoginForm } from '@widgets/admin-login-form'
import { OrganizationDocuments } from '@widgets/organization-documents' import { OrganizationDocuments } from '@widgets/organization-documents'
import { OrganizationPurchaseRequests } from '@widgets/organization-purchase-requests'
import { useOrganizationForm } from '../model/useOrganizationForm' import { useOrganizationForm } from '../model/useOrganizationForm'
import styles from './AdminOrganizationPage.module.css' import styles from './AdminOrganizationPage.module.css'
type Tab = 'info' | 'documents' | 'requests'
const TABS: { id: Tab; label: string }[] = [
{ id: 'info', label: 'Общая информация' },
{ id: 'documents', label: 'Документы' },
{ id: 'requests', label: 'Заявки' },
]
function formatDateTime(value: string | null): string { function formatDateTime(value: string | null): string {
if (!value) return '—' if (!value) return '—'
const d = new Date(value) const d = new Date(value)
@@ -21,6 +30,7 @@ export function AdminOrganizationPage() {
const navigate = useNavigate() const navigate = useNavigate()
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')
const { form, setField, handleSubmit, isSaving, error } = useOrganizationForm( const { form, setField, handleSubmit, isSaving, error } = useOrganizationForm(
org, org,
organizationId ?? '', organizationId ?? '',
@@ -43,32 +53,47 @@ export function AdminOrganizationPage() {
{isError && <div className={styles.state}>Не удалось загрузить организацию</div>} {isError && <div className={styles.state}>Не удалось загрузить организацию</div>}
{org && ( {org && (
<div className={styles.tabs}>
{TABS.map((tab) => (
<button
key={tab.id}
type="button"
className={`${styles.tab} ${activeTab === tab.id ? styles.tabActive : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
)}
{org && activeTab === 'info' && (
<form className={styles.form} onSubmit={handleSubmit}> <form className={styles.form} onSubmit={handleSubmit}>
<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.name} onChange={setField('name')} required /> <FormField label="Наименование" value={form.name} onChange={setField('name')} placeholder="ООО «Ромашка»" required />
<FormField label="Краткое наименование" value={form.short_name} onChange={setField('short_name')} /> <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')} /> <FormField label="ОГРН" value={form.ogrn} onChange={setField('ogrn')} placeholder="1027700132195" />
<FormField label="КПП" value={form.kpp} onChange={setField('kpp')} /> <FormField label="КПП" value={form.kpp} onChange={setField('kpp')} placeholder="770801001" />
<FormField label="Статус" value={form.status} onChange={setField('status')} /> <FormField label="Статус" value={form.status} onChange={setField('status')} placeholder="active" />
</div> </div>
</section> </section>
<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.legal_address} onChange={setField('legal_address')} /> <FormField label="Юридический адрес" value={form.legal_address} onChange={setField('legal_address')} placeholder="г. Москва, ул. Тверская, д. 1" />
<FormField label="Фактический адрес" value={form.actual_address} onChange={setField('actual_address')} /> <FormField label="Фактический адрес" value={form.actual_address} onChange={setField('actual_address')} placeholder="г. Москва, ул. Тверская, д. 1" />
</div> </div>
</section> </section>
<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')} /> <FormField label="Контактное лицо" value={form.contact_person} onChange={setField('contact_person')} placeholder="Иванов Иван Иванович" />
<FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} /> <FormField label="Контактный телефон" type="tel" value={form.contact_phone} onChange={setField('contact_phone')} placeholder="+7 (999) 000-00-00" />
</div> </div>
</section> </section>
@@ -93,12 +118,18 @@ export function AdminOrganizationPage() {
</form> </form>
)} )}
{org && ( {org && activeTab === 'documents' && (
<div className={styles.documents}> <div className={styles.tabPanel}>
<OrganizationDocuments orgId={org.id} /> <OrganizationDocuments orgId={org.id} />
</div> </div>
)} )}
{org && activeTab === 'requests' && (
<div className={styles.tabPanel}>
<OrganizationPurchaseRequests orgId={org.id} />
</div>
)}
{notice && ( {notice && (
<Notification <Notification
status="success" status="success"

View File

@@ -110,6 +110,7 @@
} }
.downloadBtn { .downloadBtn {
display: inline-block;
background: rgba(74, 109, 255, 0.12); background: rgba(74, 109, 255, 0.12);
border: 1px solid rgba(74, 109, 255, 0.3); border: 1px solid rgba(74, 109, 255, 0.3);
border-radius: 8px; border-radius: 8px;
@@ -118,17 +119,17 @@
font-weight: 600; font-weight: 600;
padding: 7px 14px; padding: 7px 14px;
cursor: pointer; cursor: pointer;
text-decoration: none;
transition: background 0.2s; transition: background 0.2s;
white-space: nowrap; white-space: nowrap;
} }
.downloadBtn:hover:not(:disabled) { .downloadBtn:hover {
background: rgba(74, 109, 255, 0.22); background: rgba(74, 109, 255, 0.22);
} }
.downloadBtn:disabled { .muted {
opacity: 0.6; color: var(--text-secondary, rgba(255, 255, 255, 0.4));
cursor: not-allowed;
} }
.state { .state {

View File

@@ -1,10 +1,5 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { import { useDocuments, useUploadDocument } from '@features/admin'
useDocuments,
useUploadDocument,
useDownloadDocument,
} from '@features/admin'
import type { DocumentResponse } from '@features/admin'
import styles from './OrganizationDocuments.module.css' import styles from './OrganizationDocuments.module.css'
interface Props { interface Props {
@@ -37,18 +32,16 @@ function extractErrorMessage(error: unknown): string {
export function OrganizationDocuments({ orgId }: Props) { export function OrganizationDocuments({ orgId }: Props) {
const { data: documents, isLoading, isError } = useDocuments(orgId) const { data: documents, isLoading, isError } = useDocuments(orgId)
const upload = useUploadDocument(orgId) const upload = useUploadDocument(orgId)
const { download, downloadingId } = useDownloadDocument(orgId)
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [documentType, setDocumentType] = useState('') const [documentType, setDocumentType] = useState('')
const [file, setFile] = useState<File | null>(null) const [file, setFile] = useState<File | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
function handleUpload(e: React.FormEvent) { function handleUpload(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
if (!file || !documentType.trim()) return if (!file) return
upload.mutate( upload.mutate(
{ documentType: documentType.trim(), file }, { documentType: documentType.trim() || 'other', file },
{ {
onSuccess: () => { onSuccess: () => {
setDocumentType('') setDocumentType('')
@@ -59,15 +52,6 @@ export function OrganizationDocuments({ orgId }: Props) {
) )
} }
async function handleDownload(doc: DocumentResponse) {
setDownloadError(null)
try {
await download(doc)
} catch (err) {
setDownloadError(extractErrorMessage(err))
}
}
const uploadError = upload.isError ? extractErrorMessage(upload.error) : null const uploadError = upload.isError ? extractErrorMessage(upload.error) : null
return ( return (
@@ -78,7 +62,7 @@ export function OrganizationDocuments({ orgId }: Props) {
<input <input
className={styles.input} className={styles.input}
type="text" type="text"
placeholder="Тип документа (например, charter)" placeholder="Тип документа (необязательно)"
value={documentType} value={documentType}
onChange={(e) => setDocumentType(e.target.value)} onChange={(e) => setDocumentType(e.target.value)}
/> />
@@ -91,14 +75,13 @@ export function OrganizationDocuments({ orgId }: Props) {
<button <button
className={styles.uploadBtn} className={styles.uploadBtn}
type="submit" type="submit"
disabled={!file || !documentType.trim() || upload.isPending} disabled={!file || upload.isPending}
> >
{upload.isPending ? 'Загрузка...' : 'Загрузить'} {upload.isPending ? 'Загрузка...' : 'Загрузить'}
</button> </button>
</form> </form>
{uploadError && <p className={styles.error}>{uploadError}</p>} {uploadError && <p className={styles.error}>{uploadError}</p>}
{downloadError && <p className={styles.error}>{downloadError}</p>}
{isLoading && <div className={styles.state}>Загрузка...</div>} {isLoading && <div className={styles.state}>Загрузка...</div>}
{isError && <div className={styles.state}>Не удалось загрузить документы</div>} {isError && <div className={styles.state}>Не удалось загрузить документы</div>}
@@ -126,14 +109,18 @@ export function OrganizationDocuments({ orgId }: Props) {
<td className={styles.mono}>{formatSize(doc.file_size_bytes)}</td> <td className={styles.mono}>{formatSize(doc.file_size_bytes)}</td>
<td>{formatDate(doc.created_at)}</td> <td>{formatDate(doc.created_at)}</td>
<td> <td>
<button {doc.download_url ? (
className={styles.downloadBtn} <a
type="button" className={styles.downloadBtn}
onClick={() => handleDownload(doc)} href={doc.download_url}
disabled={downloadingId === doc.id} target="_blank"
> rel="noopener noreferrer"
{downloadingId === doc.id ? '...' : 'Скачать'} >
</button> Скачать
</a>
) : (
<span className={styles.muted}></span>
)}
</td> </td>
</tr> </tr>
))} ))}

View File

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

View File

@@ -0,0 +1,50 @@
.tableWrap {
overflow-x: auto;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th {
text-align: left;
font-size: 12px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-secondary);
font-weight: 500;
padding: 0 16px 14px;
white-space: nowrap;
}
.table td {
padding: 14px 16px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: middle;
font-size: 14px;
color: var(--text-primary);
}
.mono {
font-family: var(--font-mono, monospace);
font-size: 13px;
}
.status {
display: inline-block;
padding: 4px 10px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
background: rgba(74, 109, 255, 0.12);
color: #7c95ff;
white-space: nowrap;
}
.state {
padding: 32px 16px;
text-align: center;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
font-size: 14px;
}

View File

@@ -0,0 +1,63 @@
import { usePurchaseRequests } from '@features/admin'
import styles from './OrganizationPurchaseRequests.module.css'
interface Props {
orgId: string
}
function formatAmount(value: string | null, suffix: string): string {
if (!value) return '—'
return `${value} ${suffix}`
}
function formatDate(value: string | null): string {
if (!value) return '—'
const d = new Date(value)
if (Number.isNaN(d.getTime())) return '—'
return d.toLocaleString('ru-RU')
}
export function OrganizationPurchaseRequests({ orgId }: Props) {
const { data, isLoading, isError } = usePurchaseRequests(orgId)
if (isLoading) {
return <div className={styles.state}>Загрузка...</div>
}
if (isError) {
return <div className={styles.state}>Не удалось загрузить заявки</div>
}
if (!data || data.items.length === 0) {
return <div className={styles.state}>Заявок пока нет</div>
}
return (
<div className={styles.tableWrap}>
<table className={styles.table}>
<thead>
<tr>
<th>USDT</th>
<th>Сумма </th>
<th>Курс</th>
<th>Статус</th>
<th>Создана</th>
</tr>
</thead>
<tbody>
{data.items.map((req) => (
<tr key={req.id}>
<td className={styles.mono}>{formatAmount(req.usdt_amount, 'USDT')}</td>
<td className={styles.mono}>{formatAmount(req.rub_amount, '₽')}</td>
<td className={styles.mono}>{req.exchange_rate ?? '—'}</td>
<td>
<span className={styles.status}>{req.status}</span>
</td>
<td>{formatDate(req.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}

File diff suppressed because one or more lines are too long