Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
291 lines
9.9 KiB
TypeScript
291 lines
9.9 KiB
TypeScript
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 { 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 { ConsolidationCreate, LegalEntityBrief, Quarter, RelatedGroupOut } from '../../types/api';
|
||
import { preselectMembers } from './preselect';
|
||
|
||
type Props = {
|
||
open: boolean;
|
||
onCancel: () => void;
|
||
onOk: (values: ConsolidationCreate) => Promise<void>;
|
||
};
|
||
|
||
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);
|
||
|
||
const [subgroups, setSubgroups] = useState<RelatedGroupOut[]>([]);
|
||
const [loadingSubgroups, setLoadingSubgroups] = useState(false);
|
||
const subgroupReqRef = useRef(0);
|
||
|
||
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('');
|
||
|
||
const company_group_id = Form.useWatch('company_group_id', form);
|
||
const related_group_id = Form.useWatch('related_group_id', form);
|
||
const year = Form.useWatch('year', form);
|
||
const semester = Form.useWatch('quarter', form);
|
||
const legEntIds = Form.useWatch('legal_entity_ids', form);
|
||
|
||
const quarterOptions: { value: Quarter; label: string }[] = [
|
||
{ value: 'Q1', label: '1 квартал' },
|
||
{ value: 'Q2', label: '2 квартал' },
|
||
{ value: 'Q3', label: '3 квартал' },
|
||
{ value: 'Q4', label: '4 квартал' },
|
||
];
|
||
useEffect(() => {
|
||
if (open && raw_rows.length === 0 && !loading) {
|
||
dispatch(getCompanyGroupsThunk());
|
||
}
|
||
}, [open, raw_rows.length, loading, dispatch]);
|
||
|
||
const isSubmitDisabled = !company_group_id || !related_group_id || !year || !semester || !legEntIds?.length;
|
||
|
||
const yearOptions = useMemo(() => {
|
||
const y = new Date().getFullYear();
|
||
return Array.from({ length: 4 }, (_, i) => ({
|
||
value: y - i,
|
||
label: String(y - i),
|
||
}));
|
||
}, []);
|
||
|
||
const clearLegEnts = () => {
|
||
relatedReqRef.current++; // сменили ГК: ответ по старой ГСК больше не нужен
|
||
setLegEntities([]);
|
||
setLoadingEntities(false);
|
||
setLegEntSearch('');
|
||
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;
|
||
const leReqId = ++legEntReqRef.current;
|
||
setLoadingSubgroups(true);
|
||
setLoadingEntities(true);
|
||
try {
|
||
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++;
|
||
clearLegEnts();
|
||
setLegEntSearch('');
|
||
};
|
||
|
||
const handleOk = async () => {
|
||
let values: ConsolidationCreate;
|
||
try {
|
||
values = await form.validateFields();
|
||
} catch {
|
||
return; // ошибки полей antd показывает сам
|
||
}
|
||
setSubmitting(true);
|
||
try {
|
||
await onOk(values);
|
||
form.resetFields();
|
||
resetLocal();
|
||
} catch (e: unknown) {
|
||
message.error(e instanceof ApiError ? e.message : 'Не удалось создать консолидацию');
|
||
} finally {
|
||
setSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const handleCancel = () => {
|
||
form.resetFields();
|
||
resetLocal();
|
||
onCancel();
|
||
};
|
||
|
||
const filteredLegEnts = useMemo(() => {
|
||
if (!legEntSearch) return legEntities;
|
||
const q = legEntSearch.toLowerCase();
|
||
return legEntities.filter((s) => s.name.toLowerCase().includes(q));
|
||
}, [legEntities, legEntSearch]);
|
||
|
||
return (
|
||
<Modal
|
||
title='Новая консолидация'
|
||
open={open}
|
||
onCancel={handleCancel}
|
||
onOk={handleOk}
|
||
okButtonProps={{ disabled: isSubmitDisabled }}
|
||
okText='Создать'
|
||
cancelText='Отмена'
|
||
confirmLoading={submitting}
|
||
destroyOnHidden
|
||
width={640}>
|
||
<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: 'Укажите ГК' }]}>
|
||
<Select
|
||
placeholder='Выберите ГК'
|
||
loading={loading}
|
||
showSearch
|
||
allowClear
|
||
filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
|
||
options={raw_rows.map((g) => ({ value: g.id, label: g.name }))}
|
||
onChange={handleGroupChange}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name='related_group_id' label='ГСК' rules={[{ required: true, message: 'Укажите ГСК' }]}>
|
||
<Select
|
||
placeholder={company_group_id ? 'Выберите ГСК' : 'Сначала выберите ГК'}
|
||
disabled={!company_group_id}
|
||
loading={loadingSubgroups}
|
||
showSearch
|
||
allowClear
|
||
filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
|
||
options={subgroups.map((s) => ({ value: s.id, label: s.name }))}
|
||
onChange={handleRelatedGroupChange}
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
|
||
<Col span={12}>
|
||
<Form.Item name='year' label='Год' rules={[{ required: true, message: 'Укажите год' }]}>
|
||
<Select placeholder='Выберите год' options={yearOptions} />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={12}>
|
||
<Form.Item name='quarter' label='Квартал' rules={[{ required: true, message: 'Укажите квартал' }]}>
|
||
<Select placeholder='Выберите квартал' options={quarterOptions} />
|
||
</Form.Item>
|
||
</Col>
|
||
|
||
{/* ROW 3 - full width block, only after subgroup is selected */}
|
||
{related_group_id && (
|
||
<Col span={24}>
|
||
<Form.Item name='legal_entity_ids' label='ЮЛ консолидации' rules={[{ required: true, message: 'Выберите хотя бы одно ЮЛ' }]}>
|
||
<Checkbox.Group style={{ width: '100%' }}>
|
||
<div
|
||
style={{
|
||
border: '1px solid #d9d9d9',
|
||
borderRadius: 8,
|
||
padding: 12,
|
||
width: '100%',
|
||
}}>
|
||
<Input.Search
|
||
placeholder='Поиск ЮЛ...'
|
||
allowClear
|
||
value={legEntSearch}
|
||
onChange={(e) => setLegEntSearch(e.target.value)}
|
||
style={{ marginBottom: 12 }}
|
||
/>
|
||
|
||
{loadingEntities ? (
|
||
<div style={{ textAlign: 'center', padding: 24 }}>
|
||
<Spin />
|
||
</div>
|
||
) : null}
|
||
{!loadingEntities && filteredLegEnts.length === 0 && (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description={legEntities.length === 0 ? 'Нет ЮЛ в ГК' : 'Ничего не найдено'}
|
||
/>
|
||
)}
|
||
{!loadingEntities && filteredLegEnts.length > 0 && (
|
||
<div
|
||
style={{
|
||
maxHeight: 200,
|
||
overflowY: 'auto',
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
width: '100%',
|
||
gap: 8,
|
||
}}>
|
||
{filteredLegEnts.map((s) => (
|
||
<Checkbox key={s.id} value={s.id} style={{ width: '100%', display: 'flex' }}>
|
||
<div
|
||
style={{
|
||
flex: 1,
|
||
justifySelf: 'flex-start',
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
width: '100%',
|
||
gap: '30px',
|
||
}}>
|
||
<span style={{ display: 'inline-block' }}>{s.name}</span>
|
||
<span
|
||
style={{
|
||
display: 'inline-block',
|
||
color: 'rgb(102, 112, 133)',
|
||
fontSize: 11,
|
||
}}>
|
||
{s.inn}
|
||
</span>
|
||
</div>
|
||
</Checkbox>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{!loadingEntities && legEntities.length > 0 && (
|
||
<div style={{ marginTop: 8, display: 'flex', gap: 12 }}>
|
||
<span style={{ marginLeft: 'auto', color: '#8c8c8c' }}>
|
||
Выбрано: {legEntIds?.length ?? 0} / {legEntities.length}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Checkbox.Group>
|
||
</Form.Item>
|
||
</Col>
|
||
)}
|
||
</Row>
|
||
</Form>
|
||
</Modal>
|
||
);
|
||
};
|