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 3293854..1ca995b 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/ImportCompanyGroupsModal.tsx b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx new file mode 100644 index 0000000..420bb53 --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx @@ -0,0 +1,114 @@ +import { 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 { OnExisting } from '../../../types/api'; +import { useNotificationApi } from '../../../utils/useNotification'; + +type Props = { + open: boolean; + onCancel: () => void; + onImported: () => void; +}; + +type FormValues = { + on_existing: OnExisting; + dry_run: boolean; +}; + +const ON_EXISTING_OPTIONS: { value: OnExisting; label: string }[] = [ + { value: 'skip', label: 'Пропустить существующие' }, + { value: 'update', label: 'Обновить существующие' }, +]; + +/** + * Импорт выгрузки ЕЦП CRM. Сценарий двухшаговый: после пробного прогона форма и файл остаются, чтобы снять галочку + * и записать тем же файлом; после записи — уведомление и закрытие окна. + */ +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 handleOk = async () => { + const file = fileList[0]?.originFileObj; + if (!file) { + return; + } + const values = form.getFieldsValue(); + setLoading(true); + try { + await importDirectory(file, values.dry_run, values.on_existing); + if (values.dry_run) { + notification.info({ description: 'Пробный прогон завершён: в справочник ничего не записано' }); + return; + } + notification.success({ description: 'Импорт завершён' }); + onImported(); + onCancel(); + } catch (e) { + notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось выполнить импорт' }); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + if (!loading) { + onCancel(); + } + }; + + return ( + +
+ +