feat: модалка консолидации — предотметка ГСК, все ЮЛ, ошибки, переход к версии (#3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 01:05:57 +04:00
parent 27828219c9
commit fec179b747
7 changed files with 150 additions and 80 deletions

View File

@ -1,5 +1,7 @@
import { message } from 'antd';
import { useEffect, useState } from 'react'; 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 PageTitle from '../components/PageTitle';
import { fetchOneCompanyGroup } from '../services/api/directories/company_groups'; import { fetchOneCompanyGroup } from '../services/api/directories/company_groups';
import { setSelectedVersion } from '../store/slices/consolidations/consolidationsSlice'; import { setSelectedVersion } from '../store/slices/consolidations/consolidationsSlice';
@ -12,6 +14,8 @@ import { DocumentsDrawer } from '../widgets/FilesDrawer/FilesDrawer';
export function ConsolidationPage() { export function ConsolidationPage() {
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const { group_id } = useParams(); const { group_id } = useParams();
const [searchParams] = useSearchParams();
const versionParam = searchParams.get('version');
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const [comGroupName, setComGroupName] = useState(''); const [comGroupName, setComGroupName] = useState('');
@ -21,17 +25,22 @@ export function ConsolidationPage() {
useEffect(() => { useEffect(() => {
if (!group_id) return; if (!group_id) return;
async function getAllInfo(id: string) { async function getAllInfo(id: string) {
const data = await fetchOneCompanyGroup(id); try {
setComGroupName(data.name); const data = await fetchOneCompanyGroup(id);
setLocationPath(`Отчетность>${data.name}>Консолидации`); setComGroupName(data.name);
setDesc(`${data.legal_entities_count} организаций`); setLocationPath(`Отчетность>${data.name}>Консолидации`);
dispatch(getConsolidationsListThunk(id)); 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); getAllInfo(group_id);
return () => { return () => {
dispatch(setSelectedVersion(null)); dispatch(setSelectedVersion(null));
}; };
}, [dispatch, group_id]); }, [dispatch, group_id, versionParam]);
return ( return (
<div <div
style={{ style={{

View File

@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import type { IVersionCard } from '../../types/consolidations/version_members';
import { normalizeData } from './normalize';
const card = {
id: 'v1',
members: [
{
id: 'le1',
name: 'АО "Русский бисквит"',
inn: '3528052718',
is_head_entity: true,
entity_type: 'ЮЛ',
osv_accounts: ['62', '60'],
checklist: [
{ document_type: 'BALANCE', account: null, status: 'NEEDS_CHECK', missing_quarters: [], document_ids: ['d1'] },
{ document_type: 'INCOME', account: null, status: 'MISSING', missing_quarters: [], document_ids: [] },
{ document_type: 'OSV', account: '62', status: 'CONFIRMED', missing_quarters: [], document_ids: ['d2'] },
{ document_type: 'OSV', account: '60', status: 'PROCESSING', missing_quarters: [], document_ids: [] },
{ document_type: 'OSV', account: '58', status: 'MISSING', missing_quarters: ['Q1'], document_ids: [] },
],
},
],
} as unknown as IVersionCard;
describe('normalizeData', () => {
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([]);
});
});

View File

@ -1,8 +1,8 @@
import { ArrowRightOutlined, DownOutlined, IdcardOutlined, MoreOutlined, RightOutlined } from '@ant-design/icons'; import { ArrowRightOutlined, DownOutlined, MoreOutlined, RightOutlined } from '@ant-design/icons';
import { Button, Dropdown, Space } from 'antd'; import { Button, Dropdown, Space, message } from 'antd';
import type { ColumnsType, TableProps } from 'antd/es/table'; import type { ColumnsType, TableProps } from 'antd/es/table';
import { memo, useCallback, useMemo } from 'react'; 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 { ColumnSettings, DataTable, type TableConfig, isGroup, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters'; import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice'; import { setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice';
@ -44,6 +44,7 @@ const RowActions = memo(function RowActions({
}); });
export function ConsoleTableWidget() { export function ConsoleTableWidget() {
const navigate = useNavigate(); const navigate = useNavigate();
const { group_id } = useParams();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const consolidations = useAppSelector((s) => s.consolidations.consolidations); const consolidations = useAppSelector((s) => s.consolidations.consolidations);
const loading = useAppSelector((s) => s.consolidations.loading); const loading = useAppSelector((s) => s.consolidations.loading);
@ -109,7 +110,7 @@ export function ConsoleTableWidget() {
if (key === 'history') { if (key === 'history') {
navigate('/logging'); navigate('/logging');
} else { } else {
alert('Не реализовано'); message.info('Будет позже');
} }
}, },
[navigate], [navigate],
@ -122,10 +123,7 @@ export function ConsoleTableWidget() {
width: 120, width: 120,
render: (_: unknown, row: ConsRow) => ( render: (_: unknown, row: ConsRow) => (
<div style={{ display: 'flex' }}> <div style={{ display: 'flex' }}>
<Button type='default' icon={<IdcardOutlined />} onClick={() => alert('Не реализован')}> <Button type='default' onClick={() => navigate(`/groups/${group_id}/versions/${row.id}/osv`)}>
Взять в работу
</Button>
<Button type='default' onClick={() => navigate(`/cons/${row.con_id}/versions/${row.id}`)}>
К отчетности <ArrowRightOutlined /> К отчетности <ArrowRightOutlined />
</Button> </Button>
<RowActions row={row} onAction={handleDropdownAction} /> <RowActions row={row} onAction={handleDropdownAction} />
@ -133,7 +131,7 @@ export function ConsoleTableWidget() {
), ),
}, },
], ],
[navigate], [navigate, group_id, handleDropdownAction],
); );
const onRow: TableProps<ConsRow>['onRow'] = (row) => ({ const onRow: TableProps<ConsRow>['onRow'] = (row) => ({

View File

@ -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 type React from 'react';
import { useEffect, useMemo, useRef, useState } 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 { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store'; import { useAppDispatch, useAppSelector } from '../../store/store';
import type { LegalEntityBrief, RelatedGroupOut } from '../../types/api'; import type { ConsolidationCreate, LegalEntityBrief, Quarter, RelatedGroupOut } from '../../types/api';
import { preselectMembers } from './preselect';
type FormValues = {
company_group_id: string;
related_group_id: string;
year: number;
quarter: string; // example of 2nd static field
legal_entity_ids: string[];
};
type Props = { type Props = {
open: boolean; open: boolean;
onCancel: () => void; onCancel: () => void;
onOk: (values: FormValues) => void | Promise<void>; onOk: (values: ConsolidationCreate) => Promise<void>;
}; };
export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => { export const NewConsolidationModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
const [form] = Form.useForm<FormValues>(); const [form] = Form.useForm<ConsolidationCreate>();
const dispatch = useAppDispatch(); const dispatch = useAppDispatch();
const { raw_rows, loading } = useAppSelector((state) => state.company_groups); const { raw_rows, loading } = useAppSelector((state) => state.company_groups);
@ -33,6 +27,8 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
const [legEntities, setLegEntities] = useState<LegalEntityBrief[]>([]); const [legEntities, setLegEntities] = useState<LegalEntityBrief[]>([]);
const [loadingEntities, setLoadingEntities] = useState(false); const [loadingEntities, setLoadingEntities] = useState(false);
const legEntReqRef = useRef(0); const legEntReqRef = useRef(0);
const relatedReqRef = useRef(0);
const [submitting, setSubmitting] = useState(false);
const [legEntSearch, setLegEntSearch] = useState(''); const [legEntSearch, setLegEntSearch] = useState('');
@ -42,7 +38,7 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
const semester = Form.useWatch('quarter', form); const semester = Form.useWatch('quarter', form);
const legEntIds = Form.useWatch('legal_entity_ids', form); const legEntIds = Form.useWatch('legal_entity_ids', form);
const quarterOptions = [ const quarterOptions: { value: Quarter; label: string }[] = [
{ value: 'Q1', label: '1 квартал' }, { value: 'Q1', label: '1 квартал' },
{ value: 'Q2', label: '2 квартал' }, { value: 'Q2', label: '2 квартал' },
{ value: 'Q3', label: '3 квартал' }, { value: 'Q3', label: '3 квартал' },
@ -71,37 +67,6 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
legEntReqRef.current++; 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) => { const handleGroupChange = async (value?: string) => {
form.setFieldsValue({ form.setFieldsValue({
related_group_id: undefined, related_group_id: undefined,
@ -115,17 +80,38 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
setLoadingSubgroups(true); setLoadingSubgroups(true);
setLoadingEntities(true); setLoadingEntities(true);
try { try {
const dataSubgroups = await fetchRelatedGroups(value); const [dataSubgroups, dataLegEnts] = await Promise.all([fetchRelatedGroups(value), fetchAllLegalEntities(value)]);
const dataLegEnts = await fetchAllLegalEntities(value); if (reqId === subgroupReqRef.current) setSubgroups(dataSubgroups.filter((g) => g.kind === 'gsk'));
if (reqId === subgroupReqRef.current) setSubgroups(dataSubgroups);
if (leReqId === legEntReqRef.current) setLegEntities(dataLegEnts); if (leReqId === legEntReqRef.current) setLegEntities(dataLegEnts);
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось загрузить данные ГК');
} finally { } finally {
if (reqId === subgroupReqRef.current) setLoadingSubgroups(false); if (reqId === subgroupReqRef.current) setLoadingSubgroups(false);
if (leReqId === legEntReqRef.current) setLoadingEntities(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 = () => { const resetLocal = () => {
relatedReqRef.current++;
setSubgroups([]); setSubgroups([]);
setLoadingSubgroups(false); setLoadingSubgroups(false);
subgroupReqRef.current++; subgroupReqRef.current++;
@ -134,13 +120,21 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
}; };
const handleOk = async () => { const handleOk = async () => {
let values: ConsolidationCreate;
try {
values = await form.validateFields();
} catch {
return; // ошибки полей antd показывает сам
}
setSubmitting(true);
try { try {
const values = await form.validateFields();
await onOk(values); await onOk(values);
form.resetFields(); form.resetFields();
resetLocal(); resetLocal();
} catch { } catch (e: unknown) {
// ошибка валидации или запроса: форма остаётся открытой, пользователь исправит данные message.error(e instanceof ApiError ? e.message : 'Не удалось создать консолидацию');
} finally {
setSubmitting(false);
} }
}; };
@ -163,10 +157,12 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
onCancel={handleCancel} onCancel={handleCancel}
onOk={handleOk} onOk={handleOk}
okButtonProps={{ disabled: isSubmitDisabled }} okButtonProps={{ disabled: isSubmitDisabled }}
okText='Submit' okText='Создать'
cancelText='Отмена'
confirmLoading={submitting}
destroyOnHidden destroyOnHidden
width={640}> width={640}>
<Form form={form} layout='vertical' preserve={false} initialValues={{ legEntIds: [] }}> <Form form={form} layout='vertical' preserve={false} initialValues={{ legal_entity_ids: [] }}>
<Row gutter={16}> <Row gutter={16}>
<Col span={12}> <Col span={12}>
<Form.Item name='company_group_id' label='Группа компаний' rules={[{ required: true, message: 'Укажите ГК' }]}> <Form.Item name='company_group_id' label='Группа компаний' rules={[{ required: true, message: 'Укажите ГК' }]}>
@ -191,7 +187,7 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
allowClear allowClear
filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())} filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
options={subgroups.map((s) => ({ value: s.id, label: s.name }))} options={subgroups.map((s) => ({ value: s.id, label: s.name }))}
// onChange={handleSubGroupChange} onChange={handleRelatedGroupChange}
/> />
</Form.Item> </Form.Item>
</Col> </Col>
@ -235,7 +231,7 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
{!loadingEntities && filteredLegEnts.length === 0 && ( {!loadingEntities && filteredLegEnts.length === 0 && (
<Empty <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE} image={Empty.PRESENTED_IMAGE_SIMPLE}
description={legEntities.length === 0 ? 'Нет организаций' : 'Ничего не найдено'} description={legEntities.length === 0 ? 'Нет ЮЛ в ГК' : 'Ничего не найдено'}
/> />
)} )}
{!loadingEntities && filteredLegEnts.length > 0 && ( {!loadingEntities && filteredLegEnts.length > 0 && (

View File

@ -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([]);
});
});

View File

@ -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));
}

View File

@ -9,12 +9,12 @@ import {
TeamOutlined, TeamOutlined,
UserOutlined, UserOutlined,
} from '@ant-design/icons'; } 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 { 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 { USERS_ADMIN } from '../../auth/permissions';
import { createConsolidation } from '../../services/api/consolidations'; import { createConsolidation } from '../../services/api/consolidations';
import { MyModal } from '../NewConsoleModal/NewConsoleModal'; import { NewConsolidationModal } from '../NewConsoleModal/NewConsoleModal';
import styles from './styles.module.css'; import styles from './styles.module.css';
type CollapsedIconProps = { type CollapsedIconProps = {
@ -162,6 +162,7 @@ const Section = memo(function Section({
}); });
export function Sidebar() { export function Sidebar() {
const navigate = useNavigate();
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
@ -207,17 +208,16 @@ export function Sidebar() {
<Section key={g.key} group={g} isCollapsed={isCollapsed} /> <Section key={g.key} group={g} isCollapsed={isCollapsed} />
))} ))}
</div> </div>
<MyModal <NewConsolidationModal
open={modalOpen} open={modalOpen}
onCancel={() => { onCancel={() => {
setModalOpen(false); setModalOpen(false);
}} }}
onOk={async (values) => { onOk={async (values) => {
await createConsolidation({ const card = await createConsolidation(values);
...values,
quarter: values.quarter as 'Q1' | 'Q2' | 'Q3' | 'Q4',
});
setModalOpen(false); setModalOpen(false);
message.success(`Создана консолидация ${values.year} ${values.quarter}`);
navigate(`/groups/${values.company_group_id}?version=${card.id}`);
}} }}
/> />
</Layout.Sider> </Layout.Sider>