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 { 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 (
<div
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 { 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) => (
<div style={{ display: 'flex' }}>
<Button type='default' icon={<IdcardOutlined />} onClick={() => alert('Не реализован')}>
Взять в работу
</Button>
<Button type='default' onClick={() => navigate(`/cons/${row.con_id}/versions/${row.id}`)}>
<Button type='default' onClick={() => navigate(`/groups/${group_id}/versions/${row.id}/osv`)}>
К отчетности <ArrowRightOutlined />
</Button>
<RowActions row={row} onAction={handleDropdownAction} />
@ -133,7 +131,7 @@ export function ConsoleTableWidget() {
),
},
],
[navigate],
[navigate, group_id, handleDropdownAction],
);
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 { 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<void>;
onOk: (values: ConsolidationCreate) => Promise<void>;
};
export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
const [form] = Form.useForm<FormValues>();
export const NewConsolidationModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
const [form] = Form.useForm<ConsolidationCreate>();
const dispatch = useAppDispatch();
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 [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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ open, onCancel, onOk }) => {
onCancel={handleCancel}
onOk={handleOk}
okButtonProps={{ disabled: isSubmitDisabled }}
okText='Submit'
okText='Создать'
cancelText='Отмена'
confirmLoading={submitting}
destroyOnHidden
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}>
<Col span={12}>
<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
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}
/>
</Form.Item>
</Col>
@ -235,7 +231,7 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
{!loadingEntities && filteredLegEnts.length === 0 && (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={legEntities.length === 0 ? 'Нет организаций' : 'Ничего не найдено'}
description={legEntities.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,
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() {
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
))}
</div>
<MyModal
<NewConsolidationModal
open={modalOpen}
onCancel={() => {
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}`);
}}
/>
</Layout.Sider>