added company group table #31

Merged
aep merged 13 commits from feat/legalEntities into test 2026-10-19 10:35:19 +03:00
41 changed files with 2479 additions and 33 deletions

View File

@ -1,5 +1,6 @@
import './App.css'; import './App.css';
import { Layout } from 'antd'; import { Layout } from 'antd';
import { App as AntApp } from 'antd';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { AuthGate } from './auth/AuthGate'; import { AuthGate } from './auth/AuthGate';
@ -24,6 +25,7 @@ import { Sidebar } from './widgets/Sidebar/Sidebar';
function App() { function App() {
return ( return (
<Provider store={store}> <Provider store={store}>
<AntApp>
<BrowserRouter basename={Config.APP_ADDRESS}> <BrowserRouter basename={Config.APP_ADDRESS}>
<AuthGate> <AuthGate>
<Layout hasSider={true} style={{ height: '100vh' }}> <Layout hasSider={true} style={{ height: '100vh' }}>
@ -48,6 +50,7 @@ function App() {
</Layout> </Layout>
</AuthGate> </AuthGate>
</BrowserRouter> </BrowserRouter>
</AntApp>
</Provider> </Provider>
); );
} }

View File

@ -28,6 +28,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
function buildQuery(params?: object): string {
if (!params) return '';
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
search.append(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : '';
}
/** /**
* Тело ошибки бэкенда — `{"detail": …}` в одной из трёх форм: * Тело ошибки бэкенда — `{"detail": …}` в одной из трёх форм:
* строка (HTTPException) → message; объект `{code, message}` (доменная ошибка) → code + message; * строка (HTTPException) → message; объект `{code, message}` (доменная ошибка) → code + message;
@ -127,7 +138,7 @@ function withJsonBody(method: string, body: unknown): RequestInit {
} }
export const api = { export const api = {
get: <T>(path: string) => apiFetch<T>(path), get: <T>(path: string, params?: object) => apiFetch<T>(`${path}${buildQuery(params)}`),
post: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('POST', body)), post: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('POST', body)),
patch: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PATCH', body)), patch: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PATCH', body)),
put: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PUT', body)), put: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PUT', body)),

View File

@ -39,4 +39,9 @@ export const TAG_PRESETS = {
borderColor: 'rgba(245, 158, 11, 0.25)', borderColor: 'rgba(245, 158, 11, 0.25)',
color: 'rgb(146, 64, 14)', color: 'rgb(146, 64, 14)',
}, },
gray: {
color: 'rgb(71, 84, 103)',
bgColor: 'rgb(248, 250, 252)',
borderColor: 'rgba(15, 23, 42, 0.08)',
},
} as const; } as const;

View File

@ -0,0 +1,46 @@
import { useCallback, useMemo, useRef, useState } from 'react';
type Options = {
/** высота одного элемента в px (фиксированная) */
itemHeight: number;
/** высота контейнера в px (видимая область) */
containerHeight: number;
/** сколько элементов рендерить дополнительно сверху/снизу */
overscan?: number;
};
export function useVirtualList<T>(items: T[], options: Options) {
const { itemHeight, containerHeight, overscan = 5 } = options;
const [scrollTop, setScrollTop] = useState(0);
const ref = useRef<HTMLDivElement | null>(null);
const totalHeight = items.length * itemHeight;
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const endIndex = Math.min(items.length, Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan);
const visibleItems = useMemo(
() =>
items.slice(startIndex, endIndex).map((item, i) => ({
item,
index: startIndex + i,
})),
[items, startIndex, endIndex],
);
const offsetY = startIndex * itemHeight;
const onScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
setScrollTop(e.currentTarget.scrollTop);
}, []);
return {
ref,
onScroll,
visibleItems,
totalHeight,
offsetY,
};
}

View File

@ -1,8 +1,14 @@
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import './index.css'; import './index.css';
import { ConfigProvider } from 'antd'; import { ConfigProvider } from 'antd';
import locale from 'antd/locale/ru_RU';
import dayjs from 'dayjs';
import App from './App'; import App from './App';
import 'dayjs/locale/ru';
dayjs.locale('ru');
const rootElement = document.getElementById('root'); const rootElement = document.getElementById('root');
if (!rootElement) { if (!rootElement) {
throw new Error('Не найден корневой элемент #root'); throw new Error('Не найден корневой элемент #root');
@ -11,6 +17,7 @@ if (!rootElement) {
createRoot(rootElement).render( createRoot(rootElement).render(
// <StrictMode> // <StrictMode>
<ConfigProvider <ConfigProvider
locale={locale}
theme={{ theme={{
token: { token: {
colorPrimary: 'rgba(46, 166, 75, 0.8)', colorPrimary: 'rgba(46, 166, 75, 0.8)',

View File

@ -1,3 +1,236 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button, Space } from 'antd';
import { useEffect, 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 { createRelatedGroup } from '../services/api/directories/related-groups';
import { getCatalogueJurThunk } from '../store/slices/catalogueJurSlice/thunks';
import { setActiveCompanyGroup } from '../store/slices/companyGroupsSlice/companyGroupsSlice';
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 { CreateEntityKind, RelatedGroupFormType } from '../types/relatedGroup/relatedGroup';
import { useNotificationApi } from '../utils/useNotification';
import { CataloguGroupTabs, type CatalogueGroupTabKey } from '../widgets/CataloguGroupTabs/CataloguGroupTabs';
import { CompanyGroupHeader } from '../widgets/CatalogueGroupHeader/CatalogueGroupHeader';
import { CatalogueJurForm } from '../widgets/CatalogueJurForm/CatalogueJurForm';
import { CatalogueJurTable } from '../widgets/CatalogueJurTable/CatalogueJurTable';
import { CompanyGroupForm } from '../widgets/CompanyGroupForm/CompanyGroupForm';
import { CompanyGroupsList } from '../widgets/CompanyGroupsList/CompanyGroupsList';
import { CreateEntityPickerModal } from '../widgets/CreateEntityPickerModal/CreateEntityPickerModal';
import { RelatedGroupForm } from '../widgets/RelatedGroupForm/RelatedGroupForm';
import { RelatedGroupTable } from '../widgets/RelatedGroupTable/RelatedGroupTable';
export function CatalogueGroup() { export function CatalogueGroup() {
return <h3>ГК и организации</h3>; const dispatch = useAppDispatch();
const notification = useNotificationApi();
const isGettingArchive = useAppSelector((state) => state.catalogue_jur.isGettingArchive);
const [entityCreateModalOpen, setEntityCreateModalOpen] = useState(false);
const [kindForm, setKindForm] = useState<CreateEntityKind | null>(null);
const [relatedGroupLoading, setRelatedGroupLoading] = useState(false);
const [activeTab, setActiveTab] = useState<CatalogueGroupTabKey>('legal_entities');
const [gskData, setGskData] = useState<RelatedGroupOut[]>([]);
const [gszData, setGszData] = useState<RelatedGroupOut[]>([]);
const activeCompanyGroup = useAppSelector((state) => state.company_groups.activeCompanyGroup);
const relatedGroupFilter = useAppSelector((state) => state.catalogue_jur.relatedGroupFilter);
const rawRows = useAppSelector((state) => state.company_groups.raw_rows);
async function createJurHandler(values: CatalogueJurFormProps) {
try {
await createCatalogueJur(values);
dispatch(getCompanyGroupsThunk());
dispatch(
getCatalogueJurThunk({
closed: isGettingArchive,
outside_group: false,
company_group_id: activeCompanyGroup?.id,
}),
);
setKindForm(null);
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
async function createRelatedGroupHandler(values: RelatedGroupFormType) {
if (values.company_group_id) {
try {
await createRelatedGroup(values.company_group_id, {
...values,
company_group_id: undefined,
});
getRelatedGroupsHandler();
setKindForm(null);
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
}
async function createCompanyGroupHandler(values: CreateCompanyGroupProps) {
try {
await createCompanyGroup(values);
dispatch(getCompanyGroupsThunk());
getRelatedGroupsHandler();
setKindForm(null);
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
async function getRelatedGroupsHandler() {
if (activeCompanyGroup?.id) {
setRelatedGroupLoading(true);
try {
const res = await fetchRelatedGroups(activeCompanyGroup?.id);
const gsks = res.filter((elem) => elem.kind === 'gsk');
const gszs = res.filter((elem) => elem.kind === 'gsz');
setGskData(gsks);
setGszData(gszs);
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setRelatedGroupLoading(false);
}
}
}
useEffect(() => {
getRelatedGroupsHandler();
}, [activeCompanyGroup]);
useEffect(() => {
setActiveTab('legal_entities');
}, [relatedGroupFilter]);
useEffect(() => {
if (!activeCompanyGroup && rawRows?.length) {
dispatch(setActiveCompanyGroup(rawRows?.[0]));
}
}, [rawRows, activeCompanyGroup]);
return (
<div
style={{
padding: 12,
display: 'flex',
flexDirection: 'column',
height: '100vh',
}}>
<Space style={{ justifyContent: 'space-between' }}>
<PageTitle header='Группы компаний и организаций' location='Справочники' />
<Button type='primary' icon={<PlusOutlined />} onClick={() => setEntityCreateModalOpen(true)}>
Создать ГК/ЮЛ
</Button>
</Space>
<div
style={{
flex: 1,
minHeight: 0,
display: 'flex',
gap: 12,
}}>
<div style={{ width: 320, flexShrink: 0, minHeight: 0 }}>
<CompanyGroupsList />
</div>
<div
style={{
flex: 1,
minWidth: 0,
minHeight: 0,
borderRadius: 12,
background: 'white',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}>
<CompanyGroupHeader />
{activeTab === 'legal_entities' && (
<CatalogueJurTable
outside_group={false}
tabsComponent={
<CataloguGroupTabs activeTab={activeTab} setActiveTab={(val) => setActiveTab(val)} gskData={gskData} gszData={gszData} />
}
/>
)}
{(activeTab === 'gsk' || activeTab === 'gsz') && (
<RelatedGroupTable
activeTab={activeTab}
data={activeTab === 'gsz' ? gszData : gskData}
getData={() => getRelatedGroupsHandler()}
tabsComponent={
<CataloguGroupTabs activeTab={activeTab} setActiveTab={(val) => setActiveTab(val)} gskData={gskData} gszData={gszData} />
}
loading={relatedGroupLoading}
/>
)}
</div>
</div>
<CreateEntityPickerModal
open={entityCreateModalOpen}
onCancel={() => {
setEntityCreateModalOpen(false);
}}
onSelect={(kind) => {
setKindForm(kind);
setEntityCreateModalOpen(false);
}}
/>
{kindForm === 'ul' && (
<CatalogueJurForm
open={kindForm === 'ul'}
onCancel={() => {
setKindForm(null);
}}
onOk={async (values) => {
await createJurHandler(values);
}}
/>
)}
{(kindForm === 'gsk' || kindForm === 'gsz') && (
<RelatedGroupForm
kind={kindForm}
open={kindForm === 'gsk' || kindForm === 'gsz'}
onCancel={() => {
setKindForm(null);
}}
onOk={async (values) => {
await createRelatedGroupHandler(values);
}}
/>
)}
{kindForm === 'gk' && (
<CompanyGroupForm
open={kindForm === 'gk'}
onCancel={() => {
setKindForm(null);
}}
onOk={async (values) => {
await createCompanyGroupHandler(values);
}}
/>
)}
</div>
);
} }

View File

@ -1,3 +1,69 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button, Space } from 'antd';
import { useState } from 'react';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { createCatalogueJur } from '../services/api/directories/catalogue_jur';
import { getCatalogueJurThunk } from '../store/slices/catalogueJurSlice/thunks';
import { useAppDispatch, useAppSelector } from '../store/store';
import type { CatalogueJurFormProps } from '../types/catalogueJur/catalogueJur';
import { useNotificationApi } from '../utils/useNotification';
import { CatalogueJurForm } from '../widgets/CatalogueJurForm/CatalogueJurForm';
import { CatalogueJurTable } from '../widgets/CatalogueJurTable/CatalogueJurTable';
export function CatalogueJur() { export function CatalogueJur() {
return <h3>ЮЛ вне ГК</h3>; const dispatch = useAppDispatch();
const notification = useNotificationApi();
const isGettingArchive = useAppSelector((state) => state.catalogue_jur.isGettingArchive);
const [createFormOpen, setCreateFormOpen] = useState(false);
const outside_group = true;
async function createJurHandler(values: CatalogueJurFormProps) {
try {
await createCatalogueJur(values);
dispatch(
getCatalogueJurThunk({
closed: isGettingArchive,
outside_group: outside_group,
}),
);
setCreateFormOpen(false);
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
return (
<div
style={{
padding: 12,
display: 'flex',
flexDirection: 'column',
height: '100vh',
}}>
<Space style={{ justifyContent: 'space-between' }}>
<PageTitle header='Юридические лица' location='Справочники' />
<Button type='primary' icon={<PlusOutlined />} onClick={() => setCreateFormOpen(true)}>
Создать ЮЛ
</Button>
</Space>
<CatalogueJurTable outside_group={outside_group} />
{createFormOpen && (
<CatalogueJurForm
open={createFormOpen}
onCancel={() => {
setCreateFormOpen(false);
}}
onOk={async (values) => {
await createJurHandler(values);
}}
/>
)}
</div>
);
} }

View File

@ -0,0 +1,34 @@
import { api } from '../../../api/client';
import type { GetCatalogueJurPropsI } from '../../../store/slices/catalogueJurSlice/types';
import type { Page } from '../../../types/api';
import type {
CatalogueJurFormProps,
ICatalogueJur,
MoveCatalogueJurProps,
WithdrawCatalogueJurProps,
} from '../../../types/catalogueJur/catalogueJur';
import { LE_PAGE } from './company_groups';
export async function fetchCatalogueJur(params: GetCatalogueJurPropsI) {
const res = await api.get<Page<ICatalogueJur>>('/directories/legal-entities', { limit: LE_PAGE, offset: 0, ...params });
return res;
}
export async function fetchOneCatalogueJur(catalogue_jur: string): Promise<ICatalogueJur> {
const res = await api.get<ICatalogueJur>(`/directories/legal-entities/${catalogue_jur}`);
return res;
}
export function moveCatalogueJur(entityId: string, body: MoveCatalogueJurProps): Promise<void> {
return api.post<void>(`/directories/legal-entities/${entityId}/move`, body);
}
export function withdrawCatalogueJur(entityId: string, body: WithdrawCatalogueJurProps): Promise<void> {
return api.post<void>(`/directories/legal-entities/${entityId}/withdraw`, body);
}
export function createCatalogueJur(body: CatalogueJurFormProps): Promise<void> {
return api.post<void>('/directories/legal-entities', body);
}
export function editCatalogueJur(entityId: string, body: CatalogueJurFormProps): Promise<void> {
return api.patch<void>(`/directories/legal-entities/${entityId}`, body);
}

View File

@ -1,9 +1,9 @@
import { api } from '../../../api/client'; import { api } from '../../../api/client';
import type { LegalEntityBrief, Page, RelatedGroupDetail, RelatedGroupOut } from '../../../types/api'; import type { LegalEntityBrief, Page, RelatedGroupDetail, RelatedGroupOut } from '../../../types/api';
import type { ICompanyGroupOut } from '../../../types/companyGroups/companyGroups'; import type { CreateCompanyGroupProps, ICompanyGroupOut } from '../../../types/companyGroups/companyGroups';
const CG_PAGE = 200; const CG_PAGE = 200;
const LE_PAGE = 200; export const LE_PAGE = 200;
/** Все видимые ГК — дочитываем страницы, пока не получим total. */ /** Все видимые ГК — дочитываем страницы, пока не получим total. */
export async function fetchCompanyGroups(): Promise<ICompanyGroupOut[]> { export async function fetchCompanyGroups(): Promise<ICompanyGroupOut[]> {
@ -36,3 +36,7 @@ export async function fetchAllLegalEntities(groupId: string): Promise<LegalEntit
} }
export const fetchLegalEntity = (id: string) => api.get<LegalEntityBrief>(`/directories/legal-entities/${id}`); export const fetchLegalEntity = (id: string) => api.get<LegalEntityBrief>(`/directories/legal-entities/${id}`);
export function createCompanyGroup(body: CreateCompanyGroupProps): Promise<void> {
return api.post<void>('/directories/company-groups', body);
}

View File

@ -0,0 +1,11 @@
import { api } from '../../../api/client';
import type { RelatedGroupFormType } from '../../../types/relatedGroup/relatedGroup';
export function editRelatedGroup(groupId: string, body: RelatedGroupFormType): Promise<void> {
return api.patch<void>(`/directories/related-groups/${groupId}`, body);
}
export function createRelatedGroup(companyGroupId: string, body: RelatedGroupFormType): Promise<void> {
return api.post<void>(`/directories/company-groups/${companyGroupId}/related-groups`, body);
}
export const deleteRelatedGroup = (id: string) => api.del<void>(`/directories/related-groups/${id}`);

View File

@ -0,0 +1,44 @@
import { type PayloadAction, createSlice } from '@reduxjs/toolkit';
import type { RelatedGroupOut } from '../../../types/api';
import { getCatalogueJurThunk } from './thunks';
import type { CatalogueJurSliceType } from './types';
const initialState: CatalogueJurSliceType = {
raw_rows: [],
total_groups: 0,
loading: false,
error: null,
isGettingArchive: false,
relatedGroupFilter: null,
};
const catalogueJurSlice = createSlice({
name: 'catalogue_jur',
initialState,
reducers: {
setIsGettingArchive(state, action: PayloadAction<boolean>) {
state.isGettingArchive = action.payload;
},
setRelatedGroupFilter(state, action: PayloadAction<RelatedGroupOut | null>) {
state.relatedGroupFilter = action.payload;
},
},
extraReducers: (builder) => {
builder
.addCase(getCatalogueJurThunk.pending, (state, _) => {
state.loading = true;
state.error = null;
})
.addCase(getCatalogueJurThunk.fulfilled, (state, action) => {
state.raw_rows = action.payload.items;
state.total_groups = action.payload.total;
state.loading = false;
})
.addCase(getCatalogueJurThunk.rejected, (state, action) => {
state.error = action.payload ?? 'Неизвестная ошибка';
state.loading = false;
});
},
});
export const { setIsGettingArchive, setRelatedGroupFilter } = catalogueJurSlice.actions;
export default catalogueJurSlice.reducer;

View File

@ -0,0 +1,16 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { fetchCatalogueJur } from '../../../services/api/directories/catalogue_jur';
import type { Page } from '../../../types/api';
import type { ICatalogueJur } from '../../../types/catalogueJur/catalogueJur';
import type { GetCatalogueJurPropsI } from './types';
export const getCatalogueJurThunk = createAsyncThunk<Page<ICatalogueJur>, GetCatalogueJurPropsI, { rejectValue: string }>(
'catalogue_Jur/getAll',
async (args: GetCatalogueJurPropsI, thunkApi) => {
try {
return await fetchCatalogueJur(args);
} catch (e) {
return thunkApi.rejectWithValue(e instanceof Error ? e.message : 'Ошибка');
}
},
);

View File

@ -0,0 +1,17 @@
import type { RelatedGroupOut } from '../../../types/api';
import type { ICatalogueJur } from '../../../types/catalogueJur/catalogueJur';
export interface CatalogueJurSliceType {
total_groups: number;
raw_rows: ICatalogueJur[];
loading: boolean;
error: string | null;
isGettingArchive: boolean;
relatedGroupFilter?: RelatedGroupOut | null;
}
export interface GetCatalogueJurPropsI {
closed?: boolean;
outside_group?: boolean;
company_group_id?: string;
}

View File

@ -1,4 +1,5 @@
import { createSlice } from '@reduxjs/toolkit'; import { type PayloadAction, createSlice } from '@reduxjs/toolkit';
import type { ICompanyGroup } from '../../../types/companyGroups/companyGroups';
import { getCompanyGroupsThunk } from './thunks'; import { getCompanyGroupsThunk } from './thunks';
import type { CompanyGroupSliceType } from './types'; import type { CompanyGroupSliceType } from './types';
@ -7,12 +8,17 @@ const initialState: CompanyGroupSliceType = {
total_groups: 0, total_groups: 0,
loading: false, loading: false,
error: null, error: null,
activeCompanyGroup: null,
}; };
const comGroupsSlice = createSlice({ const comGroupsSlice = createSlice({
name: 'company_groups', name: 'company_groups',
initialState, initialState,
reducers: {}, reducers: {
setActiveCompanyGroup(state, action: PayloadAction<ICompanyGroup | null>) {
state.activeCompanyGroup = action.payload;
},
},
extraReducers: (builder) => { extraReducers: (builder) => {
builder builder
.addCase(getCompanyGroupsThunk.pending, (state) => { .addCase(getCompanyGroupsThunk.pending, (state) => {
@ -21,6 +27,10 @@ const comGroupsSlice = createSlice({
}) })
.addCase(getCompanyGroupsThunk.fulfilled, (state, action) => { .addCase(getCompanyGroupsThunk.fulfilled, (state, action) => {
state.raw_rows = action.payload; state.raw_rows = action.payload;
if (state.activeCompanyGroup?.id) {
const newActiveCompanyGroup = action.payload.find((elem) => elem.id === state.activeCompanyGroup?.id);
state.activeCompanyGroup = newActiveCompanyGroup;
}
state.total_groups = action.payload.length; state.total_groups = action.payload.length;
state.loading = false; state.loading = false;
}) })
@ -30,5 +40,5 @@ const comGroupsSlice = createSlice({
}); });
}, },
}); });
export const { setActiveCompanyGroup } = comGroupsSlice.actions;
export default comGroupsSlice.reducer; export default comGroupsSlice.reducer;

View File

@ -5,4 +5,5 @@ export interface CompanyGroupSliceType {
raw_rows: ICompanyGroup[]; raw_rows: ICompanyGroup[];
loading: boolean; loading: boolean;
error: string | null; error: string | null;
activeCompanyGroup?: ICompanyGroup | null;
} }

View File

@ -1,5 +1,6 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import catalogueJurReducer from './slices/catalogueJurSlice/catalogueJurSlice';
import comGroupsReducer from './slices/companyGroupsSlice/companyGroupsSlice'; import comGroupsReducer from './slices/companyGroupsSlice/companyGroupsSlice';
import consolidationsReducer from './slices/consolidations/consolidationsSlice'; import consolidationsReducer from './slices/consolidations/consolidationsSlice';
@ -7,6 +8,7 @@ export const store = configureStore({
reducer: { reducer: {
company_groups: comGroupsReducer, company_groups: comGroupsReducer,
consolidations: consolidationsReducer, consolidations: consolidationsReducer,
catalogue_jur: catalogueJurReducer,
}, },
}); });

View File

@ -384,6 +384,7 @@ export interface RelatedGroupMemberOut {
} }
export interface RelatedGroupDetail extends RelatedGroupOut { export interface RelatedGroupDetail extends RelatedGroupOut {
members: RelatedGroupMemberOut[]; members: RelatedGroupMemberOut[];
crm_id: string;
} }
export interface LegalEntityBrief { export interface LegalEntityBrief {
id: string; id: string;

View File

@ -0,0 +1,64 @@
export interface ICatalogueJur {
id: string;
inn: string;
kpp?: string;
crm_id?: string;
entity_type: 'ЮЛ' | 'ИП';
name: string;
company_group_id?: string;
company_group_name: string | null;
regional_office_id?: string;
close_date: Date;
valid_from: Date;
created_at: Date;
updated_at: Date;
outside_group: boolean;
closed: boolean | string;
memberships?: MemberShips[];
aliases?: JurAlliase[];
regional_office: {
id: string;
name: string;
is_head_office: boolean;
};
}
export type KindType = 'gsk' | 'gsz';
export type MemberShips = {
related_group_id: string;
code: string;
kind: KindType;
name: string;
valid_from: Date;
};
export type JurAlliase = {
alias: string;
created_at: Date;
id: string;
normalized_alias: string;
};
export type MoveCatalogueJurProps = {
company_group_id: string;
effective_date: string;
};
export type WithdrawCatalogueJurProps = {
event: 'CLOSE' | 'REMOVE_FROM_GROUP';
effective_date: string;
comment: string;
};
export type CatalogueJurFormProps = {
inn?: string;
kpp?: string | null;
crm_id: string;
entity_type: 'ЮЛ' | 'ИП';
name: string;
aliases?: string[] | { value: string; label: string }[];
company_group_id?: string;
regional_office_id?: string | null;
gsk_id?: string | null;
gsz_id?: string | null;
};

View File

@ -17,3 +17,9 @@ export const toCompanyGroupRow = (g: ICompanyGroupOut): ICompanyGroup => ({
...g, ...g,
base_regional_office_name: g.base_regional_office?.name ?? '—', base_regional_office_name: g.base_regional_office?.name ?? '—',
}); });
export interface CreateCompanyGroupProps {
name: string;
crm_id: string;
base_regional_office_id: string | null;
}

View File

@ -0,0 +1,9 @@
export type RelatedGroupFormType = {
name: string;
created_date: string | null;
crm_id: string;
kind?: 'gsk' | 'gsz';
company_group_id?: string;
};
export type CreateEntityKind = 'gk' | 'ul' | 'gsk' | 'gsz';

View File

@ -7,6 +7,7 @@ import {
lineStatusLabel, lineStatusLabel,
matchKindLabel, matchKindLabel,
periodLabel, periodLabel,
pluralRu,
quarterLabel, quarterLabel,
severityLabel, severityLabel,
} from './labels'; } from './labels';
@ -74,6 +75,28 @@ describe('подписи периода', () => {
}); });
}); });
describe('pluralRu', () => {
const forms = ['запись', 'записи', 'записей'] as const;
it('1, 21, 101 — первая форма', () => {
expect(pluralRu(1, forms)).toBe('запись');
expect(pluralRu(21, forms)).toBe('запись');
expect(pluralRu(101, forms)).toBe('запись');
});
it('2–4, 22–24 — вторая форма', () => {
expect(pluralRu(2, forms)).toBe('записи');
expect(pluralRu(4, forms)).toBe('записи');
expect(pluralRu(23, forms)).toBe('записи');
});
it('0, 5–20, 11–14, 111 — третья форма', () => {
expect(pluralRu(0, forms)).toBe('записей');
expect(pluralRu(5, forms)).toBe('записей');
expect(pluralRu(11, forms)).toBe('записей');
expect(pluralRu(12, forms)).toBe('записей');
expect(pluralRu(14, forms)).toBe('записей');
expect(pluralRu(111, forms)).toBe('записей');
});
});
describe('подписи формы v5.19', () => { describe('подписи формы v5.19', () => {
it('статус строки', () => { it('статус строки', () => {
expect(lineStatusLabel('ok')).toBe('Строка формы'); expect(lineStatusLabel('ok')).toBe('Строка формы');

View File

@ -71,6 +71,16 @@ const MATCH_KIND: Record<MatchKind, string> = {
export const counterpartyTypeLabel = (t: CounterpartyType): string => COUNTERPARTY_TYPE[t] ?? t; export const counterpartyTypeLabel = (t: CounterpartyType): string => COUNTERPARTY_TYPE[t] ?? t;
export const matchKindLabel = (k: MatchKind | null): string => (k ? (MATCH_KIND[k] ?? k) : 'не сопоставлен'); export const matchKindLabel = (k: MatchKind | null): string => (k ? (MATCH_KIND[k] ?? k) : 'не сопоставлен');
/** Форма слова по числу: `forms` — [1, 2–4, 5–20] («запись», «записи», «записей»). */
export function pluralRu(count: number, forms: readonly [string, string, string]): string {
const mod100 = Math.abs(count) % 100;
const mod10 = mod100 % 10;
if (mod100 >= 11 && mod100 <= 14) return forms[2];
if (mod10 === 1) return forms[0];
if (mod10 >= 2 && mod10 <= 4) return forms[1];
return forms[2];
}
const LINE_STATUS: Record<LineStatus, string> = { const LINE_STATUS: Record<LineStatus, string> = {
ok: 'Строка формы', ok: 'Строка формы',
unreadable: 'Код не распознан', unreadable: 'Код не распознан',

View File

@ -0,0 +1,24 @@
import { App } from 'antd';
import type { ReactNode } from 'react';
type NotifyArgs = {
title?: ReactNode;
description?: ReactNode;
placement?: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight';
duration?: number;
};
export function useNotificationApi() {
const { notification } = App.useApp();
return {
error: ({ title = 'Ошибка', description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.error({ title: title, description, placement, duration }),
success: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.success({ title: title, description, placement, duration }),
info: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.info({ title: title, description, placement, duration }),
warning: ({ title, description, placement = 'bottomRight', duration }: NotifyArgs) =>
notification.warning({ title: title, description, placement, duration }),
};
}

View File

@ -12,7 +12,7 @@ const USERS_LIMIT = 200;
// Загрузчики — функции уровня модуля: они не меняются между отрисовками (требование useAsyncData) // Загрузчики — функции уровня модуля: они не меняются между отрисовками (требование useAsyncData)
const loadUsers = () => api.get<Page<AdminUser>>(`/admin/users?limit=${USERS_LIMIT}`); const loadUsers = () => api.get<Page<AdminUser>>(`/admin/users?limit=${USERS_LIMIT}`);
const loadRoles = () => api.get<Role[]>('/admin/roles'); const loadRoles = () => api.get<Role[]>('/admin/roles');
const loadOffices = () => api.get<RegionalOffice[]>('/directories/regional-offices'); export const loadOffices = () => api.get<RegionalOffice[]>('/directories/regional-offices');
/** /**
* Таблица пользователей: роли меняются прямо в строке (`PUT /admin/users/{id}/roles`), активность — переключателем * Таблица пользователей: роли меняются прямо в строке (`PUT /admin/users/{id}/roles`), активность — переключателем

View File

@ -0,0 +1,9 @@
.ant-tabs-tab-active {
background-color: #ecfdf3 !important;
}
.ant-tabs-tab {
padding: 8px 12px !important;
margin: 10px 0 0 0 !important;
border-radius: 8px !important;
}

View File

@ -0,0 +1,33 @@
import { Tabs } from 'antd';
import { useAppSelector } from '../../store/store';
import type { RelatedGroupOut } from '../../types/api';
import './CataloguGroupTabs.css';
interface CataloguGroupTabsI {
activeTab: CatalogueGroupTabKey;
setActiveTab: (val: CatalogueGroupTabKey) => void;
gskData: RelatedGroupOut[];
gszData: RelatedGroupOut[];
}
export type CatalogueGroupTabKey = 'legal_entities' | 'gsk' | 'gsz';
export const CataloguGroupTabs = ({ activeTab, setActiveTab, gskData, gszData }: CataloguGroupTabsI) => {
const activeCompanyGroup = useAppSelector((state) => state.company_groups.activeCompanyGroup);
return (
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as CatalogueGroupTabKey)}
tabBarStyle={{ margin: 0, marginBottom: 0 }}
style={{ marginBottom: 0 }}
items={[
{
key: 'legal_entities',
label: `ЮЛ ${activeCompanyGroup?.legal_entities_count ?? 0}`,
},
{ key: 'gsk', label: `ГСК ${gskData?.length ?? 0}` },
{ key: 'gsz', label: `ГСЗ ${gszData?.length ?? 0}` },
]}
/>
);
};

View File

@ -0,0 +1,33 @@
import { Space } from 'antd';
import Text from 'antd/es/typography/Text';
import Title from 'antd/es/typography/Title';
import { useAppSelector } from '../../store/store';
import { pluralRu } from '../../utils/labels';
export const CompanyGroupHeader = () => {
const activeCompanyGroup = useAppSelector((state) => state.company_groups.activeCompanyGroup);
const count = activeCompanyGroup?.legal_entities_count ?? 0;
const organizationWord = pluralRu(count, ['организация', 'организации', 'организаций']);
return (
<div
style={{
padding: '6px 16px',
borderBottom: '1px solid #0f172a0f',
flexShrink: 0,
}}>
<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>
);
};

View File

@ -0,0 +1,286 @@
import { Col, Form, Input, Modal, Row, Select } from 'antd';
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { fetchOneCatalogueJur } from '../../services/api/directories/catalogue_jur';
import { fetchRelatedGroups } from '../../services/api/directories/company_groups';
import { useAppSelector } from '../../store/store';
import type { RelatedGroupOut } from '../../types/api';
import type { CatalogueJurFormProps } from '../../types/catalogueJur/catalogueJur';
import { useNotificationApi } from '../../utils/useNotification';
import { loadOffices } from '../Admin/UsersTable';
type Props = {
open: boolean;
onCancel: () => void;
onOk: (values: CatalogueJurFormProps) => void | Promise<void>;
jurId?: string;
};
export const CatalogueJurForm: React.FC<Props> = ({ open, onCancel, onOk, jurId }) => {
const [form] = Form.useForm<CatalogueJurFormProps>();
const notification = useNotificationApi();
const [initialDataLoading, setInitialDataLoading] = useState(false);
const [gskData, setGskData] = useState<RelatedGroupOut[]>([]);
const [gszData, setGszData] = useState<RelatedGroupOut[]>([]);
const [loadingRelatedGroupsOptions, setLoadingRelatedGroupsOptions] = useState(false);
const [loadingOfficesOptions, setLoadingOfficesOptions] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const [officesOptions, setOfficesOptions] = useState<{ value: string; label: string }[]>([]);
const companyGrops = useAppSelector((state) => state.company_groups.raw_rows);
const companyGropsLoading = useAppSelector((state) => state.company_groups.loading);
const type = Form.useWatch('entity_type', form);
const companyGroupId = Form.useWatch('company_group_id', form);
async function getInitialData() {
if (jurId) {
setInitialDataLoading(true);
try {
const data = await fetchOneCatalogueJur(jurId);
form.setFieldsValue({
name: data?.name,
aliases: data?.aliases?.map((aliase) => aliase.alias),
crm_id: data?.crm_id,
entity_type: data?.entity_type,
inn: data.inn,
kpp: data?.kpp,
regional_office_id: data?.regional_office_id,
company_group_id: data?.company_group_id,
gsk_id: data.memberships?.find((elem) => elem.kind === 'gsk')?.related_group_id,
gsz_id: data.memberships?.find((elem) => elem.kind === 'gsz')?.related_group_id,
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setInitialDataLoading(false);
}
}
}
async function getOfficesOptionsHandler() {
setLoadingOfficesOptions(true);
try {
const res = await loadOffices();
const options = res.map((elem) => elem && { value: elem.id, label: elem.name });
setOfficesOptions(options);
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setLoadingOfficesOptions(false);
}
}
async function getRelatedGroupsHandler() {
if (!companyGroupId) return;
setLoadingRelatedGroupsOptions(true);
try {
const res = await fetchRelatedGroups(companyGroupId);
const gsks = res.filter((elem) => elem.kind === 'gsk');
const gszs = res.filter((elem) => elem.kind === 'gsz');
setGskData(gsks);
setGszData(gszs);
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setLoadingRelatedGroupsOptions(false);
}
}
const handleOk = async () => {
setConfirmLoading(true);
try {
const values = await form.validateFields();
await onOk({
...values,
kpp: type === 'ИП' ? null : values.kpp?.toString(),
inn: jurId ? undefined : values?.inn?.toString(),
company_group_id: jurId ? undefined : values.company_group_id,
gsz_id: values.gsz_id ? values?.gsz_id : null,
gsk_id: values.gsk_id ? values?.gsk_id : null,
regional_office_id: values.regional_office_id ? values?.regional_office_id : null,
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const companyGroupOptions = useMemo(() => {
return companyGrops?.map((company) => company && { value: company.id, label: company.name });
}, [companyGrops]);
useEffect(() => {
getInitialData();
}, [jurId, open]);
useEffect(() => {
getOfficesOptionsHandler();
}, []);
useEffect(() => {
form.setFieldsValue({
gsk_id: undefined,
gsz_id: undefined,
});
getRelatedGroupsHandler();
}, [companyGroupId]);
return (
<Modal
title={jurId ? 'Изменить юридическое лицо' : 'Добавить юридическое лицо'}
open={open}
onCancel={handleCancel}
onOk={handleOk}
okText={jurId ? 'Сохранить' : 'Добавить'}
cancelText='Отмена'
destroyOnHidden
width={720}
confirmLoading={confirmLoading}
loading={initialDataLoading}>
<Form form={form} layout='vertical'>
<Row gutter={16}>
<Col span={6}>
<Form.Item
name='inn'
label='ИНН'
rules={
type === 'ИП'
? [
{ required: true, message: 'Укажите ИНН' },
{ pattern: /^\d{12}$/, message: '12 цифр' },
]
: [
{ required: true, message: 'Укажите ИНН' },
{ pattern: /^\d{10}$/, message: '10 цифр' },
]
}>
<Input
disabled={!!jurId}
style={{ width: '100%' }}
placeholder={type === 'ИП' ? '000000000000' : '0000000000'}
maxLength={type === 'ИП' ? 12 : 10}
/>
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name='kpp' label='КПП' rules={[{ pattern: /^[0-9]{4}[0-9A-Z]{2}[0-9]{3}$/, message: 'Некорректный формат' }]}>
<Input placeholder='000000000' maxLength={9} disabled={type === 'ИП'} style={{ width: '100%' }} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name='crm_id' label='ID ЮЛ в CRM'>
<Input />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name='entity_type' label='Тип' initialValue='ЮЛ' rules={[{ required: true }]}>
<Select
disabled={!!jurId}
options={[
{ value: 'ЮЛ', label: 'ЮЛ' },
{ value: 'ИП', label: 'ИП' },
]}
/>
</Form.Item>
</Col>
</Row>
<Form.Item name='name' label='Наименование организации' rules={[{ required: true, message: 'Укажите наименование' }]}>
<Input placeholder='ООО «Название»' />
</Form.Item>
<Form.Item
name='aliases'
label='Альтернативные наименования'
extra='Введите синоним и нажмите Enter. Можно добавить несколько значений.'>
<Select
mode='tags'
style={{ width: '100%' }}
placeholder='Добавить синоним'
tokenSeparators={[',']}
open={false}
suffixIcon={null}
notFoundContent={null}
/>
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name='regional_office_id' label='Базовый РФ ГК'>
<Select
allowClear
loading={loadingOfficesOptions}
placeholder='— не указан —'
options={officesOptions}
showSearch
optionFilterProp='label'
notFoundContent={loadingOfficesOptions ? 'Загрузка' : 'Нет вариантов'}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name='company_group_id' label='ГК'>
<Select
disabled={!!jurId}
allowClear
placeholder='— не указан —'
options={companyGroupOptions}
showSearch
optionFilterProp='label'
notFoundContent={companyGropsLoading ? 'Загрузка' : 'Нет вариантов'}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name='gsk_id' label='Входит в ГСК'>
<Select
allowClear
placeholder='— не указан —'
options={gskData?.map((g) => ({ value: g.id, label: g.name }))}
showSearch
optionFilterProp='label'
notFoundContent={loadingRelatedGroupsOptions ? 'Загрузка' : 'Нет вариантов'}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name='gsz_id' label='Входит в ГСЗ'>
<Select
allowClear
placeholder='— не указан —'
options={gszData?.map((g) => ({
value: g.id,
label: g.name,
}))}
showSearch
optionFilterProp='label'
notFoundContent={loadingRelatedGroupsOptions ? 'Загрузка' : 'Нет вариантов'}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,339 @@
import {
CloseOutlined,
EditOutlined,
EllipsisOutlined,
HistoryOutlined,
LogoutOutlined,
SearchOutlined,
SwapOutlined,
} from '@ant-design/icons';
import { Button, Checkbox, Dropdown, Input, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { editCatalogueJur, moveCatalogueJur, withdrawCatalogueJur } from '../../services/api/directories/catalogue_jur';
import { setIsGettingArchive, setRelatedGroupFilter } from '../../store/slices/catalogueJurSlice/catalogueJurSlice';
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,
} 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';
interface CatalogueJurTableI {
outside_group: boolean;
tabsComponent?: ReactNode;
}
export function CatalogueJurTable({ outside_group, tabsComponent }: CatalogueJurTableI) {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const notification = useNotificationApi();
const rawRows = useAppSelector((state) => state.catalogue_jur.raw_rows);
const loading = useAppSelector((state) => state.catalogue_jur.loading);
const isGettingArchive = useAppSelector((state) => state.catalogue_jur.isGettingArchive);
const activeCompanyGroup = useAppSelector((state) => state.company_groups.activeCompanyGroup);
const relatedGroupFilter = useAppSelector((state) => state.catalogue_jur.relatedGroupFilter);
const [moveCatalogueFormOpen, setMoveCatalogueFormOpen] = useState(false);
const [editCatalogueFormOpen, setEditCatalogueFormOpen] = useState(false);
const [withdrawCatalogueFormOpen, setWithdrawCatalogueFormOpen] = useState(false);
const [jurId, setJurId] = useState('');
const filterdData = useMemo(() => {
if (relatedGroupFilter?.id && !outside_group) {
return rawRows?.filter((row) => {
return row?.memberships?.some((member) => member.related_group_id === relatedGroupFilter.id);
});
}
return rawRows;
}, [rawRows, relatedGroupFilter, outside_group]);
const { visibleKeys, setVisibleKeys } = useVisibleColumns(catalogueJurConfig);
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);
function getCatalogueJur() {
dispatch(
getCatalogueJurThunk({
closed: isGettingArchive,
outside_group: outside_group,
company_group_id: outside_group ? undefined : activeCompanyGroup?.id,
}),
);
}
function closeMoveCatalogueJurHandler() {
setJurId('');
setMoveCatalogueFormOpen(false);
}
function closeWithdrawCatalogueJurHandler() {
setJurId('');
setWithdrawCatalogueFormOpen(false);
}
function closeEditCatalogueJurHandler() {
setJurId('');
setEditCatalogueFormOpen(false);
}
async function moveCatalogueJurHandler(values: MoveCatalogueJurProps) {
try {
await moveCatalogueJur(jurId, values);
getCatalogueJur();
dispatch(getCompanyGroupsThunk());
closeMoveCatalogueJurHandler();
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
async function withdrawCatalogueJurHandler(values: WithdrawCatalogueJurProps) {
try {
await withdrawCatalogueJur(jurId, values);
getCatalogueJur();
dispatch(getCompanyGroupsThunk());
closeWithdrawCatalogueJurHandler();
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
async function editJurHandler(values: CatalogueJurFormProps) {
try {
await editCatalogueJur(jurId, values);
getCatalogueJur();
closeEditCatalogueJurHandler();
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
useEffect(() => {
if (activeCompanyGroup?.id) {
getCatalogueJur();
}
}, [isGettingArchive, activeCompanyGroup]);
const extraColumns: ColumnsType<ICatalogueJur> = useMemo(
() => [
{
key: 'actions',
title: '',
width: 30,
render: (_: unknown, row: ICatalogueJur) => {
const items = [
{
key: 'edit',
icon: <EditOutlined />,
label: 'Изменить ЮЛ',
onClick: () => {
setJurId(row.id);
setEditCatalogueFormOpen(true);
},
},
{
key: 'history',
icon: <HistoryOutlined />,
label: 'История изменений',
onClick: () => navigate('/logging'),
},
{
key: 'move',
icon: <SwapOutlined />,
label: 'Перевести в другую ГК',
onClick: () => {
setJurId(row.id);
setMoveCatalogueFormOpen(true);
},
},
{
key: 'withdraw',
icon: <LogoutOutlined />,
label: 'Вывести из состава ГК',
danger: true,
onClick: () => {
setJurId(row.id);
setWithdrawCatalogueFormOpen(true);
},
},
];
return (
<Dropdown menu={{ items }} trigger={['click']}>
<Button type='default' style={{ width: '32px' }}>
<EllipsisOutlined />
</Button>
</Dropdown>
);
},
},
],
[navigate],
);
return (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'white',
borderRadius: 12,
}}>
<Space
style={{
padding: `${outside_group ? '12px' : '0px'} 12px`,
width: '100%',
justifyContent: 'space-between',
borderBottom: '1px',
}}>
{outside_group ? (
<Input
allowClear
prefix={<SearchOutlined />}
placeholder='Поиск по ГК, CRM ID или РФ'
value={rawQuery}
onChange={(e) => setRawQuery(e.target.value)}
style={{ maxWidth: 320 }}
/>
) : (
tabsComponent
)}
<Space>
{hasActiveFilters && (
<Button
type='default'
onClick={() => {
resetAll();
}}>
Сбросить фильтры
</Button>
)}
<Checkbox checked={isGettingArchive} onChange={(e) => dispatch(setIsGettingArchive(e.target.checked))}>
Архив
</Checkbox>
<ColumnSettings defs={catalogueJurConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
</Space>
</Space>
{relatedGroupFilter?.id && !outside_group && (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 16,
padding: '10px 16px',
background: '#fff8e1', // светло-жёлтый фон
borderTop: '1px solid #ffe58f',
borderBottom: '1px solid #ffe58f',
borderRadius: 8,
color: '#874d00',
fontSize: 14,
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
minWidth: 0,
}}>
<SearchOutlined style={{ fontSize: 16, color: '#874d00' }} />
<span style={{ color: '#874d00' }}>Фильтр по:</span>
<span style={{ fontWeight: 600, color: '#ad4e00' }}>{relatedGroupFilter.code}</span>
<span style={{ color: '#874d00' }}>
— {filterdData?.length} &nbsp;
{pluralRu(filterdData?.length ?? 0, ['запись', 'записи', 'записей'])}
</span>
</div>
<Button
icon={<CloseOutlined />}
onClick={() => dispatch(setRelatedGroupFilter(null))}
style={{
borderColor: '#d9d9d9',
color: '#262626',
}}>
Сбросить
</Button>
</div>
)}
<DataTable
sortState={sort}
onSortChange={setSort}
config={catalogueJurConfig}
rows={rows}
allRows={rawRows}
loading={loading}
visibleKeys={visibleKeys}
globalQuery={rawQuery}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
extraColumns={isGettingArchive ? undefined : extraColumns}
/>
{moveCatalogueFormOpen && (
<MoveCatalogueJurForm
jurId={jurId}
open={moveCatalogueFormOpen}
onCancel={() => {
closeMoveCatalogueJurHandler();
}}
onOk={async (values) => {
await moveCatalogueJurHandler(values);
}}
/>
)}
{withdrawCatalogueFormOpen && (
<WithdrawCatalogueJurForm
jurId={jurId}
open={withdrawCatalogueFormOpen}
onCancel={() => {
closeWithdrawCatalogueJurHandler();
}}
onOk={async (values) => {
await withdrawCatalogueJurHandler(values);
}}
/>
)}
{editCatalogueFormOpen && (
<CatalogueJurForm
jurId={jurId}
open={editCatalogueFormOpen}
onCancel={() => {
closeEditCatalogueJurHandler();
}}
onOk={async (values) => {
await editJurHandler(values);
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,51 @@
import type { TableConfig } from '../../components/GenericTable';
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'],
columns: [
{
key: 'closed',
title: 'Статус',
features: { sort: true },
render: (_: unknown, row) => {
const preset = !row.closed ? TAG_PRESETS.green : TAG_PRESETS.gray;
const text = !row.closed ? 'Активно' : 'Не активно';
return <TableTag text={text} {...preset} />;
},
},
{
key: 'inn',
title: 'ИНН',
features: { search: true },
},
{
key: 'kpp',
title: 'КПП',
features: { search: true },
},
{
key: 'name',
title: 'Организация',
features: { search: true, multiselect: true, sort: true },
},
{
key: 'entity_type',
title: 'Тип',
features: { multiselect: true, sort: true },
render: (val) => {
const preset = val === 'ЮЛ' ? TAG_PRESETS.orange : TAG_PRESETS.purple;
return <TableTag text={val as string} {...preset} />;
},
},
{
key: 'regional_office',
title: 'Основной РФ',
render: (val) => (val as ICatalogueJur['regional_office'] | null)?.name,
},
],
};

View File

@ -0,0 +1,109 @@
import { Col, Form, Input, Modal, Row, Select } from 'antd';
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 { useNotificationApi } from '../../utils/useNotification';
import { loadOffices } from '../Admin/UsersTable';
type Props = {
open: boolean;
onCancel: () => void;
onOk: (values: CreateCompanyGroupProps) => void | Promise<void>;
};
export const CompanyGroupForm: React.FC<Props> = ({ open, onCancel, onOk }) => {
const [form] = Form.useForm<CreateCompanyGroupProps>();
const { me } = useMe();
const notification = useNotificationApi();
const activeCompanyGroup = useAppSelector((state) => state.company_groups.activeCompanyGroup);
const [officesOptions, setOfficesOptions] = useState<{ value: string; label: string }[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
async function getOfficesOptionsHandler() {
setLoadingOptions(true);
try {
const res = await loadOffices();
const options = res.map((elem) => elem && { value: elem.id, label: elem.name });
setOfficesOptions(options);
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setLoadingOptions(false);
}
}
const handleOk = async () => {
setConfirmLoading(true);
try {
const values = await form.validateFields();
await onOk({
...values,
base_regional_office_id: values?.base_regional_office_id ? values?.base_regional_office_id : (me?.regional_office.id as string),
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
useEffect(() => {
getOfficesOptionsHandler();
}, [activeCompanyGroup]);
return (
<Modal
title='Создать группу компаний'
open={open}
onCancel={handleCancel}
onOk={handleOk}
okText='Создать'
cancelText='Отмена'
destroyOnHidden
confirmLoading={confirmLoading}
width={720}>
<Form form={form} layout='vertical'>
<Form.Item name='name' label='Название ГК' rules={[{ required: true, message: 'Укажите название ГК' }]}>
<Input placeholder='Например: Агроком' />
</Form.Item>
<Row gutter={16}>
<Col span={12}>
<Form.Item name='crm_id' label='ID ГК в CRM'>
<Input placeholder='Генерируется автоматически' />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name='base_regional_office_id' label='Базовый РФ ГК'>
<Select
allowClear
loading={loadingOptions}
placeholder='— не указан —'
options={officesOptions}
showSearch
optionFilterProp='label'
notFoundContent={loadingOptions ? 'Загрузка' : 'Нет вариантов'}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,4 @@
.ant-listy-item {
border: 0 !important;
padding: 0 !important;
}

View File

@ -0,0 +1,164 @@
import { FolderOpenOutlined, SearchOutlined } from '@ant-design/icons';
import { Empty, Input, List, Spin, Tag, Typography } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { useVirtualList } from '../../hooks/useVirtualList';
import { setActiveCompanyGroup } from '../../store/slices/companyGroupsSlice/companyGroupsSlice';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
const { Text } = Typography;
export const CompanyGroupsList = () => {
const dispatch = useAppDispatch();
const rawRows = useAppSelector((state) => state.company_groups.raw_rows);
const loading = useAppSelector((state) => state.company_groups.loading);
const activeCompanyGroup = useAppSelector((state) => state.company_groups.activeCompanyGroup);
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
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]);
const { ref, onScroll, visibleItems, totalHeight, offsetY } = useVirtualList(filtered, {
itemHeight: 56,
containerHeight: 900,
overscan: 5,
});
useEffect(() => {
dispatch(getCompanyGroupsThunk());
}, [dispatch]);
useEffect(() => {
if (!activeCompanyGroup && rawRows?.length) {
dispatch(setActiveCompanyGroup(rawRows?.[0]));
}
}, [rawRows, activeCompanyGroup, dispatch]);
if (loading) {
return (
<div style={{ textAlign: 'center', padding: 24 }}>
<Spin />
</div>
);
}
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
background: '#fff',
borderRight: '1px solid #f0f0f0',
borderRadius: 12,
}}>
<div style={{ padding: 12 }}>
<Input
allowClear
prefix={<SearchOutlined style={{ color: '#bfbfbf' }} />}
placeholder='Поиск по ГК или CRM ID'
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<div ref={ref} onScroll={onScroll} style={{ flex: 1, overflowY: 'auto', padding: '0 8px 8px' }}>
{filtered.length === 0 ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description='Компании и организации не найдены' style={{ marginTop: 32 }} />
) : (
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
<List
dataSource={visibleItems}
split={false}
renderItem={({ item }) => {
const active = item.id === activeCompanyGroup?.id;
return (
<List.Item
onClick={() => dispatch(setActiveCompanyGroup(item))}
style={{
cursor: 'pointer',
borderRadius: 8,
padding: '10px 12px',
marginBottom: 4,
background: active ? '#e6f4ea' : 'transparent',
boxShadow: active ? 'inset 0 0 0 1px #2ea64b3d, inset 3px 0 0 #258141' : 'inset 0 0 0 1px #0f172a14',
transition: 'background 0.15s',
}}
onMouseEnter={(e) => {
if (!active) {
(e.currentTarget as HTMLElement).style.background = '#fafafa';
}
}}
onMouseLeave={(e) => {
if (!active) {
(e.currentTarget as HTMLElement).style.background = 'transparent';
}
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
width: '100%',
gap: 12,
minWidth: 0,
}}>
<FolderOpenOutlined style={{ fontSize: 18, color: '#8c8c8c', flexShrink: 0 }} />
<div style={{ flex: 1, minWidth: 0 }}>
<Text
strong
ellipsis
style={{
display: 'block',
fontSize: 14,
lineHeight: '20px',
}}>
{item.name}
</Text>
<Text
type='secondary'
ellipsis
style={{
display: 'block',
fontSize: 12,
lineHeight: '16px',
}}>
CRM ID: {item.crm_id ?? '-'}
</Text>
</div>
<Tag
style={{
margin: 0,
borderRadius: 10,
background: '#f5f5f5',
border: 'none',
color: '#595959',
fontWeight: 500,
minWidth: 24,
height: 20,
lineHeight: '20px',
textAlign: 'center',
padding: '0 8px',
flexShrink: 0,
}}>
{item.legal_entities_count}
</Tag>
</div>
</List.Item>
);
}}
/>
</div>
</div>
)}
</div>
</div>
);
};

View File

@ -0,0 +1,67 @@
/* Обёртка списка опций */
.options {
display: flex;
flex-direction: column;
gap: 12px;
}
/* Карточка */
.optionCard {
display: flex;
align-items: center;
gap: 16px;
padding: 12px;
background: #fff;
border: 1px solid #f0f0f0;
border-radius: 12px;
cursor: pointer;
text-align: left;
width: 100%;
font: inherit;
transition:
border-color 0.15s,
background 0.15s;
}
.optionCard:hover {
background: #f6fbf7;
border-color: #b7e0c2;
}
.optionCard:focus-visible {
outline: 2px solid #2e7d32;
outline-offset: 2px;
}
/* Иконка в круге */
.optionIcon {
width: 44px;
height: 44px;
border-radius: 10px;
background: #e6f4ea;
color: #2e7d32;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
/* Текстовый блок */
.optionText {
min-width: 0;
}
.optionTitle {
font-size: 16px;
font-weight: 600;
color: #1f1f1f;
line-height: 22px;
}
.optionDescription {
font-size: 13px;
color: #8c8c8c;
line-height: 18px;
margin-top: 2px;
}

View File

@ -0,0 +1,76 @@
import { ApartmentOutlined, BankOutlined, DeploymentUnitOutlined, TeamOutlined } from '@ant-design/icons';
import { Modal } from 'antd';
import type React from 'react';
import type { CreateEntityKind } from '../../types/relatedGroup/relatedGroup';
import './CreateEntityPickerModal.css';
type Option = {
key: CreateEntityKind;
title: string;
description: string;
icon: React.ReactNode;
};
const OPTIONS: Option[] = [
{
key: 'gk',
title: 'ГК',
description: 'Группа компаний',
icon: <BankOutlined />,
},
{
key: 'ul',
title: 'ЮЛ',
description: 'Юридическое лицо',
icon: <TeamOutlined />,
},
{
key: 'gsk',
title: 'ГСК',
description: 'Группа связанных компаний',
icon: <ApartmentOutlined />,
},
{
key: 'gsz',
title: 'ГСЗ',
description: 'Группа связанных заёмщиков',
icon: <DeploymentUnitOutlined />,
},
];
type Props = {
open: boolean;
onCancel: () => void;
onSelect: (kind: CreateEntityKind) => void;
};
export const CreateEntityPickerModal: React.FC<Props> = ({ open, onCancel, onSelect }) => {
return (
<Modal
title='Выберите объект для добавления'
open={open}
onCancel={onCancel}
footer={null}
centered
destroyOnHidden
styles={{
header: {
borderBottom: '1px solid #f0f0f0',
paddingBottom: 16,
marginBottom: 0,
},
body: { paddingTop: 16, paddingBottom: 16 },
}}>
<div className='options'>
{OPTIONS.map((opt) => (
<button key={opt.key} type='button' className='optionCard' onClick={() => onSelect(opt.key)}>
<div className='optionIcon'>{opt.icon}</div>
<div className='optionText'>
<div className='optionTitle'>{opt.title}</div>
<div className='optionDescription'>{opt.description}</div>
</div>
</button>
))}
</div>
</Modal>
);
};

View File

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

View File

@ -0,0 +1,132 @@
import { DatePicker, Form, Input, Modal, Select } from 'antd';
import FormItem from 'antd/es/form/FormItem';
import dayjs from 'dayjs';
import type React from 'react';
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { fetchOneCatalogueJur } from '../../services/api/directories/catalogue_jur';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { MoveCatalogueJurProps } from '../../types/catalogueJur/catalogueJur';
import { useNotificationApi } from '../../utils/useNotification';
type FormValues = {
name: string;
company_group_name: string;
company_group_id: string;
effective_date: string;
old_company_group_id: string;
};
type Props = {
open: boolean;
onCancel: () => void;
onOk: (values: MoveCatalogueJurProps) => void | Promise<void>;
jurId: string;
};
export const MoveCatalogueJurForm: React.FC<Props> = ({ open, onCancel, onOk, jurId }) => {
const [form] = Form.useForm<FormValues>();
const dispatch = useAppDispatch();
const notification = useNotificationApi();
const { raw_rows, loading } = useAppSelector((state) => state.company_groups);
const [initialDataLoading, setInitialDataLoading] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
async function getInitialData() {
setInitialDataLoading(true);
try {
const data = await fetchOneCatalogueJur(jurId);
form.setFieldsValue({
name: data?.name,
company_group_name: data?.company_group_name ?? '',
old_company_group_id: data?.company_group_id ?? '',
company_group_id: undefined,
effective_date: '',
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setInitialDataLoading(false);
}
}
const handleOk = async () => {
setConfirmLoading(true);
try {
const { effective_date, company_group_id } = await form.validateFields();
await onOk({ effective_date, company_group_id });
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
useEffect(() => {
if (open && raw_rows.length === 0 && !loading) {
dispatch(getCompanyGroupsThunk());
}
}, [open, raw_rows.length, loading, dispatch]);
useEffect(() => {
getInitialData();
}, [open]);
return (
<Modal
title='Перевести ЮЛ в другую ГК'
open={open}
onCancel={handleCancel}
onOk={handleOk}
okText='Перевести'
cancelText='Отмена'
destroyOnHidden
loading={initialDataLoading}
confirmLoading={confirmLoading}>
<Form form={form} layout='vertical'>
<FormItem name='name' label='ЮЛ'>
<Input disabled />
</FormItem>
<FormItem name='company_group_name' label='Текущая ГК'>
<Input disabled />
</FormItem>
<Form.Item name='company_group_id' label='Новая ГК' rules={[{ required: true, message: 'Укажите группу' }]}>
<Select
placeholder='Выбрать'
loading={loading}
showSearch
allowClear
options={raw_rows
.filter((row) => row.id !== form.getFieldValue('old_company_group_id'))
.map((g) => ({ value: g.id, label: g.name }))}
notFoundContent={loading ? 'Загрузка' : 'Нет вариантов'}
optionFilterProp='label'
/>
</Form.Item>
<Form.Item
name='effective_date'
label='Дата перевода'
rules={[{ required: true, message: 'Укажите дату' }]}
getValueProps={(value: string | undefined) => ({
value: value ? dayjs(value) : undefined,
})}
normalize={(value) => (value ? value.format('YYYY-MM-DD') : undefined)}>
<DatePicker minDate={dayjs()} style={{ width: '100%' }} format='DD.MM.YYYY' placeholder='ДД.ММ.ГГГГ' />
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,127 @@
import { DatePicker, Form, Input, Modal, Select } from 'antd';
import FormItem from 'antd/es/form/FormItem';
import dayjs from 'dayjs';
import type React from 'react';
import { useEffect, useMemo, useState } from 'react';
import { ApiError } from '../../api/client';
import { fetchRelatedGroup } from '../../services/api/directories/company_groups';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { RelatedGroupFormType } from '../../types/relatedGroup/relatedGroup';
import { useNotificationApi } from '../../utils/useNotification';
type Props = {
open: boolean;
onCancel: () => void;
onOk: (values: RelatedGroupFormType) => void | Promise<void>;
relatedGroupId?: string;
kind: 'gsk' | 'gsz';
};
export const RelatedGroupForm: React.FC<Props> = ({ open, onCancel, onOk, relatedGroupId, kind }) => {
const [form] = Form.useForm<RelatedGroupFormType>();
const notification = useNotificationApi();
const dispatch = useAppDispatch();
const [initialDataLoading, setInitialDataLoading] = useState(false);
const [confirmLoading, setConfirmLoading] = useState(false);
const companyGrops = useAppSelector((state) => state.company_groups.raw_rows);
const companyGropsLoading = useAppSelector((state) => state.company_groups.loading);
async function getInitialData() {
if (!relatedGroupId) return;
setInitialDataLoading(true);
try {
const data = await fetchRelatedGroup(relatedGroupId);
await dispatch(getCompanyGroupsThunk());
form.setFieldsValue({
crm_id: data?.crm_id,
name: data?.name,
created_date: data?.created_date ?? dayjs().format('YYYY-MM-DD'),
company_group_id: data?.company_group_id,
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setInitialDataLoading(false);
}
}
const handleOk = async () => {
setConfirmLoading(true);
try {
const { created_date, name, crm_id, company_group_id } = await form.validateFields();
await onOk({
created_date: created_date ?? dayjs().format('YYYY-MM-DD'),
name,
crm_id,
company_group_id: relatedGroupId ? undefined : company_group_id,
kind: relatedGroupId ? undefined : kind,
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
const companyGroupOptions = useMemo(() => {
return companyGrops?.map((company) => company && { value: company.id, label: company.name });
}, [companyGrops]);
useEffect(() => {
getInitialData();
}, [open]);
return (
<Modal
title={`${relatedGroupId ? 'Изменить' : 'Создать'} группу связанных ${kind === 'gsk' ? 'компаний' : 'заемщиков'}`}
open={open}
onCancel={handleCancel}
onOk={handleOk}
okText={`${relatedGroupId ? 'Сохранить' : 'Создать'}`}
cancelText='Отмена'
destroyOnHidden
loading={initialDataLoading}
confirmLoading={confirmLoading}>
<Form form={form} layout='vertical'>
<FormItem name='crm_id' label='ID в CRM'>
<Input placeholder='Генерируется автоматически' />
</FormItem>
<FormItem name='name' label='Наименование' rules={[{ required: true, message: 'Укажите наименование' }]}>
<Input />
</FormItem>
<Form.Item name='company_group_id' label='ГК' rules={[{ required: true, message: 'Выберите ГК' }]}>
<Select
disabled={!!relatedGroupId}
allowClear
loading={companyGropsLoading}
options={companyGroupOptions}
showSearch
optionFilterProp='label'
notFoundContent={companyGropsLoading ? 'Загрузка' : 'Нет вариантов'}
/>
</Form.Item>
<Form.Item
name='created_date'
label='Дата создания'
getValueProps={(value: string | undefined) => ({
value: value ? dayjs(value) : undefined,
})}
normalize={(value) => (value ? value.format('YYYY-MM-DD') : undefined)}>
<DatePicker style={{ width: '100%' }} format='DD.MM.YYYY' placeholder='ДД.ММ.ГГГГ' />
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,186 @@
import { 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';
import { ApiError } from '../../api/client';
import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { deleteRelatedGroup, editRelatedGroup } from '../../services/api/directories/related-groups';
import type { RelatedGroupOut } from '../../types/api';
import type { RelatedGroupFormType } from '../../types/relatedGroup/relatedGroup';
import { useNotificationApi } from '../../utils/useNotification';
import { DeleteRelatedGroupModal } from '../DeleteRelatedGroupModal/DeleteRelatedGroupModal';
import { RelatedGroupForm } from '../RelatedGroupForm/RelatedGroupForm';
import { relateGroupConfig } from './config';
interface RelatedGroupTableI {
tabsComponent?: ReactNode;
data: RelatedGroupOut[];
loading: boolean;
getData: () => void;
activeTab: 'gsk' | 'gsz';
}
export function RelatedGroupTable({ tabsComponent, data, loading, getData, activeTab }: RelatedGroupTableI) {
const notification = useNotificationApi();
const [editFormOpen, setEditFormOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [deletingLoading, setDeletingLoading] = useState(false);
const [groupId, setGroupId] = useState('');
const { visibleKeys, setVisibleKeys } = useVisibleColumns(relateGroupConfig);
const { rawQuery, columnFilters, setColumnFilters, sort, setSort, resetAll, hasActiveFilters } = useTableFilters<RelatedGroupOut>();
// single filtered array — title count and table share it
const { rows } = useFilteredRows(data, relateGroupConfig, rawQuery, columnFilters, sort);
function closeEditRelatedGroupHandler() {
setGroupId('');
setEditFormOpen(false);
}
function closeDeleteRelatedGroupHandler() {
setGroupId('');
setDeleteModalOpen(false);
}
async function deleteRelatedGroupHandler() {
try {
setDeletingLoading(true);
await deleteRelatedGroup(groupId);
getData();
setDeleteModalOpen(false);
setGroupId('');
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setDeletingLoading(false);
}
}
async function editRelatedGroupHandler(values: RelatedGroupFormType) {
try {
await editRelatedGroup(groupId, values);
getData();
closeEditRelatedGroupHandler();
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
const extraColumns: ColumnsType<RelatedGroupOut> = useMemo(
() => [
{
key: 'actions',
title: '',
width: 30,
render: (_: unknown, row: RelatedGroupOut) => {
const items = [
{
key: 'edit',
label: 'Изменить',
onClick: () => {
setGroupId(row.id);
setEditFormOpen(true);
},
},
{
key: 'delete',
label: 'Удалить',
danger: true,
onClick: () => {
setGroupId(row.id);
setDeleteModalOpen(true);
},
},
];
return (
<Dropdown menu={{ items }} trigger={['click']}>
<Button type='default' style={{ width: '32px' }}>
<EllipsisOutlined />
</Button>
</Dropdown>
);
},
},
],
[],
);
return (
<div
style={{
width: '100%',
display: 'flex',
flexDirection: 'column',
height: '100%',
background: 'white',
borderRadius: 12,
}}>
<Space
style={{
padding: '0 12px',
width: '100%',
justifyContent: 'space-between',
borderBottom: '1px',
}}>
{tabsComponent}
<Space>
{hasActiveFilters && (
<Button
type='default'
onClick={() => {
resetAll();
}}>
Сбросить фильтры
</Button>
)}
<ColumnSettings defs={relateGroupConfig.columns} visibleKeys={visibleKeys} onChange={setVisibleKeys} />
</Space>
</Space>
<DataTable
sortState={sort}
onSortChange={setSort}
config={relateGroupConfig}
loading={loading}
rows={rows}
allRows={data}
visibleKeys={visibleKeys}
globalQuery={rawQuery}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
extraColumns={extraColumns}
/>
{editFormOpen && (
<RelatedGroupForm
kind={activeTab}
relatedGroupId={groupId}
open={editFormOpen}
onCancel={() => {
closeEditRelatedGroupHandler();
}}
onOk={async (values) => {
await editRelatedGroupHandler(values);
}}
/>
)}
{deleteModalOpen && (
<DeleteRelatedGroupModal
open={deleteModalOpen}
loading={deletingLoading}
onCancel={() => closeDeleteRelatedGroupHandler()}
onOk={deleteRelatedGroupHandler}
kind={activeTab}
/>
)}
</div>
);
}

View File

@ -0,0 +1,42 @@
import { VerticalAlignTopOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import type { TableConfig } from '../../components/GenericTable';
import { setRelatedGroupFilter } from '../../store/slices/catalogueJurSlice/catalogueJurSlice';
import { store } from '../../store/store';
import type { RelatedGroupOut } from '../../types/api';
export const relateGroupConfig: TableConfig<RelatedGroupOut> = {
rowKey: 'id',
searchableKeys: ['code', 'name', 'members_count', 'created_date'],
columns: [
{
key: 'code',
title: 'ID',
features: { search: true, multiselect: true },
},
{
key: 'name',
title: 'Наименование',
features: { search: true, multiselect: true },
render: (val, row) => {
const onClick = () => store.dispatch(setRelatedGroupFilter(row));
return (
<Button icon={<VerticalAlignTopOutlined />} iconPlacement='end' type='link' onClick={onClick}>
{val}
</Button>
);
},
},
{
key: 'members_count',
title: 'Число участников',
features: { search: true, multiselect: true, sort: true },
},
{
key: 'created_date',
title: 'Дата создания',
render: (val) => {
return new Date(val as string).toLocaleDateString('ru-RU');
},
},
],
};

View File

@ -0,0 +1,115 @@
import { DatePicker, Form, Input, Modal, Select } from 'antd';
import FormItem from 'antd/es/form/FormItem';
import TextArea from 'antd/es/input/TextArea';
import dayjs from 'dayjs';
import type React from 'react';
import { useEffect, useState } from 'react';
import { ApiError } from '../../api/client';
import { fetchOneCatalogueJur } from '../../services/api/directories/catalogue_jur';
import type { WithdrawCatalogueJurProps } from '../../types/catalogueJur/catalogueJur';
import { useNotificationApi } from '../../utils/useNotification';
interface FormValues extends WithdrawCatalogueJurProps {
name: string;
}
type Props = {
open: boolean;
onCancel: () => void;
onOk: (values: WithdrawCatalogueJurProps) => void | Promise<void>;
jurId: string;
};
export const WithdrawCatalogueJurForm: React.FC<Props> = ({ open, onCancel, onOk, jurId }) => {
const [form] = Form.useForm<FormValues>();
const notification = useNotificationApi();
const [confirmLoading, setConfirmLoading] = useState(false);
const [initialDataLoading, setInitialDataLoading] = useState(false);
const eventOptions = [
{ value: 'CLOSE', label: 'Закрытие' },
{ value: 'REMOVE_FROM_GROUP', label: 'Переход в другую ГК' },
];
async function getInitialData() {
setInitialDataLoading(true);
try {
const data = await fetchOneCatalogueJur(jurId);
form.setFieldsValue({
name: data?.name,
event: undefined,
effective_date: '',
comment: '',
});
} catch (e) {
if (e instanceof ApiError) {
notification.error({ description: e.message });
}
} finally {
setInitialDataLoading(false);
}
}
const handleOk = async () => {
setConfirmLoading(true);
try {
const { effective_date, event, comment = '' } = await form.validateFields();
await onOk({ effective_date, event, comment });
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setConfirmLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
onCancel();
};
useEffect(() => {
getInitialData();
}, [open]);
return (
<Modal
title='Вывести ЮЛ из состава ГК'
open={open}
onCancel={handleCancel}
onOk={handleOk}
okText='Вывести'
cancelText='Отмена'
destroyOnHidden
loading={initialDataLoading}
confirmLoading={confirmLoading}
okButtonProps={{ danger: true }}>
<Form form={form} layout='vertical'>
<FormItem name='name' label='ЮЛ'>
<Input disabled />
</FormItem>
<Form.Item name='event' label='Событие' rules={[{ required: true, message: 'Укажите событие' }]}>
<Select placeholder='Выбрать' showSearch allowClear options={eventOptions} />
</Form.Item>
<Form.Item
name='effective_date'
label='Дата закрытия'
rules={[{ required: true, message: 'Укажите дату' }]}
getValueProps={(value: string | undefined) => ({
value: value ? dayjs(value) : undefined,
})}
normalize={(value) => (value ? value.format('YYYY-MM-DD') : undefined)}>
<DatePicker minDate={dayjs()} style={{ width: '100%' }} format='DD.MM.YYYY' placeholder='ДД.ММ.ГГГГ' />
</Form.Item>
<FormItem name='comment' label='Комментарий'>
<TextArea rows={4} placeholder='Необязательно' />
</FormItem>
</Form>
</Modal>
);
};