added company group table #31
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)),
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
46
src/hooks/useVirtualList.tsx
Normal file
46
src/hooks/useVirtualList.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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)',
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
34
src/services/api/directories/catalogue_jur.ts
Normal file
34
src/services/api/directories/catalogue_jur.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
11
src/services/api/directories/related-groups.ts
Normal file
11
src/services/api/directories/related-groups.ts
Normal 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}`);
|
||||||
44
src/store/slices/catalogueJurSlice/catalogueJurSlice.ts
Normal file
44
src/store/slices/catalogueJurSlice/catalogueJurSlice.ts
Normal 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;
|
||||||
16
src/store/slices/catalogueJurSlice/thunks.ts
Normal file
16
src/store/slices/catalogueJurSlice/thunks.ts
Normal 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 : 'Ошибка');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
17
src/store/slices/catalogueJurSlice/types.ts
Normal file
17
src/store/slices/catalogueJurSlice/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
64
src/types/catalogueJur/catalogueJur.ts
Normal file
64
src/types/catalogueJur/catalogueJur.ts
Normal 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;
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
9
src/types/relatedGroup/relatedGroup.ts
Normal file
9
src/types/relatedGroup/relatedGroup.ts
Normal 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';
|
||||||
@ -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('Строка формы');
|
||||||
|
|||||||
@ -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: 'Код не распознан',
|
||||||
|
|||||||
24
src/utils/useNotification.tsx
Normal file
24
src/utils/useNotification.tsx
Normal 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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -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`), активность — переключателем
|
||||||
|
|||||||
9
src/widgets/CataloguGroupTabs/CataloguGroupTabs.css
Normal file
9
src/widgets/CataloguGroupTabs/CataloguGroupTabs.css
Normal 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;
|
||||||
|
}
|
||||||
33
src/widgets/CataloguGroupTabs/CataloguGroupTabs.tsx
Normal file
33
src/widgets/CataloguGroupTabs/CataloguGroupTabs.tsx
Normal 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}` },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
33
src/widgets/CatalogueGroupHeader/CatalogueGroupHeader.tsx
Normal file
33
src/widgets/CatalogueGroupHeader/CatalogueGroupHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
286
src/widgets/CatalogueJurForm/CatalogueJurForm.tsx
Normal file
286
src/widgets/CatalogueJurForm/CatalogueJurForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
339
src/widgets/CatalogueJurTable/CatalogueJurTable.tsx
Normal file
339
src/widgets/CatalogueJurTable/CatalogueJurTable.tsx
Normal 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}
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/widgets/CatalogueJurTable/config.tsx
Normal file
51
src/widgets/CatalogueJurTable/config.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
109
src/widgets/CompanyGroupForm/CompanyGroupForm.tsx
Normal file
109
src/widgets/CompanyGroupForm/CompanyGroupForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
4
src/widgets/CompanyGroupsList/CompanyGroupsList.css
Normal file
4
src/widgets/CompanyGroupsList/CompanyGroupsList.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
.ant-listy-item {
|
||||||
|
border: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
164
src/widgets/CompanyGroupsList/CompanyGroupsList.tsx
Normal file
164
src/widgets/CompanyGroupsList/CompanyGroupsList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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;
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
132
src/widgets/MoveCatalogueJurForm/MoveCatalogueJurForm.tsx
Normal file
132
src/widgets/MoveCatalogueJurForm/MoveCatalogueJurForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
127
src/widgets/RelatedGroupForm/RelatedGroupForm.tsx
Normal file
127
src/widgets/RelatedGroupForm/RelatedGroupForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
186
src/widgets/RelatedGroupTable/RelatedGroupTable.tsx
Normal file
186
src/widgets/RelatedGroupTable/RelatedGroupTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
src/widgets/RelatedGroupTable/config.tsx
Normal file
42
src/widgets/RelatedGroupTable/config.tsx
Normal 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');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
Loading…
x
Reference in New Issue
Block a user