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:
parent
27828219c9
commit
fec179b747
@ -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={{
|
||||
|
||||
44
src/widgets/ConsoleLegalEntities/normalize.test.ts
Normal file
44
src/widgets/ConsoleLegalEntities/normalize.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@ -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) => ({
|
||||
|
||||
@ -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 && (
|
||||
|
||||
16
src/widgets/NewConsoleModal/preselect.test.ts
Normal file
16
src/widgets/NewConsoleModal/preselect.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
7
src/widgets/NewConsoleModal/preselect.ts
Normal file
7
src/widgets/NewConsoleModal/preselect.ts
Normal 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));
|
||||
}
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user