Merge pull request 'aurora-(1636, 1693): обновление справочника гк и юл и исправлена фильтрация юл без гк' (#201) from feat/companyGroupUpdate into main
This commit is contained in:
commit
ddf69d42c1
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
45
web/src/store/slices/companyGroupsSlice/selectors.test.ts
Normal file
45
web/src/store/slices/companyGroupsSlice/selectors.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
17
web/src/store/slices/companyGroupsSlice/selectors.ts
Normal file
17
web/src/store/slices/companyGroupsSlice/selectors.ts
Normal 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;
|
||||
}
|
||||
@ -5,4 +5,6 @@ export interface CompanyGroupSliceType {
|
||||
raw_rows: ICompanyGroup[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
/** Список справочника показывает архивные ГК вместо действующих; `raw_rows` при этом не меняется. */
|
||||
isGettingArchive: boolean;
|
||||
}
|
||||
|
||||
34
web/src/types/catalogueJur/catalogueJur.test.ts
Normal file
34
web/src/types/catalogueJur/catalogueJur.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
@ -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 = {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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),
|
||||
],
|
||||
};
|
||||
|
||||
@ -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'>
|
||||
|
||||
@ -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' }}>
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user