Merge pull request 'aurora-(1636, 1693): обновление справочника гк и юл и исправлена фильтрация юл без гк' (#201) from feat/companyGroupUpdate into main

This commit is contained in:
aep 2026-10-28 16:29:37 +03:00
commit ddf69d42c1
19 changed files with 396 additions and 74 deletions

View File

@ -1,20 +1,28 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button, Space } from 'antd';
import { useEffect, useState } from 'react';
import { Button, Empty, Space } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { createCatalogueJur } from '../services/api/directories/catalogue_jur';
import { createCompanyGroup, fetchRelatedGroups } from '../services/api/directories/company_groups';
import {
archiveCompanyGroup,
createCompanyGroup,
editCompanyGroup,
fetchRelatedGroups,
unarchiveCompanyGroup,
} from '../services/api/directories/company_groups';
import { createRelatedGroup } from '../services/api/directories/related-groups';
import { setActiveCompanyGroup } from '../store/slices/catalogueJurSlice/catalogueJurSlice';
import { getCatalogueJurThunk } from '../store/slices/catalogueJurSlice/thunks';
import { nextActiveCompanyGroup, visibleCompanyGroups } from '../store/slices/companyGroupsSlice/selectors';
import { getCompanyGroupsThunk } from '../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../store/store';
import type { RelatedGroupOut } from '../types/api';
import type { CatalogueJurFormProps } from '../types/catalogueJur/catalogueJur';
import type { CreateCompanyGroupProps } from '../types/companyGroups/companyGroups';
import type { CompanyGroupFormProps } from '../types/companyGroups/companyGroups';
import type { CreateEntityKind, RelatedGroupFormType } from '../types/relatedGroup/relatedGroup';
import { useNotificationApi } from '../utils/useNotification';
import { ArchiveCompanyGroupModal } from '../widgets/ArchiveCompanyGroupModal/ArchiveCompanyGroupModal';
import { CataloguGroupTabs, type CatalogueGroupTabKey } from '../widgets/CataloguGroupTabs/CataloguGroupTabs';
import { CompanyGroupHeader } from '../widgets/CatalogueGroupHeader/CatalogueGroupHeader';
import { CatalogueJurForm } from '../widgets/CatalogueJurForm/CatalogueJurForm';
@ -34,12 +42,18 @@ export function CatalogueGroup() {
const [entityCreateModalOpen, setEntityCreateModalOpen] = useState(false);
const [kindForm, setKindForm] = useState<CreateEntityKind | null>(null);
const [relatedGroupLoading, setRelatedGroupLoading] = useState(false);
const [companyGroupEditFormOpen, setCompanyGroupEditFormOpen] = useState(false);
const [companyGroupDeleteOpen, setCompanyGroupDeleteOpen] = useState(false);
const [companyGroupDeleteLoading, setCompanyGroupDeleteLoading] = useState(false);
const [activeTab, setActiveTab] = useState<CatalogueGroupTabKey>('legal_entities');
const [gskData, setGskData] = useState<RelatedGroupOut[]>([]);
const [gszData, setGszData] = useState<RelatedGroupOut[]>([]);
const activeCompanyGroup = useAppSelector((state) => state.catalogue_jur.activeCompanyGroup);
const relatedGroupFilter = useAppSelector((state) => state.catalogue_jur.relatedGroupFilter);
const rawRows = useAppSelector((state) => state.company_groups.raw_rows);
const companyGroupsLoading = useAppSelector((state) => state.company_groups.loading);
const isGettingArchiveCompany = useAppSelector((state) => state.company_groups.isGettingArchive);
const visibleGroups = useMemo(() => visibleCompanyGroups(rawRows, isGettingArchiveCompany), [rawRows, isGettingArchiveCompany]);
async function createJurHandler(values: CatalogueJurFormProps) {
await createCatalogueJur(values);
@ -63,12 +77,43 @@ export function CatalogueGroup() {
setKindForm(null);
}
}
async function createCompanyGroupHandler(values: CreateCompanyGroupProps) {
async function createCompanyGroupHandler(values: CompanyGroupFormProps) {
await createCompanyGroup(values);
dispatch(getCompanyGroupsThunk());
getRelatedGroupsHandler();
setKindForm(null);
}
async function editCompanyGroupHandler(values: CompanyGroupFormProps) {
if (activeCompanyGroup?.id) {
await editCompanyGroup(activeCompanyGroup?.id, values);
dispatch(getCompanyGroupsThunk());
setCompanyGroupEditFormOpen(false);
}
}
async function archiveCompanyGroupHandler() {
if (!activeCompanyGroup) {
return;
}
try {
setCompanyGroupDeleteLoading(true);
if (activeCompanyGroup?.is_archived) {
await unarchiveCompanyGroup(activeCompanyGroup.id);
} else {
await archiveCompanyGroup(activeCompanyGroup.id);
}
dispatch(getCompanyGroupsThunk());
setCompanyGroupDeleteOpen(false);
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setCompanyGroupDeleteLoading(false);
}
}
async function getRelatedGroupsHandler() {
if (activeCompanyGroup?.id) {
@ -93,12 +138,18 @@ export function CatalogueGroup() {
getRelatedGroupsHandler();
}, [activeCompanyGroup]);
// Без выбранной ГК таблица ЮЛ не грузится — выбираем первую ГК списка
// Без выбранной ГК таблица ЮЛ не грузится — выбираем первую ГК показанного списка; выбранная, ушедшая в архив
// (или из архива) или скрытая переключением «Архива», заменяется первой показанной, пустой список — без выбора
useEffect(() => {
if (!activeCompanyGroup && rawRows?.length) {
dispatch(setActiveCompanyGroup(rawRows[0]));
// список ГК ещё не загружен — выбор не трогаем (при возврате на экран остаётся прежняя ГК)
if (companyGroupsLoading || rawRows.length === 0) {
return;
}
}, [rawRows, activeCompanyGroup, dispatch]);
const next = nextActiveCompanyGroup(activeCompanyGroup, visibleGroups);
if (next?.id !== activeCompanyGroup?.id) {
dispatch(setActiveCompanyGroup(next));
}
}, [visibleGroups, activeCompanyGroup, companyGroupsLoading, rawRows.length, dispatch]);
useEffect(() => {
setActiveTab('legal_entities');
@ -140,8 +191,13 @@ export function CatalogueGroup() {
flexDirection: 'column',
overflow: 'hidden',
}}>
<CompanyGroupHeader />
{activeTab === 'legal_entities' && (
{!activeCompanyGroup && (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='Группа компаний не выбрана' style={{ marginTop: 64 }} />
)}
{activeCompanyGroup && (
<CompanyGroupHeader setDeleteConfirmOpen={setCompanyGroupDeleteOpen} setEditFormOpen={setCompanyGroupEditFormOpen} />
)}
{activeCompanyGroup && activeTab === 'legal_entities' && (
<CatalogueJurTable
outside_group={false}
tabsComponent={
@ -149,7 +205,7 @@ export function CatalogueGroup() {
}
/>
)}
{(activeTab === 'gsk' || activeTab === 'gsz') && (
{activeCompanyGroup && (activeTab === 'gsk' || activeTab === 'gsz') && (
<RelatedGroupTable
activeTab={activeTab}
data={activeTab === 'gsz' ? gszData : gskData}
@ -200,6 +256,25 @@ export function CatalogueGroup() {
onOk={createCompanyGroupHandler}
/>
)}
{companyGroupEditFormOpen && activeCompanyGroup && (
<CompanyGroupForm
open={companyGroupEditFormOpen}
onCancel={() => {
setCompanyGroupEditFormOpen(false);
}}
onOk={editCompanyGroupHandler}
companyGroupId={activeCompanyGroup.id}
/>
)}
{companyGroupDeleteOpen && activeCompanyGroup && (
<ArchiveCompanyGroupModal
open={companyGroupDeleteOpen}
loading={companyGroupDeleteLoading}
onCancel={() => setCompanyGroupDeleteOpen(false)}
onOk={archiveCompanyGroupHandler}
isArchived={activeCompanyGroup.is_archived}
/>
)}
</div>
);
}

View File

@ -1,11 +1,11 @@
import { api } from '../../../api/client';
import type { LegalEntityBrief, Page, RelatedGroupDetail, RelatedGroupOut } from '../../../types/api';
import type { CreateCompanyGroupProps, ICompanyGroupOut } from '../../../types/companyGroups/companyGroups';
import type { CompanyGroupFormProps, ICompanyGroupOut } from '../../../types/companyGroups/companyGroups';
const CG_PAGE = 200;
export const LE_PAGE = 200;
/** Все видимые ГК — дочитываем страницы, пока не получим total. */
/** Все видимые ГК, в том числе архивные (`is_archived`), — дочитываем страницы, пока не получим total. */
export async function fetchCompanyGroups(): Promise<ICompanyGroupOut[]> {
const all: ICompanyGroupOut[] = [];
for (let offset = 0; ; offset += CG_PAGE) {
@ -37,6 +37,15 @@ export async function fetchAllLegalEntities(groupId: string): Promise<LegalEntit
export const fetchLegalEntity = (id: string) => api.get<LegalEntityBrief>(`/directories/legal-entities/${id}`);
export function createCompanyGroup(body: CreateCompanyGroupProps): Promise<void> {
export function createCompanyGroup(body: CompanyGroupFormProps): Promise<void> {
return api.post<void>('/directories/company-groups', body);
}
export function editCompanyGroup(id: string, body: CompanyGroupFormProps): Promise<void> {
return api.patch<void>(`/directories/company-groups/${id}`, body);
}
export function archiveCompanyGroup(id: string): Promise<void> {
return api.post<void>(`/directories/company-groups/${id}/archive`);
}
export function unarchiveCompanyGroup(id: string): Promise<void> {
return api.post<void>(`/directories/company-groups/${id}/unarchive`);
}

View File

@ -1,4 +1,4 @@
import { createSlice } from '@reduxjs/toolkit';
import { type PayloadAction, createSlice } from '@reduxjs/toolkit';
import { getCompanyGroupsThunk } from './thunks';
import type { CompanyGroupSliceType } from './types';
@ -6,13 +6,18 @@ const initialState: CompanyGroupSliceType = {
raw_rows: [],
total_groups: 0,
loading: false,
isGettingArchive: false,
error: null,
};
const comGroupsSlice = createSlice({
name: 'company_groups',
initialState,
reducers: {},
reducers: {
setIsGettingArchive(state, action: PayloadAction<boolean>) {
state.isGettingArchive = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(getCompanyGroupsThunk.pending, (state) => {
@ -30,4 +35,6 @@ const comGroupsSlice = createSlice({
});
},
});
export const { setIsGettingArchive } = comGroupsSlice.actions;
export default comGroupsSlice.reducer;

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import type { ICompanyGroup } from '../../../types/companyGroups/companyGroups';
import { nextActiveCompanyGroup, visibleCompanyGroups } from './selectors';
const group = (id: string, is_archived: boolean): ICompanyGroup => ({
id,
name: id,
crm_id: null,
base_regional_office_id: null,
base_regional_office: null,
legal_entities_count: 0,
is_archived,
base_regional_office_name: '—',
});
const active1 = group('a1', false);
const active2 = group('a2', false);
const archived1 = group('z1', true);
const rows = [active1, archived1, active2];
describe('visibleCompanyGroups', () => {
it('без «Архива» — только действующие ГК, с «Архивом» — только архивные', () => {
expect(visibleCompanyGroups(rows, false)).toEqual([active1, active2]);
expect(visibleCompanyGroups(rows, true)).toEqual([archived1]);
});
});
describe('nextActiveCompanyGroup', () => {
it('выбранная ГК в показанном списке остаётся', () => {
expect(nextActiveCompanyGroup(active2, [active1, active2])).toBe(active2);
});
it('без выбранной ГК берётся первая показанная', () => {
expect(nextActiveCompanyGroup(null, [active1, active2])).toBe(active1);
});
it('ГК ушла в архив — выбирается первая действующая', () => {
expect(nextActiveCompanyGroup({ ...active1, is_archived: true }, [active2])).toBe(active2);
});
it('пустой список — ГК не выбрана', () => {
expect(nextActiveCompanyGroup(active1, [])).toBeNull();
expect(nextActiveCompanyGroup(null, [])).toBeNull();
});
});

View File

@ -0,0 +1,17 @@
import type { ICompanyGroup } from '../../../types/companyGroups/companyGroups';
/** ГК списка справочника: действующие или, при включённом «Архиве», только архивные. */
export function visibleCompanyGroups(rows: ICompanyGroup[], archived: boolean): ICompanyGroup[] {
return rows.filter((group) => group.is_archived === archived);
}
/**
* Какая ГК должна быть выбрана в справочнике: выбранная — пока она в показанном списке; иначе (ушла в архив, вернулась
* из архива, скрыта переключением «Архива») — первая показанная, а в пустом списке — никакая.
*/
export function nextActiveCompanyGroup(active: ICompanyGroup | null | undefined, visible: ICompanyGroup[]): ICompanyGroup | null {
if (active && visible.some((group) => group.id === active.id)) {
return active;
}
return visible[0] ?? null;
}

View File

@ -5,4 +5,6 @@ export interface CompanyGroupSliceType {
raw_rows: ICompanyGroup[];
loading: boolean;
error: string | null;
/** Список справочника показывает архивные ГК вместо действующих; `raw_rows` при этом не меняется. */
isGettingArchive: boolean;
}

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import { type ICatalogueJur, type MemberShips, toCatalogueJurRow } from './catalogueJur';
const member = (kind: MemberShips['kind']): MemberShips => ({
related_group_id: kind,
code: kind,
kind,
name: kind,
valid_from: new Date('2026-01-01'),
});
const row = (memberships?: MemberShips[]): ICatalogueJur =>
({
id: 'le-1',
inn: '7701000040',
entity_type: 'ЮЛ',
name: 'ООО «Тест»',
company_group_name: null,
closed: false,
regional_office: { id: 'ro-1', name: 'Ростовский РФ', is_head_office: false },
memberships,
}) as ICatalogueJur;
describe('toCatalogueJurRow', () => {
it('имя РФ — плоским полем для поиска и фильтра', () => {
expect(toCatalogueJurRow(row()).regional_name).toBe('Ростовский РФ');
});
it('признаки ГСК и ГСЗ — по видам членства', () => {
expect(toCatalogueJurRow(row([member('gsk')]))).toMatchObject({ in_gsk: true, in_gsz: false });
expect(toCatalogueJurRow(row([member('gsz')]))).toMatchObject({ in_gsk: false, in_gsz: true });
expect(toCatalogueJurRow(row())).toMatchObject({ in_gsk: false, in_gsz: false });
});
});

View File

@ -21,8 +21,19 @@ export interface ICatalogueJur {
name: string;
is_head_office: boolean;
};
/** Плоские поля строки таблицы (`toCatalogueJurRow`): по ним работают поиск, фильтры и колонки GenericTable. */
regional_name?: string;
in_gsk?: boolean;
in_gsz?: boolean;
}
export const toCatalogueJurRow = (row: ICatalogueJur): ICatalogueJur => ({
...row,
regional_name: row.regional_office?.name,
in_gsk: row.memberships?.some((member) => member.kind === 'gsk') ?? false,
in_gsz: row.memberships?.some((member) => member.kind === 'gsz') ?? false,
});
export type KindType = 'gsk' | 'gsz';
export type MemberShips = {

View File

@ -6,6 +6,7 @@ export interface ICompanyGroupOut {
base_regional_office_id: string | null;
base_regional_office: { id: string; name: string; is_head_office: boolean } | null;
legal_entities_count: number;
is_archived: boolean;
}
/** Строка таблицы ГК: имя филиала плоским полем — по нему работают поиск и фильтры GenericTable. */
@ -18,7 +19,7 @@ export const toCompanyGroupRow = (g: ICompanyGroupOut): ICompanyGroup => ({
base_regional_office_name: g.base_regional_office?.name ?? '—',
});
export interface CreateCompanyGroupProps {
export interface CompanyGroupFormProps {
name: string;
crm_id: string;
base_regional_office_id: string | null;

View File

@ -0,0 +1,28 @@
import { Modal } from 'antd';
type Props = {
open: boolean;
onCancel: () => void;
onOk: () => void | Promise<void>;
loading?: boolean;
isArchived: boolean;
};
export const ArchiveCompanyGroupModal: React.FC<Props> = ({ open, onCancel, onOk, loading, isArchived }) => {
return (
<Modal
title={isArchived ? 'Разархивировать ГК' : 'Архивировать ГК'}
open={open}
onCancel={onCancel}
onOk={onOk}
okText='Подтвердить'
cancelText='Отмена'
okButtonProps={isArchived ? undefined : { danger: true }}
confirmLoading={loading}
centered>
<p style={{ margin: 0, color: '#595959' }}>
{isArchived ? 'Группа компаний вернётся из архива в список действующих.' : 'Группа компаний будет перенесена в архив.'}
</p>
</Modal>
);
};

View File

@ -1,33 +1,74 @@
import { Space } from 'antd';
import { CheckCircleOutlined, DeleteOutlined, EditOutlined, EllipsisOutlined, HistoryOutlined } from '@ant-design/icons';
import { Button, Dropdown, Space } from 'antd';
import Text from 'antd/es/typography/Text';
import Title from 'antd/es/typography/Title';
import { useNavigate } from 'react-router-dom';
import { useAppSelector } from '../../store/store';
import { pluralRu } from '../../utils/labels';
export const CompanyGroupHeader = () => {
interface CompanyGroupHeaderI {
setDeleteConfirmOpen: (el: boolean) => void;
setEditFormOpen: (el: boolean) => void;
}
export const CompanyGroupHeader = ({ setEditFormOpen, setDeleteConfirmOpen }: CompanyGroupHeaderI) => {
const activeCompanyGroup = useAppSelector((state) => state.catalogue_jur.activeCompanyGroup);
const count = activeCompanyGroup?.legal_entities_count ?? 0;
const navigate = useNavigate();
const organizationWord = pluralRu(count, ['организация', 'организации', 'организаций']);
const items = [
{
key: 'edit',
icon: <EditOutlined />,
label: 'Изменить ГК',
onClick: () => {
setEditFormOpen(true);
},
},
{
key: 'history',
icon: <HistoryOutlined />,
label: 'История изменений',
onClick: () => navigate('/logging'),
},
{
key: 'delete',
icon: activeCompanyGroup?.is_archived ? <CheckCircleOutlined /> : <DeleteOutlined />,
label: activeCompanyGroup?.is_archived ? 'Разархивировать' : 'Архивировать',
danger: !activeCompanyGroup?.is_archived,
onClick: () => {
setDeleteConfirmOpen(true);
},
},
];
return (
<div
<Space
style={{
padding: '6px 16px',
borderBottom: '1px solid #0f172a0f',
flexShrink: 0,
justifyContent: 'space-between',
}}>
<Title level={5} style={{ margin: 0 }}>
{activeCompanyGroup?.name ?? ''}
</Title>
<Space>
<Text type='secondary' style={{ fontSize: 13 }}>
{activeCompanyGroup?.base_regional_office_name ?? ''}
</Text>
<Text type='secondary' style={{ fontSize: 13 }}>
{count ?? 0} {organizationWord}
</Text>
</Space>
</div>
<div>
<Title level={5} style={{ margin: 0 }}>
{activeCompanyGroup?.name ?? ''}
</Title>
<Space>
<Text type='secondary' style={{ fontSize: 13 }}>
{activeCompanyGroup?.base_regional_office_name ?? ''}
</Text>
<Text type='secondary' style={{ fontSize: 13 }}>
{count ?? 0} {organizationWord}
</Text>
</Space>
</div>
<Dropdown menu={{ items }} trigger={['click']}>
<Button type='default' style={{ width: '32px' }}>
<EllipsisOutlined />
</Button>
</Dropdown>
</Space>
);
};

View File

@ -19,18 +19,19 @@ import { setIsGettingArchive, setRelatedGroupFilter } from '../../store/slices/c
import { getCatalogueJurThunk } from '../../store/slices/catalogueJurSlice/thunks';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type {
CatalogueJurFormProps,
ICatalogueJur,
MoveCatalogueJurProps,
WithdrawCatalogueJurProps,
import {
type CatalogueJurFormProps,
type ICatalogueJur,
type MoveCatalogueJurProps,
type WithdrawCatalogueJurProps,
toCatalogueJurRow,
} from '../../types/catalogueJur/catalogueJur';
import { pluralRu } from '../../utils/labels';
import { useNotificationApi } from '../../utils/useNotification';
import { CatalogueJurForm } from '../CatalogueJurForm/CatalogueJurForm';
import { MoveCatalogueJurForm } from '../MoveCatalogueJurForm/MoveCatalogueJurForm';
import { WithdrawCatalogueJurForm } from '../WithdrawCatalogueJurForm/WithdrawCatalogueJurForm';
import { catalogueJurConfig } from './config';
import { catalogueJurConfig, companyGroupJurConfig } from './config';
interface CatalogueJurTableI {
outside_group: boolean;
@ -52,21 +53,23 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur
const [editCatalogueFormOpen, setEditCatalogueFormOpen] = useState(false);
const [withdrawCatalogueFormOpen, setWithdrawCatalogueFormOpen] = useState(false);
const [jurId, setJurId] = useState('');
const tableConfig = outside_group ? catalogueJurConfig : companyGroupJurConfig;
const filterdData = useMemo(() => {
let newData = rawRows;
if (relatedGroupFilter?.id && !outside_group) {
return rawRows?.filter((row) => {
newData = rawRows?.filter((row) => {
return row?.memberships?.some((member) => member.related_group_id === relatedGroupFilter.id);
});
}
return rawRows;
return newData?.map((elem) => elem && toCatalogueJurRow(elem));
}, [rawRows, relatedGroupFilter, outside_group]);
const { visibleKeys, setVisibleKeys } = useVisibleColumns(catalogueJurConfig);
const { visibleKeys, setVisibleKeys } = useVisibleColumns(tableConfig);
const { rawQuery, setRawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } =
useTableFilters<ICatalogueJur>();
// single filtered array — title count and table share it
const { rows } = useFilteredRows(filterdData, catalogueJurConfig, rawQuery, columnFilters, sort);
const { rows } = useFilteredRows(filterdData, tableConfig, rawQuery, columnFilters, sort);
function getCatalogueJur() {
dispatch(
@ -233,7 +236,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur
<Checkbox checked={isGettingArchive} onChange={(e) => dispatch(setIsGettingArchive(e.target.checked))}>
Архив
</Checkbox>
<ColumnSettings defs={catalogueJurConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
<ColumnSettings defs={tableConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
</Space>
</Space>
{relatedGroupFilter?.id && !outside_group && (
@ -281,7 +284,7 @@ export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJur
<DataTable
sortState={sort}
onSortChange={setSort}
config={catalogueJurConfig}
config={tableConfig}
rows={rows}
allRows={rawRows}
loading={loading}

View File

@ -3,9 +3,10 @@ import { TableTag } from '../../components/TableTag/TableTag';
import { TAG_PRESETS } from '../../components/TableTag/presets';
import type { ICatalogueJur } from '../../types/catalogueJur/catalogueJur';
/** ЮЛ вне ГК (монозаёмщики): в ГСК/ГСЗ они не входят, колонок членства нет. */
export const catalogueJurConfig: TableConfig<ICatalogueJur> = {
rowKey: 'id',
searchableKeys: ['closed', 'inn', 'kpp', 'name', 'entity_type'],
searchableKeys: ['closed', 'inn', 'crm_id', 'kpp', 'name', 'entity_type', 'regional_name'],
columns: [
{
key: 'closed',
@ -43,9 +44,29 @@ export const catalogueJurConfig: TableConfig<ICatalogueJur> = {
},
},
{
key: 'regional_office',
key: 'regional_name',
title: 'Основной РФ',
render: (val) => (val as ICatalogueJur['regional_office'] | null)?.name,
features: { search: true, multiselect: true, sort: true },
},
],
};
const membershipColumn = (key: 'in_gsk' | 'in_gsz', title: string) => ({
key,
title,
render: (value: unknown) => {
const preset = value ? TAG_PRESETS.green : TAG_PRESETS.gray;
return <TableTag text={value ? 'Входит' : 'Не входит'} {...preset} />;
},
});
/** ЮЛ карточки ГК: после статуса — признаки «Входит в ГСК» / «Входит в ГСЗ» (поля строки из `toCatalogueJurRow`). */
export const companyGroupJurConfig: TableConfig<ICatalogueJur> = {
...catalogueJurConfig,
columns: [
catalogueJurConfig.columns[0],
membershipColumn('in_gsk', 'Входит в ГСК'),
membershipColumn('in_gsz', 'Входит в ГСЗ'),
...catalogueJurConfig.columns.slice(1),
],
};

View File

@ -3,26 +3,47 @@ import type React from 'react';
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { useMe } from '../../auth/useMe';
import { useAppSelector } from '../../store/store';
import type { CreateCompanyGroupProps } from '../../types/companyGroups/companyGroups';
import { fetchOneCompanyGroup } from '../../services/api/directories/company_groups';
import type { CompanyGroupFormProps } from '../../types/companyGroups/companyGroups';
import { useNotificationApi } from '../../utils/useNotification';
import { loadOffices } from '../Admin/UsersTable';
type Props = {
open: boolean;
onCancel: () => void;
onOk: (values: CreateCompanyGroupProps) => void | Promise<void>;
onOk: (values: CompanyGroupFormProps) => void | Promise<void>;
companyGroupId?: string;
};
export const CompanyGroupForm: React.FC<Props> = ({ open, onCancel, onOk }) => {
const [form] = Form.useForm<CreateCompanyGroupProps>();
export const CompanyGroupForm: React.FC<Props> = ({ open, onCancel, onOk, companyGroupId }) => {
const [form] = Form.useForm<CompanyGroupFormProps>();
const { me } = useMe();
const notification = useNotificationApi();
const activeCompanyGroup = useAppSelector((state) => state.catalogue_jur.activeCompanyGroup);
const [officesOptions, setOfficesOptions] = useState<{ value: string; label: string }[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const [initialDataLoading, setInitialDataLoading] = useState(false);
async function getInitialData() {
if (companyGroupId) {
setInitialDataLoading(true);
try {
const data = await fetchOneCompanyGroup(companyGroupId);
form.setFieldsValue({
name: data.name,
crm_id: data.crm_id ?? undefined,
base_regional_office_id: data.base_regional_office_id,
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setInitialDataLoading(false);
}
}
}
async function getOfficesOptionsHandler() {
setLoadingOptions(true);
@ -45,6 +66,7 @@ export const CompanyGroupForm: React.FC<Props> = ({ open, onCancel, onOk }) => {
const values = await form.validateFields();
await onOk({
...values,
crm_id: values?.crm_id ?? null,
base_regional_office_id: values?.base_regional_office_id ? values?.base_regional_office_id : (me?.regional_office.id as string),
});
} catch (e) {
@ -72,18 +94,20 @@ export const CompanyGroupForm: React.FC<Props> = ({ open, onCancel, onOk }) => {
};
useEffect(() => {
getInitialData();
getOfficesOptionsHandler();
}, [activeCompanyGroup]);
}, []);
return (
<Modal
title='Создать группу компаний'
title={companyGroupId ? 'Изменить группу компаний' : 'Создать группу компаний'}
open={open}
onCancel={handleCancel}
onOk={handleOk}
okText='Создать'
okText={companyGroupId ? 'Сохранить' : 'Создать'}
cancelText='Отмена'
destroyOnHidden
loading={initialDataLoading}
confirmLoading={confirmLoading}
width={720}>
<Form form={form} layout='vertical'>

View File

@ -1,8 +1,10 @@
import { FolderOpenOutlined, SearchOutlined } from '@ant-design/icons';
import { Empty, Input, List, Spin, Tag, Typography } from 'antd';
import { Checkbox, Empty, Input, List, Spin, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useVirtualList } from '../../hooks/useVirtualList';
import { setActiveCompanyGroup } from '../../store/slices/catalogueJurSlice/catalogueJurSlice';
import { setIsGettingArchive } from '../../store/slices/companyGroupsSlice/companyGroupsSlice';
import { visibleCompanyGroups } from '../../store/slices/companyGroupsSlice/selectors';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
@ -14,14 +16,15 @@ export const CompanyGroupsList = () => {
const rawRows = useAppSelector((state) => state.company_groups.raw_rows);
const loading = useAppSelector((state) => state.company_groups.loading);
const activeCompanyGroup = useAppSelector((state) => state.catalogue_jur.activeCompanyGroup);
const isGettingArchiveCompany = useAppSelector((state) => state.company_groups.isGettingArchive);
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const rows = visibleCompanyGroups(rawRows, isGettingArchiveCompany);
const q = query.trim().toLowerCase();
if (!q) return rawRows;
return rawRows.filter((g) => g.name.toLowerCase().includes(q) || g.crm_id?.toLowerCase().includes(q));
}, [rawRows, query]);
if (!q) return rows;
return rows.filter((g) => g.name.toLowerCase().includes(q) || g.crm_id?.toLowerCase().includes(q));
}, [rawRows, isGettingArchiveCompany, query]);
const { ref, onScroll, visibleItems, totalHeight, offsetY } = useVirtualList(filtered, {
itemHeight: 56,
@ -33,12 +36,6 @@ export const CompanyGroupsList = () => {
dispatch(getCompanyGroupsThunk());
}, [dispatch]);
useEffect(() => {
if (!activeCompanyGroup && rawRows?.length) {
dispatch(setActiveCompanyGroup(rawRows?.[0]));
}
}, [rawRows, activeCompanyGroup, dispatch]);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 24 }}>
@ -57,14 +54,18 @@ export const CompanyGroupsList = () => {
borderRight: '1px solid #f0f0f0',
borderRadius: 12,
}}>
<div style={{ padding: 12 }}>
<div style={{ padding: 12, display: 'flex', alignItems: 'center', gap: 12 }}>
<Input
allowClear
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
placeholder='Поиск по ГК или CRM ID'
value={query}
onChange={(e) => setQuery(e.target.value)}
style={{ flex: 1 }}
/>
<Checkbox checked={isGettingArchiveCompany} onChange={(e) => dispatch(setIsGettingArchive(e.target.checked))}>
Архив
</Checkbox>
</div>
<div ref={ref} onScroll={onScroll} style={{ flex: 1, overflowY: 'auto', padding: '0 8px 8px' }}>

View File

@ -109,7 +109,7 @@ export const MoveCatalogueJurForm: React.FC<Props> = ({ open, onCancel, onOk, ju
showSearch
allowClear
options={raw_rows
.filter((row) => row.id !== form.getFieldValue('old_company_group_id'))
.filter((row) => !row.is_archived && row.id !== form.getFieldValue('old_company_group_id'))
.map((g) => ({ value: g.id, label: g.name }))}
notFoundContent={loading ? 'Загрузка' : 'Нет вариантов'}
optionFilterProp='label'

View File

@ -167,7 +167,7 @@ export const NewConsolidationModal: React.FC<Props> = ({ open, onCancel, onOk })
showSearch
allowClear
filterOption={(input, option) => (option?.label as string)?.toLowerCase().includes(input.toLowerCase())}
options={raw_rows.map((g) => ({ value: g.id, label: g.name }))}
options={raw_rows.filter((g) => !g.is_archived).map((g) => ({ value: g.id, label: g.name }))}
onChange={handleGroupChange}
/>
</Form.Item>

View File

@ -39,6 +39,7 @@ export const RelatedGroupForm: React.FC<Props> = ({ open, onCancel, onOk, relate
try {
const data = await fetchRelatedGroup(relatedGroupId);
await dispatch(getCompanyGroupsThunk());
form.setFieldsValue({
crm_id: data?.crm_id,
name: data?.name,

View File

@ -1,4 +1,4 @@
import { EllipsisOutlined } from '@ant-design/icons';
import { DeleteOutlined, EditOutlined, EllipsisOutlined } from '@ant-design/icons';
import { Button, Dropdown, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { type ReactNode, useMemo, useState } from 'react';
@ -77,6 +77,7 @@ export function RelatedGroupTable({ tabsComponent, data, loading, getData, activ
const items = [
{
key: 'edit',
icon: <EditOutlined />,
label: 'Изменить',
onClick: () => {
setGroupId(row.id);
@ -85,6 +86,7 @@ export function RelatedGroupTable({ tabsComponent, data, loading, getData, activ
},
{
key: 'delete',
icon: <DeleteOutlined />,
label: 'Удалить',
danger: true,
onClick: () => {