diff --git a/web/src/auth/permissions.ts b/web/src/auth/permissions.ts index f6a49b8..a0ff6ae 100644 --- a/web/src/auth/permissions.ts +++ b/web/src/auth/permissions.ts @@ -1,3 +1,4 @@ // Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер: // проверки на клиенте только прячут то, что пользователю всё равно не разрешат. export const USERS_ADMIN = 'USERS_ADMIN'; +export const DIRECTORY_ADMIN = 'DIRECTORY_ADMIN'; diff --git a/web/src/layouts/CatalogueGroups.tsx b/web/src/layouts/CatalogueGroups.tsx index dfcf853..4941d21 100644 --- a/web/src/layouts/CatalogueGroups.tsx +++ b/web/src/layouts/CatalogueGroups.tsx @@ -1,7 +1,9 @@ -import { PlusOutlined } from '@ant-design/icons'; +import { PlusOutlined, UploadOutlined } from '@ant-design/icons'; import { Button, Empty, Space } from 'antd'; import { useEffect, useMemo, useState } from 'react'; import { ApiError } from '../api/client'; +import { useCurrentUser } from '../auth/MeContext'; +import { DIRECTORY_ADMIN } from '../auth/permissions'; import PageTitle from '../components/PageTitle'; import { createCatalogueJur } from '../services/api/directories/catalogue_jur'; import { @@ -30,6 +32,7 @@ import { } from '../widgets/CatalogueCompanyGroup/CatalogueCompanyGroupTabs/CatalogueCompanyGroupTabs'; import { CompanyGroupForm } from '../widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm'; import { CompanyGroupsList } from '../widgets/CatalogueCompanyGroup/CompanyGroupsList/CompanyGroupsList'; +import { ImportCompanyGroupsModal } from '../widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal'; import { CatalogueJurForm } from '../widgets/CatalogueJur/CatalogueJurForm/CatalogueJurForm'; import { CatalogueJurTable } from '../widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable'; import { CreateEntityPickerModal } from '../widgets/CreateEntityPickerModal/CreateEntityPickerModal'; @@ -39,10 +42,12 @@ import { RelatedGroupTable } from '../widgets/RelatedGroup/RelatedGroupTable/Rel export function CatalogueGroup() { const dispatch = useAppDispatch(); const notification = useNotificationApi(); + const me = useCurrentUser(); const isGettingArchive = useAppSelector((state) => state.catalogue_jur.isGettingArchive); const [entityCreateModalOpen, setEntityCreateModalOpen] = useState(false); + const [importModalOpen, setImportModalOpen] = useState(false); const [kindForm, setKindForm] = useState(null); const [relatedGroupLoading, setRelatedGroupLoading] = useState(false); const [companyGroupEditFormOpen, setCompanyGroupEditFormOpen] = useState(false); @@ -93,6 +98,18 @@ export function CatalogueGroup() { } } + function importedHandler() { + dispatch(getCompanyGroupsThunk()); + dispatch( + getCatalogueJurThunk({ + closed: isGettingArchive, + outside_group: false, + company_group_id: activeCompanyGroup?.id, + }), + ); + getRelatedGroupsHandler(); + } + async function archiveCompanyGroupHandler() { if (!activeCompanyGroup) { return; @@ -168,9 +185,16 @@ export function CatalogueGroup() { }}> - + + {me.permissions.includes(DIRECTORY_ADMIN) && ( + + )} + +
+ {importModalOpen && ( + setImportModalOpen(false)} onImported={importedHandler} /> + )} { diff --git a/web/src/services/api/directories/directory_import.ts b/web/src/services/api/directories/directory_import.ts new file mode 100644 index 0000000..a6fedb6 --- /dev/null +++ b/web/src/services/api/directories/directory_import.ts @@ -0,0 +1,10 @@ +import { apiFetch } from '../../../api/client'; +import type { ImportReportOut, OnExisting } from '../../../types/api'; + +export function importDirectory(file: File, dryRun: boolean, onExisting: OnExisting): Promise { + const form = new FormData(); + form.append('file', file); + form.append('dry_run', String(dryRun)); + form.append('on_existing', onExisting); + return apiFetch('/directories/import', { method: 'POST', body: form }); +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 6889769..14f6a7f 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -30,6 +30,27 @@ export interface UploadResult { rejected: RejectedOut[]; } +export type OnExisting = 'skip' | 'update'; +export interface ImportReportLine { + row: number; + inn: string; + code: string; + message: string; +} +export interface ImportReportOut { + rows: number; + groups_created: number; + groups_updated: number; + groups_skipped: number; + entities_created: number; + entities_updated: number; + entities_skipped: number; + memberships_created: number; + offices_created: number; + errors: number; + lines: ImportReportLine[]; +} + /** Статусы строки формы (v5.19): ok — строка бланка; unreadable — код не распознан; missing — кода нет; detail — расшифровка. */ export type LineStatus = 'ok' | 'unreadable' | 'missing' | 'detail'; export type Severity = 'error' | 'info'; diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/Counter.tsx b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/Counter.tsx new file mode 100644 index 0000000..5c8880d --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/Counter.tsx @@ -0,0 +1,16 @@ +import { Typography } from 'antd'; + +type Props = { + label: string; + value: number; +}; + +/** Строка карточки: подпись серым, число справа — чёрным, если не 0. */ +export function Counter({ label, value }: Props) { + return ( +
+ {label} + {value} +
+ ); +} diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/CounterCard.tsx b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/CounterCard.tsx new file mode 100644 index 0000000..577ba6c --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/CounterCard.tsx @@ -0,0 +1,19 @@ +import { Card } from 'antd'; +import { Counter } from './Counter'; + +type Props = { + title: string; + items: { label: string; value: number }[]; +}; + +export function CounterCard({ title, items }: Props) { + return ( + +
+ {items.map((item) => ( + + ))} +
+
+ ); +} diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx new file mode 100644 index 0000000..51a2f81 --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx @@ -0,0 +1,147 @@ +import { ArrowLeftOutlined, FileAddOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Form, Select, Upload } from 'antd'; +import type { UploadFile } from 'antd/es/upload/interface'; +import type React from 'react'; +import { useState } from 'react'; +import { ApiError } from '../../../api/client'; +import { AppModal } from '../../../components/AppModal/AppModal'; +import { importDirectory } from '../../../services/api/directories/directory_import'; +import type { ImportReportOut, OnExisting } from '../../../types/api'; +import { useNotificationApi } from '../../../utils/useNotification'; +import { ImportSummary } from './ImportSummary'; + +type Props = { + open: boolean; + onCancel: () => void; + onImported: () => void; +}; + +type FormValues = { + on_existing: OnExisting; + dry_run: boolean; +}; + +/** Отчёт последнего запуска; dryRun — был ли это пробный прогон (от него зависят заголовок, текст и кнопки сводки). */ +type ImportResult = { + report: ImportReportOut; + dryRun: boolean; +}; + +const ON_EXISTING_OPTIONS: { value: OnExisting; label: string }[] = [ + { value: 'skip', label: 'Пропустить существующие' }, + { value: 'update', label: 'Обновить существующие' }, +]; + +function modalTitle(result: ImportResult | null): string { + if (!result) { + return 'Импорт групп компаний из файла'; + } + return result.dryRun ? 'Сводка импорта' : 'Импорт завершён'; +} + +/** + * Импорт выгрузки ЕЦП CRM. Ответ заменяет форму сводкой (отчёт §13.3). После пробного прогона из сводки — закрытие окна + * или возврат к форме («К загрузке файла», файл и значения формы сохраняются); после записи — только закрытие, + * список справочника обновляется сразу. + */ +export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onImported }) => { + const [form] = Form.useForm(); + const notification = useNotificationApi(); + + const [fileList, setFileList] = useState([]); + const [loading, setLoading] = useState(false); + const [result, setResult] = useState(null); + + const handleOk = async () => { + const file = fileList[0]?.originFileObj; + if (!file) { + return; + } + const values = form.getFieldsValue(); + setLoading(true); + try { + const report = await importDirectory(file, values.dry_run, values.on_existing); + setResult({ report, dryRun: values.dry_run }); + if (!values.dry_run) { + onImported(); + } + } catch (e) { + notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось выполнить импорт' }); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + if (!loading) { + onCancel(); + } + }; + + return ( + + {result.dryRun && ( + + )} + + + ) : undefined + } + confirmLoading={loading} + closable={!loading} + destroyOnHidden + width={result ? 880 : 720}> +
+ +