fix docs
This commit is contained in:
161
dist/assets/index-CF-a3AIG.js
vendored
161
dist/assets/index-CF-a3AIG.js
vendored
File diff suppressed because one or more lines are too long
1
dist/assets/index-CneFMUxK.css
vendored
Normal file
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
161
dist/assets/index-D1qd5k5N.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
dist/assets/index-f9EVcxOv.css
vendored
1
dist/assets/index-f9EVcxOv.css
vendored
File diff suppressed because one or more lines are too long
4
dist/index.html
vendored
4
dist/index.html
vendored
@@ -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
5383
src/b2bapi.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export type {
|
|||||||
UpdateOrganizationRequest,
|
UpdateOrganizationRequest,
|
||||||
WalletResponse,
|
WalletResponse,
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
|
DocumentTypeSlug,
|
||||||
PurchaseRequestResponse,
|
PurchaseRequestResponse,
|
||||||
PurchaseRequestListResponse,
|
PurchaseRequestListResponse,
|
||||||
BankDetails,
|
BankDetails,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
2393
src/openapi.json
2393
src/openapi.json
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user