admin page
This commit is contained in:
@@ -3,9 +3,11 @@ import type {
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
CreateOrganizationRequest,
|
||||
DocumentResponse,
|
||||
Organization,
|
||||
OrganizationListResponse,
|
||||
UpdateOrganizationRequest,
|
||||
WalletResponse,
|
||||
} from '../model/types'
|
||||
|
||||
const ADMIN_API_URL = 'https://app.admin.elcsa.ru'
|
||||
@@ -26,12 +28,15 @@ async function doAdminRequest<T>(
|
||||
allowRetry: boolean,
|
||||
): Promise<T> {
|
||||
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}`, {
|
||||
...options,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
|
||||
...(bearer ? { Authorization: `Bearer ${bearer}` } : {}),
|
||||
...options.headers,
|
||||
},
|
||||
@@ -107,6 +112,58 @@ export function getOrganization(id: string): Promise<Organization> {
|
||||
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(
|
||||
id: string,
|
||||
payload: UpdateOrganizationRequest,
|
||||
|
||||
16
src/features/admin/hooks/useCreateOrganizationWallets.ts
Normal file
16
src/features/admin/hooks/useCreateOrganizationWallets.ts
Normal 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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
12
src/features/admin/hooks/useDocuments.ts
Normal file
12
src/features/admin/hooks/useDocuments.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
32
src/features/admin/hooks/useDownloadDocument.ts
Normal file
32
src/features/admin/hooks/useDownloadDocument.ts
Normal 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 }
|
||||
}
|
||||
19
src/features/admin/hooks/useUploadDocument.ts
Normal file
19
src/features/admin/hooks/useUploadDocument.ts
Normal 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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,11 @@ export {
|
||||
getOrganizations,
|
||||
getOrganization,
|
||||
createOrganization,
|
||||
createOrganizationWallets,
|
||||
updateOrganization,
|
||||
getDocuments,
|
||||
uploadDocument,
|
||||
getDocument,
|
||||
refreshAdminToken,
|
||||
adminTokenStore,
|
||||
} from './api/adminApi'
|
||||
@@ -17,6 +21,8 @@ export type {
|
||||
OrganizationListResponse,
|
||||
CreateOrganizationRequest,
|
||||
UpdateOrganizationRequest,
|
||||
WalletResponse,
|
||||
DocumentResponse,
|
||||
BankDetails,
|
||||
} from './model/types'
|
||||
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 { useOrganization, ORGANIZATION_QUERY_KEY } from './hooks/useOrganization'
|
||||
export { useCreateOrganization } from './hooks/useCreateOrganization'
|
||||
export { useCreateOrganizationWallets } from './hooks/useCreateOrganizationWallets'
|
||||
export { useUpdateOrganization } from './hooks/useUpdateOrganization'
|
||||
export { useDocuments, DOCUMENTS_QUERY_KEY } from './hooks/useDocuments'
|
||||
export { useUploadDocument } from './hooks/useUploadDocument'
|
||||
export { useDownloadDocument } from './hooks/useDownloadDocument'
|
||||
|
||||
@@ -50,6 +50,26 @@ export interface OrganizationListResponse {
|
||||
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 {
|
||||
name?: string | null
|
||||
short_name?: string | null
|
||||
|
||||
@@ -12,7 +12,6 @@ interface FormState {
|
||||
contact_person: string
|
||||
contact_phone: string
|
||||
status: string
|
||||
bank_details: string
|
||||
}
|
||||
|
||||
function toForm(org: Organization): FormState {
|
||||
@@ -26,7 +25,6 @@ function toForm(org: Organization): FormState {
|
||||
contact_person: org.contact_person ?? '',
|
||||
contact_phone: org.contact_phone ?? '',
|
||||
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>(() =>
|
||||
org ? toForm(org) : {
|
||||
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)
|
||||
|
||||
// Sync local form state once the organization loads / changes.
|
||||
@@ -63,20 +60,9 @@ export function useOrganizationForm(
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setBankError(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 = {
|
||||
name: form.name.trim(),
|
||||
short_name: trimmedOrNull(form.short_name),
|
||||
@@ -87,14 +73,12 @@ export function useOrganizationForm(
|
||||
contact_person: trimmedOrNull(form.contact_person),
|
||||
contact_phone: trimmedOrNull(form.contact_phone),
|
||||
status: trimmedOrNull(form.status),
|
||||
bank_details,
|
||||
}
|
||||
|
||||
mutation.mutate(payload, { onSuccess: () => onSaved?.() })
|
||||
}
|
||||
|
||||
const error =
|
||||
bankError ?? (mutation.isError ? extractErrorMessage(mutation.error) : null)
|
||||
const error = mutation.isError ? extractErrorMessage(mutation.error) : null
|
||||
|
||||
return {
|
||||
form,
|
||||
|
||||
@@ -39,6 +39,11 @@
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.documents {
|
||||
max-width: 900px;
|
||||
margin: 24px auto 0;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAdminAuth, useOrganization } from '@features/admin'
|
||||
import { ROUTES } from '@shared/config/routes'
|
||||
import { FormField, Notification, PrimaryButton } from '@shared/ui'
|
||||
import { AdminLoginForm } from '@widgets/admin-login-form'
|
||||
import { OrganizationDocuments } from '@widgets/organization-documents'
|
||||
import { useOrganizationForm } from '../model/useOrganizationForm'
|
||||
import styles from './AdminOrganizationPage.module.css'
|
||||
|
||||
@@ -71,19 +72,6 @@ export function AdminOrganizationPage() {
|
||||
</div>
|
||||
</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}>
|
||||
<h2 className={styles.sectionTitle}>Системная информация</h2>
|
||||
<div className={styles.grid}>
|
||||
@@ -105,6 +93,12 @@ export function AdminOrganizationPage() {
|
||||
</form>
|
||||
)}
|
||||
|
||||
{org && (
|
||||
<div className={styles.documents}>
|
||||
<OrganizationDocuments orgId={org.id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notice && (
|
||||
<Notification
|
||||
status="success"
|
||||
|
||||
@@ -1,16 +1,40 @@
|
||||
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 { AdminLoginForm } from '@widgets/admin-login-form'
|
||||
import { LegalEntitiesTable } from '@widgets/legal-entities-table'
|
||||
import { AddLegalEntityModal } from '@widgets/add-legal-entity-modal'
|
||||
import styles from './AdminPage.module.css'
|
||||
|
||||
type NotificationState = { message: string; status: 'success' | 'error' | 'warning' }
|
||||
|
||||
export function AdminPage() {
|
||||
const { isAuthenticated, isLoading } = useAdminAuth()
|
||||
const logout = useAdminLogout()
|
||||
const createWallets = useCreateOrganizationWallets()
|
||||
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 (!isAuthenticated) return <AdminLoginForm />
|
||||
@@ -38,7 +62,7 @@ export function AdminPage() {
|
||||
<AddLegalEntityModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={() => setNotification({ status: 'success', message: 'Юридическое лицо добавлено' })}
|
||||
onCreated={handleCreated}
|
||||
/>
|
||||
|
||||
{notification && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react'
|
||||
import { useCreateOrganization } from '@features/admin'
|
||||
import type { CreateOrganizationRequest } from '@features/admin'
|
||||
import type { CreateOrganizationRequest, Organization } from '@features/admin'
|
||||
|
||||
const INITIAL = {
|
||||
email: '',
|
||||
@@ -32,7 +32,7 @@ function extractErrorMessage(error: unknown): string {
|
||||
return 'Не удалось добавить юридическое лицо'
|
||||
}
|
||||
|
||||
export function useAddLegalEntityForm(onSuccess: () => void) {
|
||||
export function useAddLegalEntityForm(onSuccess: (organization: Organization) => void) {
|
||||
const [form, setForm] = useState<FormState>(INITIAL)
|
||||
const mutation = useCreateOrganization()
|
||||
|
||||
@@ -67,9 +67,9 @@ export function useAddLegalEntityForm(onSuccess: () => void) {
|
||||
}
|
||||
|
||||
mutation.mutate(payload, {
|
||||
onSuccess: () => {
|
||||
onSuccess: (organization) => {
|
||||
setForm(INITIAL)
|
||||
onSuccess()
|
||||
onSuccess(organization)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { Organization } from '@features/admin'
|
||||
import { FormField, PrimaryButton } from '@shared/ui'
|
||||
import { useAddLegalEntityForm } from '../model/useAddLegalEntityForm'
|
||||
import styles from './AddLegalEntityModal.module.css'
|
||||
@@ -6,12 +7,12 @@ import styles from './AddLegalEntityModal.module.css'
|
||||
interface Props {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onCreated: () => void
|
||||
onCreated: (organization: Organization) => void
|
||||
}
|
||||
|
||||
export function AddLegalEntityModal({ open, onClose, onCreated }: Props) {
|
||||
const { form, setField, handleSubmit, isLoading, error } = useAddLegalEntityForm(() => {
|
||||
onCreated()
|
||||
const { form, setField, handleSubmit, isLoading, error } = useAddLegalEntityForm((organization) => {
|
||||
onCreated(organization)
|
||||
onClose()
|
||||
})
|
||||
|
||||
|
||||
1
src/widgets/organization-documents/index.ts
Normal file
1
src/widgets/organization-documents/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { OrganizationDocuments } from './ui/OrganizationDocuments'
|
||||
@@ -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;
|
||||
}
|
||||
145
src/widgets/organization-documents/ui/OrganizationDocuments.tsx
Normal file
145
src/widgets/organization-documents/ui/OrganizationDocuments.tsx
Normal 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
Reference in New Issue
Block a user