admin page
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
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,
|
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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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