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

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,
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,
)
}

View File

@@ -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) })
},

View File

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

View File

@@ -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
}

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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)}
/>
</>
)
}