updated company group and forms

This commit is contained in:
Grog 2026-10-15 16:11:02 +03:00
parent 83d4c2b894
commit 71dae2173d
30 changed files with 1492 additions and 222 deletions

View File

@ -1,55 +1,67 @@
import './App.css';
import { Layout } from 'antd';
import { Provider } from 'react-redux';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { AuthGate } from './auth/AuthGate';
import { Config } from './conf/config';
import "./App.css";
import { Layout } from "antd";
import { Provider } from "react-redux";
import { BrowserRouter, Route, Routes } from "react-router-dom";
import { AuthGate } from "./auth/AuthGate";
import { Config } from "./conf/config";
import {
Admin,
AuditLogging,
CatalogueGroup,
CatalogueJur,
ConsolidatedOsvPage,
ConsolidationPage,
DocStorage,
DocumentPage,
GroupsLayout,
Main,
Mono,
} from './layouts/';
import { store } from './store/store';
import { Sidebar } from './widgets/Sidebar/Sidebar';
Admin,
AuditLogging,
CatalogueGroup,
CatalogueJur,
ConsolidatedOsvPage,
ConsolidationPage,
DocStorage,
DocumentPage,
GroupsLayout,
Main,
Mono,
} from "./layouts/";
import { store } from "./store/store";
import { Sidebar } from "./widgets/Sidebar/Sidebar";
import { App as AntApp } from "antd";
// Каркас: экраны по docs/context/10-user-journey.md добавляются срезами.
function App() {
return (
<Provider store={store}>
<BrowserRouter basename={Config.APP_ADDRESS}>
<AuthGate>
<Layout hasSider={true} style={{ height: '100vh' }}>
<Sidebar />
<Layout.Content>
<Routes>
<Route path='/admin' element={<Admin />} />
<Route path='/' element={<Main />} />
<Route path='/groups'>
<Route index element={<GroupsLayout />} />
<Route path=':group_id' element={<ConsolidationPage />} />
<Route path=':group_id/versions/:version_id/osv' element={<ConsolidatedOsvPage />} />
<Route path='monozaemchiki' element={<Mono />} />
</Route>
<Route path='/catalogue/groups' element={<CatalogueGroup />} />
<Route path='/catalogue/jur_l' element={<CatalogueJur />} />
<Route path='/docstorage' element={<DocStorage />} />
<Route path='/documents/:document_id' element={<DocumentPage />} />
<Route path='/logging' element={<AuditLogging />} />
</Routes>
</Layout.Content>
</Layout>
</AuthGate>
</BrowserRouter>
</Provider>
);
return (
<Provider store={store}>
<AntApp>
<BrowserRouter basename={Config.APP_ADDRESS}>
<AuthGate>
<Layout hasSider={true} style={{ height: "100vh" }}>
<Sidebar />
<Layout.Content>
<Routes>
<Route path="/admin" element={<Admin />} />
<Route path="/" element={<Main />} />
<Route path="/groups">
<Route index element={<GroupsLayout />} />
<Route path=":group_id" element={<ConsolidationPage />} />
<Route
path=":group_id/versions/:version_id/osv"
element={<ConsolidatedOsvPage />}
/>
<Route path="monozaemchiki" element={<Mono />} />
</Route>
<Route
path="/catalogue/groups"
element={<CatalogueGroup />}
/>
<Route path="/catalogue/jur_l" element={<CatalogueJur />} />
<Route path="/docstorage" element={<DocStorage />} />
<Route
path="/documents/:document_id"
element={<DocumentPage />}
/>
<Route path="/logging" element={<AuditLogging />} />
</Routes>
</Layout.Content>
</Layout>
</AuthGate>
</BrowserRouter>
</AntApp>
</Provider>
);
}
export default App;

View File

@ -2,31 +2,139 @@ import { Button, Space } from "antd";
import PageTitle from "../components/PageTitle";
import { CatalogueJurTable } from "../widgets/CatalogueJurTable/CatalogueJurTable";
import { PlusOutlined } from "@ant-design/icons";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../store/store";
import { CatalogueJurForm } from "../widgets/CatalogueJurForm/CatalogueJurForm";
import { getCatalogueJurThunk } from "../store/slices/catalogueJurSlice/thunks";
import { CatalogueJurFormProps } from "../types/catalogueJur/catalogueJur";
import { createCatalogueJur } from "../services/api/directories/catalogue_jur";
import { CompanyGroupsList } from "../widgets/CompanyGroupsList/CompanyGroupsList";
import { RelatedGroupOut } from "../types/api";
import {
createCompanyGroup,
fetchRelatedGroups,
} from "../services/api/directories/company_groups";
import { RelatedGroupTable } from "../widgets/RelatedGroupTable/RelatedGroupTable";
import { CreateEntityPickerModal } from "../widgets/CreateEntityPickerModal/CreateEntityPickerModal";
import {
CreateEntityKind,
RelatedGroupFormType,
} from "../types/relatedGroup/relatedGroup";
import { createRelatedGroup } from "../services/api/directories/related-groups";
import { RelatedGroupForm } from "../widgets/RelatedGroupForm/RelatedGroupForm";
import { CompanyGroupForm } from "../widgets/CompanyGroupForm/CompanyGroupForm";
import { CreateCompanyGroupProps } from "../types/companyGroups/companyGroups";
import { getCompanyGroupsThunk } from "../store/slices/companyGroupsSlice/thunks";
import { setActiveCompanyGroup } from "../store/slices/companyGroupsSlice/companyGroupsSlice";
import { useNotificationApi } from "../utils/useNotification";
import { ApiError } from "../api/client";
import { CompanyGroupHeader } from "../widgets/CatalogueGroupHeader/CatalogueGroupHeader";
import {
CatalogueGroupTabKey,
CataloguGroupTabs,
} from "../widgets/CataloguGroupTabs/CataloguGroupTabs";
export function CatalogueGroup() {
const dispatch = useAppDispatch();
const notification = useNotificationApi();
const isGettingArchive = useAppSelector(
(state) => state.catalogue_jur.isGettingArchive,
);
const [createFormOpen, setCreateFormOpen] = useState(false);
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: true }),
getCatalogueJurThunk({
closed: isGettingArchive,
outside_group: false,
company_group_id: activeCompanyGroup?.id,
}),
);
setCreateFormOpen(false);
} catch {}
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() {
setRelatedGroupLoading(true);
if (activeCompanyGroup?.id) {
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);
}
setRelatedGroupLoading(false);
}
useEffect(() => {
getRelatedGroupsHandler();
}, [activeCompanyGroup]);
useEffect(() => {
setActiveTab("legal_entities");
}, [relatedGroupFilter]);
useEffect(() => {
if (!activeCompanyGroup && rawRows?.length) {
dispatch(setActiveCompanyGroup(rawRows?.[0]));
}
}, [rawRows, activeCompanyGroup]);
return (
<div
@ -45,7 +153,7 @@ export function CatalogueGroup() {
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setCreateFormOpen(true)}
onClick={() => setEntityCreateModalOpen(true)}
>
Создать ГК/ЮЛ
</Button>
@ -59,21 +167,94 @@ export function CatalogueGroup() {
}}
>
<div style={{ width: 320, flexShrink: 0, minHeight: 0 }}>
<CompanyGroupsList/>
<CompanyGroupsList />
</div>
<div style={{ flex: 1, minWidth: 0, minHeight: 0, borderRadius:12, background:'white' }}>
<CatalogueJurTable outside_group={false} />
<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>
{createFormOpen && (
<CreateEntityPickerModal
open={entityCreateModalOpen}
onCancel={() => {
setEntityCreateModalOpen(false);
}}
onSelect={(kind) => {
setKindForm(kind);
setEntityCreateModalOpen(false);
}}
/>
{kindForm === "ul" && (
<CatalogueJurForm
open={createFormOpen}
open={kindForm === "ul"}
onCancel={() => {
setCreateFormOpen(false);
setKindForm(null);
}}
onOk={(values) => {
createJurHandler(values);
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);
}}
/>
)}

View File

@ -8,23 +8,36 @@ import { CatalogueJurForm } from "../widgets/CatalogueJurForm/CatalogueJurForm";
import { getCatalogueJurThunk } from "../store/slices/catalogueJurSlice/thunks";
import { CatalogueJurFormProps } from "../types/catalogueJur/catalogueJur";
import { createCatalogueJur } from "../services/api/directories/catalogue_jur";
import { ApiError } from "../api/client";
import { useNotificationApi } from "../utils/useNotification";
export function CatalogueJur() {
const dispatch = useAppDispatch();
const notification = useNotificationApi();
const isGettingArchive = useAppSelector(
(state) => state.catalogue_jur.isGettingArchive,
);
const [createFormOpen, setCreateFormOpen] = useState(false);
const outside_group = true
const outside_group = true;
async function createJurHandler(values: CatalogueJurFormProps) {
try {
await createCatalogueJur(values);
dispatch(
getCatalogueJurThunk({ closed: isGettingArchive, outside_group: outside_group }),
getCatalogueJurThunk({
closed: isGettingArchive,
outside_group: outside_group,
}),
);
setCreateFormOpen(false);
} catch {}
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
return (
@ -46,15 +59,15 @@ export function CatalogueJur() {
Создать ЮЛ
</Button>
</Space>
<CatalogueJurTable outside_group={outside_group}/>
<CatalogueJurTable outside_group={outside_group} />
{createFormOpen && (
<CatalogueJurForm
open={createFormOpen}
onCancel={() => {
setCreateFormOpen(false);
}}
onOk={(values) => {
createJurHandler(values);
onOk={async(values) => {
await createJurHandler(values);
}}
/>
)}

View File

@ -1,6 +1,6 @@
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;
@ -36,3 +36,12 @@ export async function fetchAllLegalEntities(groupId: string): Promise<LegalEntit
}
export const fetchLegalEntity = (id: string) => api.get<LegalEntityBrief>(`/directories/legal-entities/${id}`);
export async function createCompanyGroup(
body: CreateCompanyGroupProps,
): Promise<void> {
return api.post<void>(
`/directories/company-groups`,
body,
);
}

View File

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

View File

@ -1,6 +1,7 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { CatalogueJurSliceType } from "./types";
import { getCatalogueJurThunk } from "./thunks";
import { RelatedGroupOut } from "../../../types/api";
const initialState: CatalogueJurSliceType = {
raw_rows: [],
@ -8,6 +9,7 @@ const initialState: CatalogueJurSliceType = {
loading: false,
error: null,
isGettingArchive: false,
relatedGroupFilter: null,
};
const catalogueJurSlice = createSlice({
@ -17,6 +19,9 @@ const catalogueJurSlice = createSlice({
setIsGettingArchive(state, action: PayloadAction<boolean>) {
state.isGettingArchive = action.payload;
},
setRelatedGroupFilter(state, action: PayloadAction<RelatedGroupOut | null>) {
state.relatedGroupFilter = action.payload;
},
},
extraReducers: (builder) => {
builder
@ -35,5 +40,5 @@ const catalogueJurSlice = createSlice({
});
},
});
export const { setIsGettingArchive } = catalogueJurSlice.actions;
export const { setIsGettingArchive, setRelatedGroupFilter } = catalogueJurSlice.actions;
export default catalogueJurSlice.reducer;

View File

@ -1,3 +1,4 @@
import { RelatedGroupOut } from "../../../types/api";
import { ICatalogueJur } from "../../../types/catalogueJur/catalogueJur";
export interface CatalogueJurSliceType{
@ -5,7 +6,8 @@ export interface CatalogueJurSliceType{
raw_rows: ICatalogueJur[];
loading: boolean;
error: string | null;
isGettingArchive:boolean
isGettingArchive:boolean;
relatedGroupFilter?:RelatedGroupOut | null
}

View File

@ -27,6 +27,12 @@ const comGroupsSlice = createSlice({
})
.addCase(getCompanyGroupsThunk.fulfilled, (state, action) => {
state.raw_rows = action.payload;
if (state.activeCompanyGroup?.id) {
let newActiveCompanyGroup = action.payload.find(
(elem) => elem.id === state.activeCompanyGroup?.id,
);
state.activeCompanyGroup = newActiveCompanyGroup;
}
state.total_groups = action.payload.length;
state.loading = false;
})

View File

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

View File

@ -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;
}

View File

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

View File

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

View File

@ -12,7 +12,7 @@ const USERS_LIMIT = 200;
// Загрузчики — функции уровня модуля: они не меняются между отрисовками (требование useAsyncData)
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`), активность — переключателем

View File

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

View File

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

View File

@ -0,0 +1,40 @@
import { Space } from "antd";
import Title from "antd/es/typography/Title";
import Text from "antd/es/typography/Text";
import { useAppSelector } from "../../store/store";
export const CompanyGroupHeader = () => {
const activeCompanyGroup = useAppSelector(
(state) => state.company_groups.activeCompanyGroup,
);
const count = activeCompanyGroup?.legal_entities_count ?? 0;
const organizationWord =
count === 1
? "организация"
: count > 1 && count < 5
? "организации"
: "организаций";
return (
<div
style={{
padding: "6px 16px",
borderBottom: "1px solid #0f172a0f",
flexShrink: 0,
}}
>
<Title level={5} style={{ margin: 0 }}>
{activeCompanyGroup?.name ?? ""}
</Title>
<Space>
<Text type="secondary" style={{ fontSize: 13 }}>
{activeCompanyGroup?.base_regional_office_name ?? ""}
</Text>
<Text type="secondary" style={{ fontSize: 13 }}>
{count ?? 0} {organizationWord}
</Text>
</Space>
</div>
);
};

View File

@ -1,12 +1,13 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { Modal, Form, Input, Select, Row, Col, InputNumber } from "antd";
import { CatalogueJurFormProps } from "../../types/catalogueJur/catalogueJur";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
import { useAppSelector } from "../../store/store";
import { fetchOneCatalogueJur } from "../../services/api/directories/catalogue_jur";
import { fetchRelatedGroups } from "../../services/api/directories/company_groups";
import { RelatedGroupOut } from "../../types/api";
import { useMe } from "../../auth/useMe";
import { useNotificationApi } from "../../utils/useNotification";
import { ApiError } from "../../api/client";
import { loadOffices } from "../Admin/UsersTable";
type Props = {
open: boolean;
@ -22,25 +23,29 @@ export const CatalogueJurForm: React.FC<Props> = ({
jurId,
}) => {
const [form] = Form.useForm<CatalogueJurFormProps>();
const dispatch = useAppDispatch();
const { me } = useMe();
const type = Form.useWatch("entity_type", form);
const activeCompanyGroup = useAppSelector(
(state) => state.company_groups.activeCompanyGroup,
);
const notification = useNotificationApi();
const [initialDataLoading, setInitialDataLoading] = useState(false);
const [gskData, setGskData] = useState<RelatedGroupOut[]>([]);
const [gszData, setGszData] = useState<RelatedGroupOut[]>([]);
const [loadingOptions, setLoadingOptions] = useState(false);
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() {
setInitialDataLoading(true);
form.setFieldsValue({
company_group_id: jurId ? undefined : activeCompanyGroup?.id,
regional_office_id: me?.regional_office.id,
});
if (jurId) {
setInitialDataLoading(true);
const data = await fetchOneCatalogueJur(jurId);
form.setFieldsValue({
name: data?.name,
@ -50,38 +55,57 @@ export const CatalogueJurForm: React.FC<Props> = ({
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,
});
}
setInitialDataLoading(false);
setInitialDataLoading(false);
}
}
async function getOfficesOptionsHandler() {
setLoadingOfficesOptions(true);
const res = await loadOffices();
const options = res.map(
(elem) => elem && { value: elem.id, label: elem.name },
);
setOfficesOptions(options);
setLoadingOfficesOptions(false);
}
async function getRelatedGroupsHandler() {
if (activeCompanyGroup?.id) {
setLoadingOptions(true);
const res = await fetchRelatedGroups(activeCompanyGroup?.id);
if (companyGroupId) {
setLoadingRelatedGroupsOptions(true);
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);
setLoadingOptions(false);
setLoadingRelatedGroupsOptions(false);
}
}
const handleOk = async () => {
setConfirmLoading(true);
try {
const values = await form.validateFields();
await onOk({
...values,
kpp: type === "ИП" ? undefined : values.kpp?.toString(),
inn: jurId ? undefined : values?.inn?.toString(),
company_group_id: jurId ? undefined : values.company_group_id,
});
} catch {
return;
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setConfirmLoading(false);
}
};
@ -90,19 +114,22 @@ export const CatalogueJurForm: React.FC<Props> = ({
onCancel();
};
useEffect(() => {
if (open) {
dispatch(getCompanyGroupsThunk());
}
}, [open, dispatch]);
const companyGroupOptions = useMemo(() => {
return companyGrops?.map(
(company) => company && { value: company.id, label: company.name },
);
}, [companyGrops]);
useEffect(() => {
getInitialData();
}, [open]);
}, [jurId, open]);
useEffect(() => {
getOfficesOptionsHandler();
}, []);
useEffect(() => {
getRelatedGroupsHandler();
}, [activeCompanyGroup]);
}, [companyGroupId]);
return (
<Modal
@ -116,6 +143,7 @@ export const CatalogueJurForm: React.FC<Props> = ({
cancelText="Отмена"
destroyOnHidden
width={720}
confirmLoading={confirmLoading}
loading={initialDataLoading}
>
<Form form={form} layout="vertical">
@ -124,16 +152,23 @@ export const CatalogueJurForm: React.FC<Props> = ({
<Form.Item
name="inn"
label="ИНН"
rules={[
{ required: true, message: "Укажите ИНН" },
{ pattern: /^\d{10}$/, message: "10 цифр" },
]}
rules={
type === "ИП"
? [
{ required: true, message: "Укажите ИНН" },
{ pattern: /^\d{12}$/, message: "12 цифр" },
]
: [
{ required: true, message: "Укажите ИНН" },
{ pattern: /^\d{10}$/, message: "10 цифр" },
]
}
>
<InputNumber
disabled={!!jurId}
style={{ width: "100%" }}
placeholder="0000000000"
maxLength={10}
placeholder={type === "ИП" ? "000000000000" : "0000000000"}
maxLength={type === "ИП" ? 12 : 10}
/>
</Form.Item>
</Col>
@ -155,10 +190,7 @@ export const CatalogueJurForm: React.FC<Props> = ({
<Form.Item
name="crm_id"
label="ID ЮЛ в CRM"
rules={[
{ required: true, message: "Укажите CRM ID" },
{ pattern: /^\d{11}$/, message: "11 цифр" },
]}
rules={[{ len: 11, message: "Должно быть 11 символов" }]}
>
<Input maxLength={11} />
</Form.Item>
@ -171,6 +203,7 @@ export const CatalogueJurForm: React.FC<Props> = ({
rules={[{ required: true }]}
>
<Select
disabled={!!jurId}
options={[
{ value: "ЮЛ", label: "ЮЛ" },
{ value: "ИП", label: "ИП" },
@ -203,6 +236,38 @@ export const CatalogueJurForm: React.FC<Props> = ({
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}>
@ -213,7 +278,9 @@ export const CatalogueJurForm: React.FC<Props> = ({
options={gskData?.map((g) => ({ value: g.id, label: g.name }))}
showSearch
optionFilterProp="label"
notFoundContent={loadingOptions ? "Загрузка" : "Нет вариантов"}
notFoundContent={
loadingRelatedGroupsOptions ? "Загрузка" : "Нет вариантов"
}
/>
</Form.Item>
</Col>
@ -228,7 +295,9 @@ export const CatalogueJurForm: React.FC<Props> = ({
}))}
showSearch
optionFilterProp="label"
notFoundContent={loadingOptions ? "Загрузка" : "Нет вариантов"}
notFoundContent={
loadingRelatedGroupsOptions ? "Загрузка" : "Нет вариантов"
}
/>
</Form.Item>
</Col>

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { ReactNode, useEffect, useMemo, useState } from "react";
import { Button, Checkbox, Dropdown, Input, Space, Tabs } from "antd";
import { useNavigate } from "react-router-dom";
import type { ColumnsType } from "antd/es/table";
@ -10,6 +10,7 @@ import {
} from "../../components/GenericTable";
import { catalogueJurConfig } from "./config";
import {
CloseOutlined,
EditOutlined,
EllipsisOutlined,
HistoryOutlined,
@ -33,21 +34,28 @@ import {
withdrawCatalogueJur,
} from "../../services/api/directories/catalogue_jur";
import { WithdrawCatalogueJurForm } from "../WithdrawCatalogueJurForm/WithdrawCatalogueJurForm";
import { setIsGettingArchive } from "../../store/slices/catalogueJurSlice/catalogueJurSlice";
import {
setIsGettingArchive,
setRelatedGroupFilter,
} from "../../store/slices/catalogueJurSlice/catalogueJurSlice";
import { CatalogueJurForm } from "../CatalogueJurForm/CatalogueJurForm";
import Title from "antd/es/typography/Title";
import Text from "antd/es/typography/Text";
import { fetchRelatedGroups } from "../../services/api/directories/company_groups";
import { RelatedGroupOut } from "../../types/api";
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
import { ApiError } from "../../api/client";
import { useNotificationApi } from "../../utils/useNotification";
interface CatalogueJurTableI {
outside_group: boolean;
tabsComponent?: ReactNode;
}
type TabKey = "legal_entities" | "gsk" | "gsz";
export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
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(
@ -57,14 +65,27 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
(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 [activeTab, setActiveTab] = useState<TabKey>("legal_entities");
const [jurId, setJurId] = useState("");
const [gskData, setGskData] = useState<RelatedGroupOut[]>([]);
const [gszData, setGszData] = useState<RelatedGroupOut[]>([]);
const filterdData = useMemo(() => {
if (relatedGroupFilter?.id) {
return rawRows?.filter((row) => {
return row?.memberships?.some(
(member) => member.related_group_id === relatedGroupFilter.id,
);
});
} else {
return rawRows;
}
}, [rawRows, relatedGroupFilter]);
const { visibleKeys, setVisibleKeys } = useVisibleColumns(catalogueJurConfig);
const {
rawQuery,
@ -78,7 +99,7 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
} = useTableFilters<ICatalogueJur>();
// single filtered array — title count and table share it
const { rows } = useFilteredRows(
rawRows,
filterdData,
catalogueJurConfig,
rawQuery,
columnFilters,
@ -90,7 +111,7 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
getCatalogueJurThunk({
closed: isGettingArchive,
outside_group: outside_group,
company_group_id:activeCompanyGroup?.id
company_group_id: outside_group ? undefined : activeCompanyGroup?.id,
}),
);
}
@ -112,9 +133,14 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
try {
await moveCatalogueJur(jurId, values);
getCatalogueJur();
dispatch(getCompanyGroupsThunk());
closeMoveCatalogueJurHandler();
} catch (e) {
console.error(e);
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
async function withdrawCatalogueJurHandler(
@ -123,40 +149,38 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
try {
await withdrawCatalogueJur(jurId, values);
getCatalogueJur();
dispatch(getCompanyGroupsThunk());
closeWithdrawCatalogueJurHandler();
} catch (e) {
console.error(e);
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
}
}
async function editJurHandler(values: CatalogueJurFormProps) {
try {
await editCatalogueJur(jurId, values);
dispatch(
getCatalogueJurThunk({ closed: isGettingArchive, outside_group: true }),
);
getCatalogueJur();
closeEditCatalogueJurHandler();
} catch {}
}
async function getRelatedGroupsHandler() {
if (activeCompanyGroup?.id) {
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,
});
}
}
}
useEffect(() => {
getCatalogueJur();
if (!!activeCompanyGroup?.id) {
getCatalogueJur();
}
}, [isGettingArchive, activeCompanyGroup]);
useEffect(() => {
getRelatedGroupsHandler();
}, [activeCompanyGroup]);
const extraColumns: ColumnsType<ICatalogueJur> = useMemo(
() => [
{
@ -224,22 +248,9 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
borderRadius: 12,
}}
>
{!outside_group && (
<div
style={{ padding: "12px 16px", borderBottom: "1px solid #0f172a0f" }}
>
<Title level={5} style={{ margin: 0 }}>
{activeCompanyGroup?.name}
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
{activeCompanyGroup?.base_regional_office_name ?? ""}
{activeCompanyGroup?.legal_entities_count} организаций
</Text>
</div>
)}
<Space
style={{
padding: "0 12px",
padding: `${outside_group ? "12px" : "0px"} 12px`,
width: "100%",
justifyContent: "space-between",
borderBottom: "1px",
@ -255,19 +266,7 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
style={{ maxWidth: 320 }}
/>
) : (
<Tabs
activeKey={activeTab}
onChange={(k) => setActiveTab(k as TabKey)}
style={{ marginBottom: 0 }}
items={[
{
key: "legal_entities",
label: `ЮЛ ${activeCompanyGroup?.legal_entities_count}`,
},
{ key: "gsk", label: `ГСК ${gskData?.length ?? 0}` },
{ key: "gsz", label: `ГСЗ ${gszData?.length ?? 0}` },
]}
/>
tabsComponent
)}
<Space>
@ -281,14 +280,12 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
Сбросить фильтры
</Button>
)}
{activeTab === "legal_entities" && (
<Checkbox
checked={isGettingArchive}
onChange={(e) => dispatch(setIsGettingArchive(e.target.checked))}
>
Архив
</Checkbox>
)}
<Checkbox
checked={isGettingArchive}
onChange={(e) => dispatch(setIsGettingArchive(e.target.checked))}
>
Архив
</Checkbox>
<ColumnSettings
defs={catalogueJurConfig.columns}
visibleKeys={visibleKeys}
@ -296,6 +293,57 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
/>
</Space>
</Space>
{relatedGroupFilter?.id && (
<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}{" "}
{filterdData?.length === 1
? "запись"
: filterdData?.length < 5
? "записи"
: "записей"}
</span>
</div>
<Button
icon={<CloseOutlined />}
onClick={() => dispatch(setRelatedGroupFilter(null))}
style={{
borderColor: "#d9d9d9",
color: "#262626",
}}
>
Сбросить
</Button>
</div>
)}
<DataTable
sortState={sort}
onSortChange={setSort}
@ -316,8 +364,8 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
onCancel={() => {
closeMoveCatalogueJurHandler();
}}
onOk={(values) => {
moveCatalogueJurHandler(values);
onOk={async (values) => {
await moveCatalogueJurHandler(values);
}}
/>
)}
@ -328,8 +376,8 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
onCancel={() => {
closeWithdrawCatalogueJurHandler();
}}
onOk={(values) => {
withdrawCatalogueJurHandler(values);
onOk={async (values) => {
await withdrawCatalogueJurHandler(values);
}}
/>
)}
@ -340,8 +388,8 @@ export function CatalogueJurTable({ outside_group }: CatalogueJurTableI) {
onCancel={() => {
closeEditCatalogueJurHandler();
}}
onOk={(values) => {
editJurHandler(values);
onOk={async (values) => {
await editJurHandler(values);
}}
/>
)}

View File

@ -1,6 +1,8 @@
import { Tag } from "antd";
import type { TableConfig } from "../../components/GenericTable";
import { ICatalogueJur } from "../../types/catalogueJur/catalogueJur";
import { TableTag } from "../../components/TableTag/TableTag";
import { TAG_PRESETS } from "../../components/TableTag/presets";
export const catalogueJurConfig: TableConfig<ICatalogueJur> = {
rowKey: "id",
@ -10,10 +12,15 @@ export const catalogueJurConfig: TableConfig<ICatalogueJur> = {
key: "closed",
title: "Статус",
features: { sort: true },
render: (_:unknown, row) =>{
render: (_: unknown, row) => {
// #TODO поменять логику если запись не активна
return <Tag variant="outlined" color={'success'}>{!row.closed ? 'Активно' : 'Не активно'}</Tag>
}
return (
<TableTag
text={!row.closed ? "Активно" : "Не активно"}
{...TAG_PRESETS.green}
/>
);
},
},
{
key: "inn",
@ -34,16 +41,17 @@ export const catalogueJurConfig: TableConfig<ICatalogueJur> = {
key: "entity_type",
title: "Тип",
features: { multiselect: true, sort: true },
render: (val) =>{
return <Tag variant="outlined" color={val === 'ЮЛ' ? 'purple' : 'orange'}>{val as string}</Tag>
}
render: (val) => {
const preset = val === "ЮЛ" ? TAG_PRESETS.orange : TAG_PRESETS.purple;
return <TableTag text={val as string} {...preset} />;
},
},
{
key: "regional_office",
title: "Основной РФ",
render: (val:any) =>{
return val?.name
}
render: (val: any) => {
return val?.name;
},
},
],
};

View File

@ -0,0 +1,124 @@
import React, { useEffect, useState } from "react";
import { Modal, Form, Input, Select, Row, Col } from "antd";
import { useAppSelector } from "../../store/store";
import { loadOffices } from "../Admin/UsersTable";
import { CreateCompanyGroupProps } from "../../types/companyGroups/companyGroups";
import { useMe } from "../../auth/useMe";
import { ApiError } from "../../api/client";
import { useNotificationApi } from "../../utils/useNotification";
type Props = {
open: boolean;
onCancel: () => void;
onOk: (values: CreateCompanyGroupProps) => void | Promise<void>;
companyId?: string;
};
export const CompanyGroupForm: React.FC<Props> = ({
open,
onCancel,
onOk,
companyId,
}) => {
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);
const res = await loadOffices();
const options = res.map(
(elem) => elem && { value: elem.id, label: elem.name },
);
setOfficesOptions(options);
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"
rules={[{ len: 11, message: "Должно быть 11 символов" }]}
>
<Input maxLength={11} placeholder="Генерируется автоматически" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="base_regional_office_id" label="Базовый РФ ГК">
<Select
allowClear
loading={loadingOptions}
placeholder="— не указан —"
options={officesOptions}
showSearch
optionFilterProp="label"
notFoundContent={loadingOptions ? "Загрузка" : "Нет вариантов"}
/>
</Form.Item>
</Col>
</Row>
</Form>
</Modal>
);
};

View File

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

View File

@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from "react";
import { Input, List, Spin, Empty, Typography, Tag } from "antd";
import { Input, Spin, Empty, Typography, Tag, Listy } from "antd";
import { FolderOpenOutlined, SearchOutlined } from "@ant-design/icons";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
import { setActiveCompanyGroup } from "../../store/slices/companyGroupsSlice/companyGroupsSlice";
import "./CompanyGroupsList.css"
const { Text } = Typography;
export const CompanyGroupsList = () => {
@ -31,12 +31,6 @@ export const CompanyGroupsList = () => {
dispatch(getCompanyGroupsThunk());
}, [dispatch]);
useEffect(() => {
if (!activeCompanyGroup && rawRows?.length) {
dispatch(setActiveCompanyGroup(rawRows?.[0]));
}
}, [rawRows, activeCompanyGroup]);
return (
<div
style={{
@ -48,11 +42,13 @@ export const CompanyGroupsList = () => {
borderRadius: 12,
}}
>
<div style={{ padding: "12px 16px", borderBottom:"1px solid #0f172a0f" }}>
<div
style={{ padding: "12px 16px", borderBottom: "1px solid #0f172a0f" }}
>
<Input
allowClear
prefix={<SearchOutlined style={{ color: "#bfbfbf" }} />}
placeholder={"Поиск по ЮЛ или ГСК"}
placeholder={"Поиск по ГК или CRM ID"}
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
@ -70,13 +66,15 @@ export const CompanyGroupsList = () => {
style={{ marginTop: 32 }}
/>
) : (
<List
dataSource={filtered}
split={false}
renderItem={(item) => {
<Listy
virtual
rowKey={"id"}
items={filtered}
height={900}
itemRender={(item) => {
const active = item.id === activeCompanyGroup?.id;
return (
<List.Item
<div
onClick={() => dispatch(setActiveCompanyGroup(item))}
style={{
cursor: "pointer",
@ -88,6 +86,7 @@ export const CompanyGroupsList = () => {
? "inset 0 0 0 1px #2ea64b3d, inset 3px 0 0 #258141"
: "inset 0 0 0 1px #0f172a14",
transition: "background 0.15s",
borderBottom:0
}}
onMouseEnter={(e) => {
if (!active) {
@ -175,7 +174,7 @@ export const CompanyGroupsList = () => {
{item.legal_entities_count}
</Tag>
</div>
</List.Item>
</div>
);
}}
/>

View File

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

View File

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

View File

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

View File

@ -6,6 +6,8 @@ import FormItem from "antd/es/form/FormItem";
import { fetchOneCatalogueJur } from "../../services/api/directories/catalogue_jur";
import dayjs from "dayjs";
import { MoveCatalogueJurProps } from "../../types/catalogueJur/catalogueJur";
import { ApiError } from "../../api/client";
import { useNotificationApi } from "../../utils/useNotification";
type FormValues = {
name: string;
@ -30,9 +32,11 @@ export const MoveCatalogueJurForm: React.FC<Props> = ({
}) => {
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);
@ -48,11 +52,18 @@ export const MoveCatalogueJurForm: React.FC<Props> = ({
}
const handleOk = async () => {
setConfirmLoading(true);
try {
const { effective_date, company_group_id } = await form.validateFields();
await onOk({ effective_date, company_group_id });
} catch {
// validation error - keep modal open
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally {
setConfirmLoading(false);
}
};
@ -76,10 +87,11 @@ export const MoveCatalogueJurForm: React.FC<Props> = ({
open={open}
onCancel={handleCancel}
onOk={handleOk}
okText="+ Создать"
okText="Перевести"
cancelText="Отмена"
destroyOnHidden
loading={initialDataLoading}
confirmLoading={confirmLoading}
>
<Form form={form} layout="vertical">
<FormItem name="name" label="ЮЛ">

View File

@ -0,0 +1,158 @@
import React, { useEffect, useMemo, useState } from "react";
import { Modal, Form, Input, DatePicker, Select } from "antd";
import FormItem from "antd/es/form/FormItem";
import dayjs from "dayjs";
import { RelatedGroupFormType } from "../../types/relatedGroup/relatedGroup";
import { fetchRelatedGroup } from "../../services/api/directories/company_groups";
import { useNotificationApi } from "../../utils/useNotification";
import { ApiError } from "../../api/client";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
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,
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,
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"
rules={[{ len: 11, message: "Должно быть 11 символов" }]}
>
<Input maxLength={11} 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="Дата создания"
rules={[{ required: true, message: "Укажите дату" }]}
getValueProps={(value: string | undefined) => ({
value: value ? dayjs(value) : undefined,
})}
normalize={(value) =>
value ? value.format("YYYY-MM-DD") : undefined
}
>
<DatePicker
style={{ width: "100%" }}
format="DD.MM.YYYY"
placeholder="ДД.ММ.ГГГГ"
/>
</Form.Item>
</Form>
</Modal>
);
};

View File

@ -0,0 +1,222 @@
import { ReactNode, useMemo, useState } from "react";
import { Button, Dropdown, Space } from "antd";
import { useNavigate } from "react-router-dom";
import type { ColumnsType } from "antd/es/table";
import {
DataTable,
ColumnSettings,
useVisibleColumns,
useFilteredRows,
} from "../../components/GenericTable";
import { EllipsisOutlined } from "@ant-design/icons";
import { useTableFilters } from "../../components/GenericTable/useTableFilters";
import { RelatedGroupOut } from "../../types/api";
import { relateGroupConfig } from "./config";
import { RelatedGroupForm } from "../RelatedGroupForm/RelatedGroupForm";
import { RelatedGroupFormType } from "../../types/relatedGroup/relatedGroup";
import {
deleteRelatedGroup,
editRelatedGroup,
} from "../../services/api/directories/related-groups";
import { DeleteRelatedGroupModal } from "../DeleteRelatedGroupModal/DeleteRelatedGroupModal";
import { useNotificationApi } from "../../utils/useNotification";
import { ApiError } from "../../api/client";
interface RelatedGroupTableI {
tabsComponent?: ReactNode;
data: RelatedGroupOut[];
loading: boolean;
getData: () => void;
activeTab: "gsk" | "gsz";
}
export function RelatedGroupTable({
tabsComponent,
data,
loading,
getData,
activeTab,
}: RelatedGroupTableI) {
const navigate = useNavigate();
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>
);
},
},
],
[navigate],
);
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}
/>
)}
</div>
);
}

View File

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

View File

@ -5,6 +5,8 @@ import { fetchOneCatalogueJur } from "../../services/api/directories/catalogue_j
import dayjs from "dayjs";
import { WithdrawCatalogueJurProps } from "../../types/catalogueJur/catalogueJur";
import TextArea from "antd/es/input/TextArea";
import { ApiError } from "../../api/client";
import { useNotificationApi } from "../../utils/useNotification";
interface FormValues extends WithdrawCatalogueJurProps {
name: string;
@ -24,6 +26,9 @@ export const WithdrawCatalogueJurForm: React.FC<Props> = ({
jurId,
}) => {
const [form] = Form.useForm<FormValues>();
const notification = useNotificationApi();
const [confirmLoading, setConfirmLoading] = useState(false);
const [initialDataLoading, setInitialDataLoading] = useState(false);
const eventOptions = [
@ -44,6 +49,7 @@ export const WithdrawCatalogueJurForm: React.FC<Props> = ({
}
const handleOk = async () => {
setConfirmLoading(true)
try {
const {
effective_date,
@ -51,8 +57,14 @@ export const WithdrawCatalogueJurForm: React.FC<Props> = ({
comment = "",
} = await form.validateFields();
await onOk({ effective_date, event, comment });
} catch {
// validation error - keep modal open
} catch (e) {
if (e instanceof ApiError) {
notification.error({
description: e.message,
});
}
} finally{
setConfirmLoading(false)
}
};
@ -75,6 +87,7 @@ export const WithdrawCatalogueJurForm: React.FC<Props> = ({
cancelText="Отмена"
destroyOnHidden
loading={initialDataLoading}
confirmLoading={confirmLoading}
okButtonProps={{ danger: true }}
>
<Form form={form} layout="vertical">
@ -106,13 +119,14 @@ export const WithdrawCatalogueJurForm: React.FC<Props> = ({
}
>
<DatePicker
minDate={dayjs()}
style={{ width: "100%" }}
format="DD.MM.YYYY"
placeholder="ДД.ММ.ГГГГ"
/>
</Form.Item>
<FormItem name={"comment"} label="Комментарий">
<TextArea maxLength={6} rows={4} placeholder="Необязательно"/>
<TextArea maxLength={6} rows={4} placeholder="Необязательно" />
</FormItem>
</Form>
</Modal>