This commit is contained in:
2026-06-09 20:38:58 +03:00
parent 80c7c5e8f8
commit 6ab0f8c137
13 changed files with 8037 additions and 369 deletions

File diff suppressed because one or more lines are too long

1
dist/assets/index-CneFMUxK.css vendored Normal file

File diff suppressed because one or more lines are too long

161
dist/assets/index-D1qd5k5N.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

4
dist/index.html vendored
View File

@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ЭКСА — Ваш мост в мир цифровых активов</title> <title>ЭКСА — Ваш мост в мир цифровых активов</title>
<script type="module" crossorigin src="/assets/index-CF-a3AIG.js"></script> <script type="module" crossorigin src="/assets/index-D1qd5k5N.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-f9EVcxOv.css"> <link rel="stylesheet" crossorigin href="/assets/index-CneFMUxK.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

5383
src/b2bapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@ import type {
CreateWalletsResponse, CreateWalletsResponse,
WalletResponse, WalletResponse,
DocumentResponse, DocumentResponse,
DocumentTypeSlug,
Organization, Organization,
OrganizationListResponse, OrganizationListResponse,
PurchaseRequestListResponse, PurchaseRequestListResponse,
@@ -138,18 +139,19 @@ export function getDocuments(orgId: string): Promise<DocumentResponse[]> {
) )
} }
// Upload/replace a single typed document. The type is part of the path and
// the body carries only the file — PUT acts as an upsert for that slot.
export function uploadDocument( export function uploadDocument(
orgId: string, orgId: string,
documentType: string, type: DocumentTypeSlug,
file: File, file: File,
): Promise<DocumentResponse> { ): Promise<DocumentResponse> {
const body = new FormData() const body = new FormData()
body.append('document_type', documentType)
body.append('file', file) body.append('file', file)
return doAdminRequest<DocumentResponse>( return doAdminRequest<DocumentResponse>(
`/v1/organizations/${orgId}/documents`, `/v1/organizations/${orgId}/documents/${type}`,
{ method: 'POST', body }, { method: 'PUT', body },
true, true,
) )
} }

View File

@@ -1,17 +1,18 @@
import { useMutation, useQueryClient } from '@tanstack/react-query' import { useMutation, useQueryClient } from '@tanstack/react-query'
import { uploadDocument } from '../api/adminApi' import { uploadDocument } from '../api/adminApi'
import type { DocumentTypeSlug } from '../model/types'
import { DOCUMENTS_QUERY_KEY } from './useDocuments' import { DOCUMENTS_QUERY_KEY } from './useDocuments'
interface UploadArgs { interface UploadArgs {
documentType: string type: DocumentTypeSlug
file: File file: File
} }
export function useUploadDocument(orgId: string) { export function useUploadDocument(orgId: string) {
const queryClient = useQueryClient() const queryClient = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ documentType, file }: UploadArgs) => mutationFn: ({ type, file }: UploadArgs) =>
uploadDocument(orgId, documentType, file), uploadDocument(orgId, type, file),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: DOCUMENTS_QUERY_KEY(orgId) }) queryClient.invalidateQueries({ queryKey: DOCUMENTS_QUERY_KEY(orgId) })
}, },

View File

@@ -24,6 +24,7 @@ export type {
UpdateOrganizationRequest, UpdateOrganizationRequest,
WalletResponse, WalletResponse,
DocumentResponse, DocumentResponse,
DocumentTypeSlug,
PurchaseRequestResponse, PurchaseRequestResponse,
PurchaseRequestListResponse, PurchaseRequestListResponse,
BankDetails, BankDetails,

View File

@@ -67,15 +67,25 @@ export interface CreateWalletsResponse {
mnemonic: string mnemonic: string
} }
// Fixed document type slugs — match the path segments of the per-type
// PUT/GET endpoints (`/documents/{slug}`). The API no longer accepts a
// free-text document type.
export type DocumentTypeSlug =
| 'charter'
| 'inn-certificate'
| 'ogrn-certificate'
| 'bank-details'
| 'kyc-representative'
| 'power-of-attorney'
| 'other'
export interface DocumentResponse { export interface DocumentResponse {
id: string
organization_id: string organization_id: string
document_type: string document_type: string
file_name: string s3_key: string | null
content_type: string file_name: string | null
file_size_bytes: number content_type: string | null
uploaded_by: string | null file_size_bytes: number | null
created_at: string | null
download_url: string | null download_url: string | null
} }

File diff suppressed because it is too large Load Diff

View File

@@ -14,52 +14,54 @@
margin: 0 0 18px; margin: 0 0 18px;
} }
.uploadForm { .slots {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
.slot {
display: flex; display: flex;
gap: 12px;
align-items: center; align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 16px; padding: 16px 0;
border-top: 1px solid rgba(255, 255, 255, 0.06);
} }
.input { .slot:first-child {
flex: 1; border-top: none;
}
.slotInfo {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 200px; min-width: 200px;
background: var(--glass-bg, rgba(255, 255, 255, 0.06)); }
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
border-radius: 10px; .slotLabel {
color: var(--text-primary, #fff);
font-size: 14px; font-size: 14px;
padding: 11px 14px; font-weight: 600;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.input:focus {
border-color: var(--interactive, #4a6dff);
box-shadow: 0 0 0 3px rgba(74, 109, 255, 0.15);
}
.fileInput {
flex: 1;
min-width: 200px;
font-size: 13px;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
}
.fileInput::file-selector-button {
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--glass-border, rgba(255, 255, 255, 0.1));
border-radius: 8px;
color: var(--text-primary, #fff); color: var(--text-primary, #fff);
padding: 8px 14px;
margin-right: 12px;
cursor: pointer;
transition: background 0.2s;
} }
.fileInput::file-selector-button:hover { .slotFile {
background: rgba(255, 255, 255, 0.14); font-size: 13px;
color: var(--text-secondary, rgba(255, 255, 255, 0.7));
}
.slotActions {
display: flex;
align-items: center;
gap: 10px;
}
.hiddenInput {
display: none;
} }
.uploadBtn { .uploadBtn {
@@ -80,30 +82,6 @@
cursor: not-allowed; cursor: not-allowed;
} }
.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 { .mono {
font-family: var(--font-mono, monospace); font-family: var(--font-mono, monospace);
font-size: 13px; font-size: 13px;

View File

@@ -1,24 +1,29 @@
import { useRef, useState } from 'react' import { useRef, useState } from 'react'
import { useDocuments, useUploadDocument } from '@features/admin' import { useDocuments, useUploadDocument } from '@features/admin'
import type { DocumentResponse, DocumentTypeSlug } from '@features/admin'
import styles from './OrganizationDocuments.module.css' import styles from './OrganizationDocuments.module.css'
interface Props { interface Props {
orgId: string orgId: string
} }
function formatSize(bytes: number): string { const DOCUMENT_SLOTS: { type: DocumentTypeSlug; label: string }[] = [
{ type: 'charter', label: 'Устав' },
{ type: 'inn-certificate', label: 'Свидетельство ИНН' },
{ type: 'ogrn-certificate', label: 'Свидетельство ОГРН' },
{ type: 'bank-details', label: 'Банковские реквизиты' },
{ type: 'kyc-representative', label: 'Документ представителя (KYC)' },
{ type: 'power-of-attorney', label: 'Доверенность' },
{ type: 'other', label: 'Прочее' },
]
function formatSize(bytes: number | null): string {
if (bytes == null) return '—'
if (bytes < 1024) return `${bytes} Б` if (bytes < 1024) return `${bytes} Б`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} КБ`
return `${(bytes / (1024 * 1024)).toFixed(1)} МБ` return `${(bytes / (1024 * 1024)).toFixed(1)} МБ`
} }
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')
}
function extractErrorMessage(error: unknown): string { function extractErrorMessage(error: unknown): string {
const e = error as { detail?: unknown; message?: unknown } const e = error as { detail?: unknown; message?: unknown }
if (typeof e?.detail === 'string') return e.detail if (typeof e?.detail === 'string') return e.detail
@@ -33,22 +38,26 @@ 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 fileInputRef = useRef<HTMLInputElement>(null) // Which slot's file picker is currently being uploaded — drives the per-row
const [documentType, setDocumentType] = useState('') // "Загрузка..." state and disables only the active row.
const [file, setFile] = useState<File | null>(null) const [activeType, setActiveType] = useState<DocumentTypeSlug | null>(null)
function handleUpload(e: React.FormEvent) { // Map list items onto fixed slots. `document_type` may come back in
e.preventDefault() // underscore form (e.g. `inn_certificate`) while our slugs use hyphens.
const byType = new Map<string, DocumentResponse>()
for (const doc of documents ?? []) {
byType.set(doc.document_type.replace(/_/g, '-'), doc)
}
function handleSelect(type: DocumentTypeSlug, input: HTMLInputElement) {
const file = input.files?.[0]
// Reset so picking the same file again still fires onChange.
input.value = ''
if (!file) return if (!file) return
setActiveType(type)
upload.mutate( upload.mutate(
{ documentType: documentType.trim() || 'other', file }, { type, file },
{ { onSettled: () => setActiveType(null) },
onSuccess: () => {
setDocumentType('')
setFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
},
},
) )
} }
@@ -58,58 +67,32 @@ export function OrganizationDocuments({ orgId }: Props) {
<section className={styles.section}> <section className={styles.section}>
<h2 className={styles.sectionTitle}>Документы</h2> <h2 className={styles.sectionTitle}>Документы</h2>
<form className={styles.uploadForm} onSubmit={handleUpload}>
<input
className={styles.input}
type="text"
placeholder="Тип документа (необязательно)"
value={documentType}
onChange={(e) => setDocumentType(e.target.value)}
/>
<input
ref={fileInputRef}
className={styles.fileInput}
type="file"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
<button
className={styles.uploadBtn}
type="submit"
disabled={!file || upload.isPending}
>
{upload.isPending ? 'Загрузка...' : 'Загрузить'}
</button>
</form>
{uploadError && <p className={styles.error}>{uploadError}</p>} {uploadError && <p className={styles.error}>{uploadError}</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>}
{documents && documents.length === 0 && ( {!isLoading && !isError && (
<div className={styles.state}>Документы ещё не загружены</div> <ul className={styles.slots}>
)} {DOCUMENT_SLOTS.map(({ type, label }) => {
const doc = byType.get(type)
const isUploading = upload.isPending && activeType === type
return (
<li key={type} className={styles.slot}>
<div className={styles.slotInfo}>
<span className={styles.slotLabel}>{label}</span>
{doc ? (
<span className={styles.slotFile}>
{doc.file_name ?? '—'}
<span className={styles.mono}> · {formatSize(doc.file_size_bytes)}</span>
</span>
) : (
<span className={styles.muted}>Не загружен</span>
)}
</div>
{documents && documents.length > 0 && ( <div className={styles.slotActions}>
<table className={styles.table}> {doc?.download_url && (
<thead>
<tr>
<th>Файл</th>
<th>Тип</th>
<th>Размер</th>
<th>Загружено</th>
<th />
</tr>
</thead>
<tbody>
{documents.map((doc) => (
<tr key={doc.id}>
<td>{doc.file_name}</td>
<td>{doc.document_type}</td>
<td className={styles.mono}>{formatSize(doc.file_size_bytes)}</td>
<td>{formatDate(doc.created_at)}</td>
<td>
{doc.download_url ? (
<a <a
className={styles.downloadBtn} className={styles.downloadBtn}
href={doc.download_url} href={doc.download_url}
@@ -118,15 +101,48 @@ export function OrganizationDocuments({ orgId }: Props) {
> >
Скачать Скачать
</a> </a>
) : (
<span className={styles.muted}></span>
)} )}
</td> <UploadButton
</tr> label={doc ? 'Заменить' : 'Загрузить'}
))} busy={isUploading}
</tbody> disabled={upload.isPending}
</table> onSelect={(input) => handleSelect(type, input)}
/>
</div>
</li>
)
})}
</ul>
)} )}
</section> </section>
) )
} }
interface UploadButtonProps {
label: string
busy: boolean
disabled: boolean
onSelect: (input: HTMLInputElement) => void
}
function UploadButton({ label, busy, disabled, onSelect }: UploadButtonProps) {
const inputRef = useRef<HTMLInputElement>(null)
return (
<>
<button
type="button"
className={styles.uploadBtn}
disabled={disabled}
onClick={() => inputRef.current?.click()}
>
{busy ? 'Загрузка...' : label}
</button>
<input
ref={inputRef}
type="file"
className={styles.hiddenInput}
onChange={(e) => onSelect(e.currentTarget)}
/>
</>
)
}