From fec179b74747179508e0f6293e482d71a615d971 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Thu, 8 Oct 2026 01:05:57 +0400 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BE=D0=BD=D1=81=D0=BE=D0=BB=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=E2=80=94=20=D0=BF=D1=80=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20=D0=93=D0=A1=D0=9A,=20?= =?UTF-8?q?=D0=B2=D1=81=D0=B5=20=D0=AE=D0=9B,=20=D0=BE=D1=88=D0=B8=D0=B1?= =?UTF-8?q?=D0=BA=D0=B8,=20=D0=BF=D0=B5=D1=80=D0=B5=D1=85=D0=BE=D0=B4=20?= =?UTF-8?q?=D0=BA=20=D0=B2=D0=B5=D1=80=D1=81=D0=B8=D0=B8=20(#3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- src/layouts/ConsolidationsList.tsx | 23 ++-- .../ConsoleLegalEntities/normalize.test.ts | 44 +++++++ src/widgets/ConsoleTable/ConsoleTable.tsx | 16 ++- .../NewConsoleModal/NewConsoleModal.tsx | 108 +++++++++--------- src/widgets/NewConsoleModal/preselect.test.ts | 16 +++ src/widgets/NewConsoleModal/preselect.ts | 7 ++ src/widgets/Sidebar/Sidebar.tsx | 16 +-- 7 files changed, 150 insertions(+), 80 deletions(-) create mode 100644 src/widgets/ConsoleLegalEntities/normalize.test.ts create mode 100644 src/widgets/NewConsoleModal/preselect.test.ts create mode 100644 src/widgets/NewConsoleModal/preselect.ts diff --git a/src/layouts/ConsolidationsList.tsx b/src/layouts/ConsolidationsList.tsx index 489c34d..a641d46 100644 --- a/src/layouts/ConsolidationsList.tsx +++ b/src/layouts/ConsolidationsList.tsx @@ -1,5 +1,7 @@ +import { message } from 'antd'; import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; +import { ApiError } from '../api/client'; import PageTitle from '../components/PageTitle'; import { fetchOneCompanyGroup } from '../services/api/directories/company_groups'; import { setSelectedVersion } from '../store/slices/consolidations/consolidationsSlice'; @@ -12,6 +14,8 @@ import { DocumentsDrawer } from '../widgets/FilesDrawer/FilesDrawer'; export function ConsolidationPage() { const [drawerOpen, setDrawerOpen] = useState(false); const { group_id } = useParams(); + const [searchParams] = useSearchParams(); + const versionParam = searchParams.get('version'); const dispatch = useAppDispatch(); const [comGroupName, setComGroupName] = useState(''); @@ -21,17 +25,22 @@ export function ConsolidationPage() { useEffect(() => { if (!group_id) return; async function getAllInfo(id: string) { - const data = await fetchOneCompanyGroup(id); - setComGroupName(data.name); - setLocationPath(`Отчетность>${data.name}>Консолидации`); - setDesc(`${data.legal_entities_count} организаций`); - dispatch(getConsolidationsListThunk(id)); + try { + const data = await fetchOneCompanyGroup(id); + setComGroupName(data.name); + setLocationPath(`Отчетность>${data.name}>Консолидации`); + setDesc(`${data.legal_entities_count} организаций`); + await dispatch(getConsolidationsListThunk(id)); + if (versionParam) dispatch(setSelectedVersion(versionParam)); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить группу компаний'); + } } getAllInfo(group_id); return () => { dispatch(setSelectedVersion(null)); }; - }, [dispatch, group_id]); + }, [dispatch, group_id, versionParam]); return (
{ + it('строка ЮЛ со статусами ББ/ОФР и загруженными ОСВ', () => { + expect(normalizeData(card)).toEqual([ + { + id: 'le1', + name: 'АО "Русский бисквит"', + inn: '3528052718', + version_id: 'v1', + entity_type: 'ЮЛ', + bb_status: 'Требуется проверка', + ofr_status: 'Отсутствует', + uploaded_osvs: '60, 62', + }, + ]); + }); + it('null — пусто', () => { + expect(normalizeData(null)).toEqual([]); + }); +}); diff --git a/src/widgets/ConsoleTable/ConsoleTable.tsx b/src/widgets/ConsoleTable/ConsoleTable.tsx index 1270094..3ce387b 100644 --- a/src/widgets/ConsoleTable/ConsoleTable.tsx +++ b/src/widgets/ConsoleTable/ConsoleTable.tsx @@ -1,8 +1,8 @@ -import { ArrowRightOutlined, DownOutlined, IdcardOutlined, MoreOutlined, RightOutlined } from '@ant-design/icons'; -import { Button, Dropdown, Space } from 'antd'; +import { ArrowRightOutlined, DownOutlined, MoreOutlined, RightOutlined } from '@ant-design/icons'; +import { Button, Dropdown, Space, message } from 'antd'; import type { ColumnsType, TableProps } from 'antd/es/table'; import { memo, useCallback, useMemo } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useParams } from 'react-router-dom'; import { ColumnSettings, DataTable, type TableConfig, isGroup, useFilteredRows, useVisibleColumns } from '../../components/GenericTable'; import { useTableFilters } from '../../components/GenericTable/useTableFilters'; import { setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice'; @@ -44,6 +44,7 @@ const RowActions = memo(function RowActions({ }); export function ConsoleTableWidget() { const navigate = useNavigate(); + const { group_id } = useParams(); const dispatch = useAppDispatch(); const consolidations = useAppSelector((s) => s.consolidations.consolidations); const loading = useAppSelector((s) => s.consolidations.loading); @@ -109,7 +110,7 @@ export function ConsoleTableWidget() { if (key === 'history') { navigate('/logging'); } else { - alert('Не реализовано'); + message.info('Будет позже'); } }, [navigate], @@ -122,10 +123,7 @@ export function ConsoleTableWidget() { width: 120, render: (_: unknown, row: ConsRow) => (
- - @@ -133,7 +131,7 @@ export function ConsoleTableWidget() { ), }, ], - [navigate], + [navigate, group_id, handleDropdownAction], ); const onRow: TableProps['onRow'] = (row) => ({ diff --git a/src/widgets/NewConsoleModal/NewConsoleModal.tsx b/src/widgets/NewConsoleModal/NewConsoleModal.tsx index 2413a98..c461107 100644 --- a/src/widgets/NewConsoleModal/NewConsoleModal.tsx +++ b/src/widgets/NewConsoleModal/NewConsoleModal.tsx @@ -1,27 +1,21 @@ -import { Checkbox, Col, Empty, Form, Input, Modal, Row, Select, Spin } from 'antd'; +import { Checkbox, Col, Empty, Form, Input, Modal, Row, Select, Spin, message } from 'antd'; import type React from 'react'; import { useEffect, useMemo, useRef, useState } from 'react'; -import { fetchAllLegalEntities, fetchRelatedGroups } from '../../services/api/directories/company_groups'; +import { ApiError } from '../../api/client'; +import { fetchAllLegalEntities, fetchRelatedGroup, fetchRelatedGroups } from '../../services/api/directories/company_groups'; import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks'; import { useAppDispatch, useAppSelector } from '../../store/store'; -import type { LegalEntityBrief, RelatedGroupOut } from '../../types/api'; - -type FormValues = { - company_group_id: string; - related_group_id: string; - year: number; - quarter: string; // example of 2nd static field - legal_entity_ids: string[]; -}; +import type { ConsolidationCreate, LegalEntityBrief, Quarter, RelatedGroupOut } from '../../types/api'; +import { preselectMembers } from './preselect'; type Props = { open: boolean; onCancel: () => void; - onOk: (values: FormValues) => void | Promise; + onOk: (values: ConsolidationCreate) => Promise; }; -export const MyModal: React.FC = ({ open, onCancel, onOk }) => { - const [form] = Form.useForm(); +export const NewConsolidationModal: React.FC = ({ open, onCancel, onOk }) => { + const [form] = Form.useForm(); const dispatch = useAppDispatch(); const { raw_rows, loading } = useAppSelector((state) => state.company_groups); @@ -33,6 +27,8 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { const [legEntities, setLegEntities] = useState([]); const [loadingEntities, setLoadingEntities] = useState(false); const legEntReqRef = useRef(0); + const relatedReqRef = useRef(0); + const [submitting, setSubmitting] = useState(false); const [legEntSearch, setLegEntSearch] = useState(''); @@ -42,7 +38,7 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { const semester = Form.useWatch('quarter', form); const legEntIds = Form.useWatch('legal_entity_ids', form); - const quarterOptions = [ + const quarterOptions: { value: Quarter; label: string }[] = [ { value: 'Q1', label: '1 квартал' }, { value: 'Q2', label: '2 квартал' }, { value: 'Q3', label: '3 квартал' }, @@ -71,37 +67,6 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { legEntReqRef.current++; }; - // const handleGroupChange = async (value?: string) => { - // form.setFieldsValue({ - // related_group_id: undefined, - // legal_entity_ids: [], - // }); - // setSubgroups([]); - // clearLegEnts(); - // if (!value) return; - // const reqId = ++subgroupReqRef.current; - // setLoadingSubgroups(true); - // try { - // const data = await fetchGSKByComGroup(value); - // if (reqId === subgroupReqRef.current) setSubgroups(data); - // } finally { - // if (reqId === subgroupReqRef.current) setLoadingSubgroups(false); - // } - // }; - - // const handleSubGroupChange = async (value?: string) => { - // form.setFieldsValue({ legal_entity_ids: [] }); - // clearLegEnts(); - // if (!value) return; - // const reqId = ++legEntReqRef.current; - // setLoadingEntities(true); - // try { - // const data = await fetchGroupMembers(value); - // if (reqId === legEntReqRef.current) setLegEntities(data); - // } finally { - // if (reqId === legEntReqRef.current) setLoadingEntities(false); - // } - // }; const handleGroupChange = async (value?: string) => { form.setFieldsValue({ related_group_id: undefined, @@ -115,17 +80,38 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { setLoadingSubgroups(true); setLoadingEntities(true); try { - const dataSubgroups = await fetchRelatedGroups(value); - const dataLegEnts = await fetchAllLegalEntities(value); - if (reqId === subgroupReqRef.current) setSubgroups(dataSubgroups); + const [dataSubgroups, dataLegEnts] = await Promise.all([fetchRelatedGroups(value), fetchAllLegalEntities(value)]); + if (reqId === subgroupReqRef.current) setSubgroups(dataSubgroups.filter((g) => g.kind === 'gsk')); if (leReqId === legEntReqRef.current) setLegEntities(dataLegEnts); + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить данные ГК'); } finally { if (reqId === subgroupReqRef.current) setLoadingSubgroups(false); if (leReqId === legEntReqRef.current) setLoadingEntities(false); } }; + const handleRelatedGroupChange = async (value?: string) => { + form.setFieldsValue({ legal_entity_ids: [] }); + if (!value) return; + const reqId = ++relatedReqRef.current; + try { + const detail = await fetchRelatedGroup(value); + if (reqId === relatedReqRef.current) { + form.setFieldsValue({ + legal_entity_ids: preselectMembers( + detail.members.map((m) => m.legal_entity_id), + legEntities, + ), + }); + } + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить состав ГСК'); + } + }; + const resetLocal = () => { + relatedReqRef.current++; setSubgroups([]); setLoadingSubgroups(false); subgroupReqRef.current++; @@ -134,13 +120,21 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { }; const handleOk = async () => { + let values: ConsolidationCreate; + try { + values = await form.validateFields(); + } catch { + return; // ошибки полей antd показывает сам + } + setSubmitting(true); try { - const values = await form.validateFields(); await onOk(values); form.resetFields(); resetLocal(); - } catch { - // ошибка валидации или запроса: форма остаётся открытой, пользователь исправит данные + } catch (e: unknown) { + message.error(e instanceof ApiError ? e.message : 'Не удалось создать консолидацию'); + } finally { + setSubmitting(false); } }; @@ -163,10 +157,12 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { onCancel={handleCancel} onOk={handleOk} okButtonProps={{ disabled: isSubmitDisabled }} - okText='Submit' + okText='Создать' + cancelText='Отмена' + confirmLoading={submitting} destroyOnHidden width={640}> -
+ @@ -191,7 +187,7 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { allowClear filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())} options={subgroups.map((s) => ({ value: s.id, label: s.name }))} - // onChange={handleSubGroupChange} + onChange={handleRelatedGroupChange} /> @@ -235,7 +231,7 @@ export const MyModal: React.FC = ({ open, onCancel, onOk }) => { {!loadingEntities && filteredLegEnts.length === 0 && ( )} {!loadingEntities && filteredLegEnts.length > 0 && ( diff --git a/src/widgets/NewConsoleModal/preselect.test.ts b/src/widgets/NewConsoleModal/preselect.test.ts new file mode 100644 index 0000000..74e9124 --- /dev/null +++ b/src/widgets/NewConsoleModal/preselect.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; +import { preselectMembers } from './preselect'; + +const entities = [ + { id: 'a', name: 'А', inn: '1' }, + { id: 'b', name: 'Б', inn: '2' }, +]; + +describe('preselectMembers', () => { + it('отмечает только тех членов ГСК, что есть среди ЮЛ ГК', () => { + expect(preselectMembers(['b', 'x'], entities)).toEqual(['b']); + }); + it('пустая ГСК — ничего', () => { + expect(preselectMembers([], entities)).toEqual([]); + }); +}); diff --git a/src/widgets/NewConsoleModal/preselect.ts b/src/widgets/NewConsoleModal/preselect.ts new file mode 100644 index 0000000..7fb83f9 --- /dev/null +++ b/src/widgets/NewConsoleModal/preselect.ts @@ -0,0 +1,7 @@ +import type { LegalEntityBrief } from '../../types/api'; + +/** Члены ГСК, присутствующие среди ЮЛ ГК, — начальные отметки периметра. */ +export function preselectMembers(memberIds: string[], entities: LegalEntityBrief[]): string[] { + const present = new Set(entities.map((e) => e.id)); + return memberIds.filter((id) => present.has(id)); +} diff --git a/src/widgets/Sidebar/Sidebar.tsx b/src/widgets/Sidebar/Sidebar.tsx index 61ed0e2..3a9b088 100644 --- a/src/widgets/Sidebar/Sidebar.tsx +++ b/src/widgets/Sidebar/Sidebar.tsx @@ -9,12 +9,12 @@ import { TeamOutlined, UserOutlined, } from '@ant-design/icons'; -import { Button, Divider, Layout, Tooltip } from 'antd'; +import { Button, Divider, Layout, Tooltip, message } from 'antd'; import { type ReactNode, memo, useState } from 'react'; -import { NavLink } from 'react-router-dom'; +import { NavLink, useNavigate } from 'react-router-dom'; import { USERS_ADMIN } from '../../auth/permissions'; import { createConsolidation } from '../../services/api/consolidations'; -import { MyModal } from '../NewConsoleModal/NewConsoleModal'; +import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal'; import styles from './styles.module.css'; type CollapsedIconProps = { @@ -162,6 +162,7 @@ const Section = memo(function Section({ }); export function Sidebar() { + const navigate = useNavigate(); const [modalOpen, setModalOpen] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false); @@ -207,17 +208,16 @@ export function Sidebar() {
))}
- { setModalOpen(false); }} onOk={async (values) => { - await createConsolidation({ - ...values, - quarter: values.quarter as 'Q1' | 'Q2' | 'Q3' | 'Q4', - }); + const card = await createConsolidation(values); setModalOpen(false); + message.success(`Создана консолидация ${values.year} ${values.quarter}`); + navigate(`/groups/${values.company_group_id}?version=${card.id}`); }} />