added company group table #31
@ -1,5 +1,6 @@
|
||||
import './App.css';
|
||||
import { Layout } from 'antd';
|
||||
import { App as AntApp } from 'antd';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||
import { AuthGate } from './auth/AuthGate';
|
||||
@ -24,6 +25,7 @@ import { Sidebar } from './widgets/Sidebar/Sidebar';
|
||||
function App() {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<AntApp>
|
||||
<BrowserRouter basename={Config.APP_ADDRESS}>
|
||||
<AuthGate>
|
||||
<Layout hasSider={true} style={{ height: '100vh' }}>
|
||||
@ -48,6 +50,7 @@ function App() {
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
</AntApp>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -28,6 +28,17 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
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": …}` в одной из трёх форм:
|
||||
* строка (HTTPException) → message; объект `{code, message}` (доменная ошибка) → code + message;
|
||||
@ -127,7 +138,7 @@ function withJsonBody(method: string, body: unknown): RequestInit {
|
||||
}
|
||||
|
||||
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)),
|
||||
patch: <T>(path: string, body?: unknown) => apiFetch<T>(path, withJsonBody('PATCH', 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)',
|
||||
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;
|
||||
|
||||
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 './index.css';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import locale from 'antd/locale/ru_RU';
|
||||
import dayjs from 'dayjs';
|
||||
import App from './App';
|
||||
|
||||
import 'dayjs/locale/ru';
|
||||
|
||||
dayjs.locale('ru');
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Не найден корневой элемент #root');
|
||||
@ -11,6 +17,7 @@ if (!rootElement) {
|
||||
createRoot(rootElement).render(
|
||||
// <StrictMode>
|
||||
<ConfigProvider
|
||||
locale={locale}
|
||||
theme={{
|
||||
token: {
|
||||
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() {
|
||||
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() {
|
||||
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 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 LE_PAGE = 200;
|
||||
export const LE_PAGE = 200;
|
||||
|
||||
/** Все видимые ГК — дочитываем страницы, пока не получим total. */
|
||||
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 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 type { CompanyGroupSliceType } from './types';
|
||||
|
||||
@ -7,12 +8,17 @@ const initialState: CompanyGroupSliceType = {
|
||||
total_groups: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
activeCompanyGroup: null,
|
||||
};
|
||||
|
||||
const comGroupsSlice = createSlice({
|
||||
name: 'company_groups',
|
||||
initialState,
|
||||
reducers: {},
|
||||
reducers: {
|
||||
setActiveCompanyGroup(state, action: PayloadAction<ICompanyGroup | null>) {
|
||||
state.activeCompanyGroup = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(getCompanyGroupsThunk.pending, (state) => {
|
||||
@ -21,6 +27,10 @@ const comGroupsSlice = createSlice({
|
||||
})
|
||||
.addCase(getCompanyGroupsThunk.fulfilled, (state, action) => {
|
||||
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.loading = false;
|
||||
})
|
||||
@ -30,5 +40,5 @@ const comGroupsSlice = createSlice({
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const { setActiveCompanyGroup } = comGroupsSlice.actions;
|
||||
export default comGroupsSlice.reducer;
|
||||
|
||||
@ -5,4 +5,5 @@ export interface CompanyGroupSliceType {
|
||||
raw_rows: ICompanyGroup[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
activeCompanyGroup?: ICompanyGroup | null;
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import catalogueJurReducer from './slices/catalogueJurSlice/catalogueJurSlice';
|
||||
import comGroupsReducer from './slices/companyGroupsSlice/companyGroupsSlice';
|
||||
import consolidationsReducer from './slices/consolidations/consolidationsSlice';
|
||||
|
||||
@ -7,6 +8,7 @@ export const store = configureStore({
|
||||
reducer: {
|
||||
company_groups: comGroupsReducer,
|
||||
consolidations: consolidationsReducer,
|
||||
catalogue_jur: catalogueJurReducer,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@ -384,6 +384,7 @@ export interface RelatedGroupMemberOut {
|
||||
}
|
||||
export interface RelatedGroupDetail extends RelatedGroupOut {
|
||||
members: RelatedGroupMemberOut[];
|
||||
crm_id: string;
|
||||
}
|
||||
export interface LegalEntityBrief {
|
||||
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,
|
||||
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,
|
||||
matchKindLabel,
|
||||
periodLabel,
|
||||
pluralRu,
|
||||
quarterLabel,
|
||||
severityLabel,
|
||||
} 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', () => {
|
||||
it('статус строки', () => {
|
||||
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 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> = {
|
||||
ok: 'Строка формы',
|
||||
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)
|
||||
const loadUsers = () => api.get<Page<AdminUser>>(`/admin/users?limit=${USERS_LIMIT}`);
|
||||
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`), активность — переключателем
|
||||
|
||||
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