fix docs
This commit is contained in:
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,
|
||||
WalletResponse,
|
||||
DocumentResponse,
|
||||
DocumentTypeSlug,
|
||||
Organization,
|
||||
OrganizationListResponse,
|
||||
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(
|
||||
orgId: string,
|
||||
documentType: string,
|
||||
type: DocumentTypeSlug,
|
||||
file: File,
|
||||
): Promise<DocumentResponse> {
|
||||
const body = new FormData()
|
||||
body.append('document_type', documentType)
|
||||
body.append('file', file)
|
||||
|
||||
return doAdminRequest<DocumentResponse>(
|
||||
`/v1/organizations/${orgId}/documents`,
|
||||
{ method: 'POST', body },
|
||||
`/v1/organizations/${orgId}/documents/${type}`,
|
||||
{ method: 'PUT', body },
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { uploadDocument } from '../api/adminApi'
|
||||
import type { DocumentTypeSlug } from '../model/types'
|
||||
import { DOCUMENTS_QUERY_KEY } from './useDocuments'
|
||||
|
||||
interface UploadArgs {
|
||||
documentType: string
|
||||
type: DocumentTypeSlug
|
||||
file: File
|
||||
}
|
||||
|
||||
export function useUploadDocument(orgId: string) {
|
||||
const queryClient = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ documentType, file }: UploadArgs) =>
|
||||
uploadDocument(orgId, documentType, file),
|
||||
mutationFn: ({ type, file }: UploadArgs) =>
|
||||
uploadDocument(orgId, type, file),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: DOCUMENTS_QUERY_KEY(orgId) })
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ export type {
|
||||
UpdateOrganizationRequest,
|
||||
WalletResponse,
|
||||
DocumentResponse,
|
||||
DocumentTypeSlug,
|
||||
PurchaseRequestResponse,
|
||||
PurchaseRequestListResponse,
|
||||
BankDetails,
|
||||
|
||||
@@ -67,15 +67,25 @@ export interface CreateWalletsResponse {
|
||||
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 {
|
||||
id: string
|
||||
organization_id: string
|
||||
document_type: string
|
||||
file_name: string
|
||||
content_type: string
|
||||
file_size_bytes: number
|
||||
uploaded_by: string | null
|
||||
created_at: string | null
|
||||
s3_key: string | null
|
||||
file_name: string | null
|
||||
content_type: string | null
|
||||
file_size_bytes: number | 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;
|
||||
}
|
||||
|
||||
.uploadForm {
|
||||
.slots {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.slot {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
padding: 16px 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
.slot:first-child {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.slotInfo {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
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;
|
||||
color: var(--text-primary, #fff);
|
||||
}
|
||||
|
||||
.slotLabel {
|
||||
font-size: 14px;
|
||||
padding: 11px 14px;
|
||||
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;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #fff);
|
||||
padding: 8px 14px;
|
||||
margin-right: 12px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.fileInput::file-selector-button:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
.slotFile {
|
||||
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 {
|
||||
@@ -80,30 +82,6 @@
|
||||
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 {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { useDocuments, useUploadDocument } from '@features/admin'
|
||||
import type { DocumentResponse, DocumentTypeSlug } from '@features/admin'
|
||||
import styles from './OrganizationDocuments.module.css'
|
||||
|
||||
interface Props {
|
||||
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 * 1024) return `${(bytes / 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 {
|
||||
const e = error as { detail?: unknown; message?: unknown }
|
||||
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 upload = useUploadDocument(orgId)
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [documentType, setDocumentType] = useState('')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
// Which slot's file picker is currently being uploaded — drives the per-row
|
||||
// "Загрузка..." state and disables only the active row.
|
||||
const [activeType, setActiveType] = useState<DocumentTypeSlug | null>(null)
|
||||
|
||||
function handleUpload(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
// Map list items onto fixed slots. `document_type` may come back in
|
||||
// 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
|
||||
setActiveType(type)
|
||||
upload.mutate(
|
||||
{ documentType: documentType.trim() || 'other', file },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setDocumentType('')
|
||||
setFile(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
},
|
||||
},
|
||||
{ type, file },
|
||||
{ onSettled: () => setActiveType(null) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -58,58 +67,32 @@ export function OrganizationDocuments({ orgId }: Props) {
|
||||
<section className={styles.section}>
|
||||
<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>}
|
||||
|
||||
{isLoading && <div className={styles.state}>Загрузка...</div>}
|
||||
{isError && <div className={styles.state}>Не удалось загрузить документы</div>}
|
||||
|
||||
{documents && documents.length === 0 && (
|
||||
<div className={styles.state}>Документы ещё не загружены</div>
|
||||
)}
|
||||
{!isLoading && !isError && (
|
||||
<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 && (
|
||||
<table className={styles.table}>
|
||||
<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 ? (
|
||||
<div className={styles.slotActions}>
|
||||
{doc?.download_url && (
|
||||
<a
|
||||
className={styles.downloadBtn}
|
||||
href={doc.download_url}
|
||||
@@ -118,15 +101,48 @@ export function OrganizationDocuments({ orgId }: Props) {
|
||||
>
|
||||
Скачать
|
||||
</a>
|
||||
) : (
|
||||
<span className={styles.muted}>—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<UploadButton
|
||||
label={doc ? 'Заменить' : 'Загрузить'}
|
||||
busy={isUploading}
|
||||
disabled={upload.isPending}
|
||||
onSelect={(input) => handleSelect(type, input)}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</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