admin page

This commit is contained in:
2026-06-05 22:33:02 +03:00
parent fd66ca9c9b
commit f4af2fd137
17 changed files with 508 additions and 43 deletions

View File

@@ -3,9 +3,11 @@ import type {
AdminLoginResponse, AdminLoginResponse,
AdminMeResponse, AdminMeResponse,
CreateOrganizationRequest, CreateOrganizationRequest,
DocumentResponse,
Organization, Organization,
OrganizationListResponse, OrganizationListResponse,
UpdateOrganizationRequest, UpdateOrganizationRequest,
WalletResponse,
} from '../model/types' } from '../model/types'
const ADMIN_API_URL = 'https://app.admin.elcsa.ru' const ADMIN_API_URL = 'https://app.admin.elcsa.ru'
@@ -26,12 +28,15 @@ async function doAdminRequest<T>(
allowRetry: boolean, allowRetry: boolean,
): Promise<T> { ): Promise<T> {
const bearer = adminTokenStore.get() const bearer = adminTokenStore.get()
// For multipart uploads we must NOT set Content-Type — the browser adds the
// boundary itself. Detect FormData bodies and skip the JSON header.
const isFormData = options.body instanceof FormData
const res = await fetch(`${ADMIN_API_URL}${path}`, { const res = await fetch(`${ADMIN_API_URL}${path}`, {
...options, ...options,
credentials: 'include', credentials: 'include',
headers: { headers: {
'Content-Type': 'application/json', ...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}), ...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
...options.headers, ...options.headers,
}, },
@@ -107,6 +112,58 @@ export function getOrganization(id: string): Promise<Organization> {
return doAdminRequest<Organization>(`/v1/organizations/${id}`, {}, true) return doAdminRequest<Organization>(`/v1/organizations/${id}`, {}, true)
} }
export function createOrganizationWallets(id: string): Promise<WalletResponse[]> {
return doAdminRequest<WalletResponse[]>(
`/v1/organizations/${id}/wallets/create`,
{ method: 'POST' },
true,
)
}
export async function getDocuments(orgId: string): Promise<DocumentResponse[]> {
const data = await doAdminRequest<DocumentResponse[]>(
`/v1/organizations/${orgId}/documents`,
{},
true,
)
// TEMP: inspect the real backend shape (download_url presence, fields).
console.log('[documents] list response:', data)
return data
}
export async function uploadDocument(
orgId: string,
documentType: string,
file: File,
): Promise<DocumentResponse> {
const body = new FormData()
body.append('document_type', documentType)
body.append('file', file)
const data = await doAdminRequest<DocumentResponse>(
`/v1/organizations/${orgId}/documents`,
{ method: 'POST', body },
true,
)
// TEMP: inspect the real backend shape after upload.
console.log('[documents] upload response:', data)
return data
}
export async function getDocument(
orgId: string,
documentId: string,
): Promise<DocumentResponse> {
const data = await doAdminRequest<DocumentResponse>(
`/v1/organizations/${orgId}/documents/${documentId}`,
{},
true,
)
// TEMP: inspect single-document shape (this is where download_url should appear).
console.log('[documents] get-one response:', data)
return data
}
export function updateOrganization( export function updateOrganization(
id: string, id: string,
payload: UpdateOrganizationRequest, payload: UpdateOrganizationRequest,

View File

@@ -0,0 +1,16 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { createOrganizationWallets } from '../api/adminApi'
import { ORGANIZATIONS_QUERY_KEY } from './useOrganizations'
import { ORGANIZATION_QUERY_KEY } from './useOrganization'
export function useCreateOrganizationWallets() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (organizationId: string) => createOrganizationWallets(organizationId),
onSuccess: (_wallets, organizationId) => {
// `has_wallets` flips to true server-side — refresh list and detail view.
queryClient.invalidateQueries({ queryKey: ORGANIZATIONS_QUERY_KEY })
queryClient.invalidateQueries({ queryKey: ORGANIZATION_QUERY_KEY(organizationId) })
},
})
}

View File

@@ -0,0 +1,12 @@
import { useQuery } from '@tanstack/react-query'
import { getDocuments } from '../api/adminApi'
export const DOCUMENTS_QUERY_KEY = (orgId: string) => ['admin-documents', orgId]
export function useDocuments(orgId: string | undefined) {
return useQuery({
queryKey: DOCUMENTS_QUERY_KEY(orgId ?? ''),
queryFn: () => getDocuments(orgId as string),
enabled: !!orgId,
})
}

View File

@@ -0,0 +1,32 @@
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,19 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { uploadDocument } from '../api/adminApi'
import { DOCUMENTS_QUERY_KEY } from './useDocuments'
interface UploadArgs {
documentType: string
file: File
}
export function useUploadDocument(orgId: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: ({ documentType, file }: UploadArgs) =>
uploadDocument(orgId, documentType, file),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: DOCUMENTS_QUERY_KEY(orgId) })
},
})
}

View File

@@ -5,7 +5,11 @@ export {
getOrganizations, getOrganizations,
getOrganization, getOrganization,
createOrganization, createOrganization,
createOrganizationWallets,
updateOrganization, updateOrganization,
getDocuments,
uploadDocument,
getDocument,
refreshAdminToken, refreshAdminToken,
adminTokenStore, adminTokenStore,
} from './api/adminApi' } from './api/adminApi'
@@ -17,6 +21,8 @@ export type {
OrganizationListResponse, OrganizationListResponse,
CreateOrganizationRequest, CreateOrganizationRequest,
UpdateOrganizationRequest, UpdateOrganizationRequest,
WalletResponse,
DocumentResponse,
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'
@@ -25,4 +31,8 @@ export { useAdminLogout } from './hooks/useAdminLogout'
export { useOrganizations, ORGANIZATIONS_QUERY_KEY } from './hooks/useOrganizations' export { useOrganizations, ORGANIZATIONS_QUERY_KEY } from './hooks/useOrganizations'
export { useOrganization, ORGANIZATION_QUERY_KEY } from './hooks/useOrganization' export { useOrganization, ORGANIZATION_QUERY_KEY } from './hooks/useOrganization'
export { useCreateOrganization } from './hooks/useCreateOrganization' export { useCreateOrganization } from './hooks/useCreateOrganization'
export { useCreateOrganizationWallets } from './hooks/useCreateOrganizationWallets'
export { useUpdateOrganization } from './hooks/useUpdateOrganization' export { useUpdateOrganization } from './hooks/useUpdateOrganization'
export { useDocuments, DOCUMENTS_QUERY_KEY } from './hooks/useDocuments'
export { useUploadDocument } from './hooks/useUploadDocument'
export { useDownloadDocument } from './hooks/useDownloadDocument'

View File

@@ -50,6 +50,26 @@ export interface OrganizationListResponse {
total: number total: number
} }
export interface WalletResponse {
id: string
chain: string
address: string
derivation_path: string
created_at: string | null
}
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
download_url: string | null
}
export interface UpdateOrganizationRequest { export interface UpdateOrganizationRequest {
name?: string | null name?: string | null
short_name?: string | null short_name?: string | null

View File

@@ -12,7 +12,6 @@ interface FormState {
contact_person: string contact_person: string
contact_phone: string contact_phone: string
status: string status: string
bank_details: string
} }
function toForm(org: Organization): FormState { function toForm(org: Organization): FormState {
@@ -26,7 +25,6 @@ function toForm(org: Organization): FormState {
contact_person: org.contact_person ?? '', contact_person: org.contact_person ?? '',
contact_phone: org.contact_phone ?? '', contact_phone: org.contact_phone ?? '',
status: org.status ?? '', status: org.status ?? '',
bank_details: org.bank_details ? JSON.stringify(org.bank_details, null, 2) : '',
} }
} }
@@ -47,10 +45,9 @@ export function useOrganizationForm(
const [form, setForm] = useState<FormState>(() => const [form, setForm] = useState<FormState>(() =>
org ? toForm(org) : { org ? toForm(org) : {
name: '', short_name: '', ogrn: '', kpp: '', legal_address: '', name: '', short_name: '', ogrn: '', kpp: '', legal_address: '',
actual_address: '', contact_person: '', contact_phone: '', status: '', bank_details: '', actual_address: '', contact_person: '', contact_phone: '', status: '',
}, },
) )
const [bankError, setBankError] = useState<string | null>(null)
const mutation = useUpdateOrganization(id) const mutation = useUpdateOrganization(id)
// Sync local form state once the organization loads / changes. // Sync local form state once the organization loads / changes.
@@ -63,20 +60,9 @@ export function useOrganizationForm(
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
setBankError(null)
const trimmedOrNull = (v: string) => (v.trim() ? v.trim() : null) const trimmedOrNull = (v: string) => (v.trim() ? v.trim() : null)
let bank_details: Record<string, unknown> | null = null
if (form.bank_details.trim()) {
try {
bank_details = JSON.parse(form.bank_details)
} catch {
setBankError('Банковские реквизиты должны быть корректным JSON')
return
}
}
const payload: UpdateOrganizationRequest = { const payload: UpdateOrganizationRequest = {
name: form.name.trim(), name: form.name.trim(),
short_name: trimmedOrNull(form.short_name), short_name: trimmedOrNull(form.short_name),
@@ -87,14 +73,12 @@ export function useOrganizationForm(
contact_person: trimmedOrNull(form.contact_person), contact_person: trimmedOrNull(form.contact_person),
contact_phone: trimmedOrNull(form.contact_phone), contact_phone: trimmedOrNull(form.contact_phone),
status: trimmedOrNull(form.status), status: trimmedOrNull(form.status),
bank_details,
} }
mutation.mutate(payload, { onSuccess: () => onSaved?.() }) mutation.mutate(payload, { onSuccess: () => onSaved?.() })
} }
const error = const error = mutation.isError ? extractErrorMessage(mutation.error) : null
bankError ?? (mutation.isError ? extractErrorMessage(mutation.error) : null)
return { return {
form, form,

View File

@@ -39,6 +39,11 @@
gap: 24px; gap: 24px;
} }
.documents {
max-width: 900px;
margin: 24px auto 0;
}
.section { .section {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);

View File

@@ -4,6 +4,7 @@ import { useAdminAuth, useOrganization } from '@features/admin'
import { ROUTES } from '@shared/config/routes' 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 { useOrganizationForm } from '../model/useOrganizationForm' import { useOrganizationForm } from '../model/useOrganizationForm'
import styles from './AdminOrganizationPage.module.css' import styles from './AdminOrganizationPage.module.css'
@@ -71,19 +72,6 @@ export function AdminOrganizationPage() {
</div> </div>
</section> </section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Банковские реквизиты</h2>
<label className={styles.bankLabel}>JSON-объект реквизитов</label>
<textarea
className={styles.textarea}
value={form.bank_details}
onChange={(e) => setField('bank_details')(e.target.value)}
placeholder={'{\n "bank_name": "...",\n "bik": "...",\n "account": "..."\n}'}
rows={6}
spellCheck={false}
/>
</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}>
@@ -105,6 +93,12 @@ export function AdminOrganizationPage() {
</form> </form>
)} )}
{org && (
<div className={styles.documents}>
<OrganizationDocuments orgId={org.id} />
</div>
)}
{notice && ( {notice && (
<Notification <Notification
status="success" status="success"

View File

@@ -1,16 +1,40 @@
import { useState } from 'react' import { useState } from 'react'
import { useAdminAuth, useAdminLogout } from '@features/admin' import { useAdminAuth, useAdminLogout, useCreateOrganizationWallets } from '@features/admin'
import type { Organization } from '@features/admin'
import { Notification } from '@shared/ui' import { Notification } from '@shared/ui'
import { AdminLoginForm } from '@widgets/admin-login-form' import { AdminLoginForm } from '@widgets/admin-login-form'
import { LegalEntitiesTable } from '@widgets/legal-entities-table' import { LegalEntitiesTable } from '@widgets/legal-entities-table'
import { AddLegalEntityModal } from '@widgets/add-legal-entity-modal' import { AddLegalEntityModal } from '@widgets/add-legal-entity-modal'
import styles from './AdminPage.module.css' import styles from './AdminPage.module.css'
type NotificationState = { message: string; status: 'success' | 'error' | 'warning' }
export function AdminPage() { export function AdminPage() {
const { isAuthenticated, isLoading } = useAdminAuth() const { isAuthenticated, isLoading } = useAdminAuth()
const logout = useAdminLogout() const logout = useAdminLogout()
const createWallets = useCreateOrganizationWallets()
const [modalOpen, setModalOpen] = useState(false) const [modalOpen, setModalOpen] = useState(false)
const [notification, setNotification] = useState<{ message: string; status: 'success' | 'error' } | null>(null) const [notification, setNotification] = useState<NotificationState | null>(null)
// After a legal entity is created we immediately provision its wallets.
// The page stays mounted (unlike the modal), so these mutate callbacks fire reliably.
function handleCreated(organization: Organization) {
setNotification({ status: 'success', message: 'Юридическое лицо добавлено' })
createWallets.mutate(organization.id, {
onSuccess: (wallets) => {
setNotification({
status: 'success',
message: `Кошельки созданы (${wallets.length})`,
})
},
onError: () => {
setNotification({
status: 'warning',
message: 'Юридическое лицо создано, но кошельки создать не удалось',
})
},
})
}
if (isLoading) return null if (isLoading) return null
if (!isAuthenticated) return <AdminLoginForm /> if (!isAuthenticated) return <AdminLoginForm />
@@ -38,7 +62,7 @@ export function AdminPage() {
<AddLegalEntityModal <AddLegalEntityModal
open={modalOpen} open={modalOpen}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
onCreated={() => setNotification({ status: 'success', message: 'Юридическое лицо добавлено' })} onCreated={handleCreated}
/> />
{notification && ( {notification && (

View File

@@ -1,6 +1,6 @@
import { useState } from 'react' import { useState } from 'react'
import { useCreateOrganization } from '@features/admin' import { useCreateOrganization } from '@features/admin'
import type { CreateOrganizationRequest } from '@features/admin' import type { CreateOrganizationRequest, Organization } from '@features/admin'
const INITIAL = { const INITIAL = {
email: '', email: '',
@@ -32,7 +32,7 @@ function extractErrorMessage(error: unknown): string {
return 'Не удалось добавить юридическое лицо' return 'Не удалось добавить юридическое лицо'
} }
export function useAddLegalEntityForm(onSuccess: () => void) { export function useAddLegalEntityForm(onSuccess: (organization: Organization) => void) {
const [form, setForm] = useState<FormState>(INITIAL) const [form, setForm] = useState<FormState>(INITIAL)
const mutation = useCreateOrganization() const mutation = useCreateOrganization()
@@ -67,9 +67,9 @@ export function useAddLegalEntityForm(onSuccess: () => void) {
} }
mutation.mutate(payload, { mutation.mutate(payload, {
onSuccess: () => { onSuccess: (organization) => {
setForm(INITIAL) setForm(INITIAL)
onSuccess() onSuccess(organization)
}, },
}) })
} }

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react' import { useEffect } from 'react'
import type { Organization } from '@features/admin'
import { FormField, PrimaryButton } from '@shared/ui' import { FormField, PrimaryButton } from '@shared/ui'
import { useAddLegalEntityForm } from '../model/useAddLegalEntityForm' import { useAddLegalEntityForm } from '../model/useAddLegalEntityForm'
import styles from './AddLegalEntityModal.module.css' import styles from './AddLegalEntityModal.module.css'
@@ -6,12 +7,12 @@ import styles from './AddLegalEntityModal.module.css'
interface Props { interface Props {
open: boolean open: boolean
onClose: () => void onClose: () => void
onCreated: () => void onCreated: (organization: Organization) => void
} }
export function AddLegalEntityModal({ open, onClose, onCreated }: Props) { export function AddLegalEntityModal({ open, onClose, onCreated }: Props) {
const { form, setField, handleSubmit, isLoading, error } = useAddLegalEntityForm(() => { const { form, setField, handleSubmit, isLoading, error } = useAddLegalEntityForm((organization) => {
onCreated() onCreated(organization)
onClose() onClose()
}) })

View File

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

View File

@@ -0,0 +1,145 @@
.section {
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 24px;
}
.sectionTitle {
font-size: 14px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
font-weight: 600;
margin: 0 0 18px;
}
.uploadForm {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.input {
flex: 1;
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);
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;
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);
}
.uploadBtn {
background: var(--interactive, #4a6dff);
border: none;
border-radius: 10px;
color: #fff;
font-size: 14px;
font-weight: 600;
padding: 11px 20px;
cursor: pointer;
transition: opacity 0.2s;
white-space: nowrap;
}
.uploadBtn:disabled {
opacity: 0.5;
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;
}
.downloadBtn {
background: rgba(74, 109, 255, 0.12);
border: 1px solid rgba(74, 109, 255, 0.3);
border-radius: 8px;
color: #7c95ff;
font-size: 13px;
font-weight: 600;
padding: 7px 14px;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.downloadBtn:hover:not(:disabled) {
background: rgba(74, 109, 255, 0.22);
}
.downloadBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.state {
padding: 32px 16px;
text-align: center;
color: var(--text-secondary, rgba(255, 255, 255, 0.6));
font-size: 14px;
}
.error {
color: #ff5a5a;
font-size: 13px;
margin: 0 0 12px;
}

View File

@@ -0,0 +1,145 @@
import { useRef, useState } from 'react'
import {
useDocuments,
useUploadDocument,
useDownloadDocument,
} from '@features/admin'
import type { DocumentResponse } from '@features/admin'
import styles from './OrganizationDocuments.module.css'
interface Props {
orgId: string
}
function formatSize(bytes: number): string {
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
if (Array.isArray(e?.detail) && (e.detail[0] as { msg?: string })?.msg) {
return (e.detail[0] as { msg: string }).msg
}
if (typeof e?.message === 'string') return e.message
return 'Не удалось выполнить операцию'
}
export function OrganizationDocuments({ orgId }: Props) {
const { data: documents, isLoading, isError } = useDocuments(orgId)
const upload = useUploadDocument(orgId)
const { download, downloadingId } = useDownloadDocument(orgId)
const fileInputRef = useRef<HTMLInputElement>(null)
const [documentType, setDocumentType] = useState('')
const [file, setFile] = useState<File | null>(null)
const [downloadError, setDownloadError] = useState<string | null>(null)
function handleUpload(e: React.FormEvent) {
e.preventDefault()
if (!file || !documentType.trim()) return
upload.mutate(
{ documentType: documentType.trim(), file },
{
onSuccess: () => {
setDocumentType('')
setFile(null)
if (fileInputRef.current) fileInputRef.current.value = ''
},
},
)
}
async function handleDownload(doc: DocumentResponse) {
setDownloadError(null)
try {
await download(doc)
} catch (err) {
setDownloadError(extractErrorMessage(err))
}
}
const uploadError = upload.isError ? extractErrorMessage(upload.error) : null
return (
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Документы</h2>
<form className={styles.uploadForm} onSubmit={handleUpload}>
<input
className={styles.input}
type="text"
placeholder="Тип документа (например, charter)"
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 || !documentType.trim() || upload.isPending}
>
{upload.isPending ? 'Загрузка...' : 'Загрузить'}
</button>
</form>
{uploadError && <p className={styles.error}>{uploadError}</p>}
{downloadError && <p className={styles.error}>{downloadError}</p>}
{isLoading && <div className={styles.state}>Загрузка...</div>}
{isError && <div className={styles.state}>Не удалось загрузить документы</div>}
{documents && documents.length === 0 && (
<div className={styles.state}>Документы ещё не загружены</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>
<button
className={styles.downloadBtn}
type="button"
onClick={() => handleDownload(doc)}
disabled={downloadingId === doc.id}
>
{downloadingId === doc.id ? '...' : 'Скачать'}
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</section>
)
}

File diff suppressed because one or more lines are too long