addded legalEntities table and move form

This commit is contained in:
Grog 2026-10-08 17:04:35 +03:00
parent 81548eb4ae
commit 8e8e46ecbc
12 changed files with 621 additions and 86 deletions

View File

@ -1,29 +1,40 @@
import { clearToken, getToken } from '../auth/token';
import { Config } from '../conf/config';
import { clearToken, getToken } from "../auth/token";
import { Config } from "../conf/config";
// Хвостовые слэши срезаются: API_URL='/' иначе дал бы `//api/v1` (адрес без хоста)
export const API_BASE = `${Config.API_URL.replace(/\/+$/, '')}/api/v1`;
export const API_BASE = `${Config.API_URL.replace(/\/+$/, "")}/api/v1`;
/** Событие `window`: сервер ответил 401, токен сброшен — интерфейс должен вернуться к входу. */
export const UNAUTHORIZED_EVENT = 'armka:unauthorized';
export const UNAUTHORIZED_EVENT = "armka:unauthorized";
const FALLBACK_MESSAGE = 'Не удалось выполнить запрос';
const FALLBACK_MESSAGE = "Не удалось выполнить запрос";
export class ApiError extends Error {
constructor(
public status: number,
public code: string | undefined,
message: string,
) {
super(message);
this.name = 'ApiError';
}
constructor(
public status: number,
public code: string | undefined,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
type ErrorInfo = { code: string | undefined; message: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function buildQuery(params?: object): string {
if (!params) return "";
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
search.append(key, String(value));
}
const qs = search.toString();
return qs ? `?${qs}` : "";
}
/**
@ -32,88 +43,102 @@ function isRecord(value: unknown): value is Record<string, unknown> {
* массив (422 валидации pydantic) → `msg` первого элемента.
*/
function parseErrorBody(body: unknown): ErrorInfo {
const detail = isRecord(body) ? body.detail : undefined;
if (typeof detail === 'string') {
return { code: undefined, message: detail };
}
if (isRecord(detail)) {
const code = typeof detail.code === 'string' ? detail.code : undefined;
const message = typeof detail.message === 'string' ? detail.message : FALLBACK_MESSAGE;
return { code, message };
}
if (Array.isArray(detail)) {
const first: unknown = detail[0];
if (isRecord(first) && typeof first.msg === 'string') {
return { code: undefined, message: first.msg };
}
}
return { code: undefined, message: FALLBACK_MESSAGE };
const detail = isRecord(body) ? body.detail : undefined;
if (typeof detail === "string") {
return { code: undefined, message: detail };
}
if (isRecord(detail)) {
const code = typeof detail.code === "string" ? detail.code : undefined;
const message =
typeof detail.message === "string" ? detail.message : FALLBACK_MESSAGE;
return { code, message };
}
if (Array.isArray(detail)) {
const first: unknown = detail[0];
if (isRecord(first) && typeof first.msg === "string") {
return { code: undefined, message: first.msg };
}
}
return { code: undefined, message: FALLBACK_MESSAGE };
}
async function readErrorInfo(response: Response): Promise<ErrorInfo> {
try {
return parseErrorBody(await response.json());
} catch {
// тело не JSON (например, ответ прокси) — показываем общий текст
return { code: undefined, message: FALLBACK_MESSAGE };
}
try {
return parseErrorBody(await response.json());
} catch {
// тело не JSON (например, ответ прокси) — показываем общий текст
return { code: undefined, message: FALLBACK_MESSAGE };
}
}
export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
const token = getToken();
if (token) {
headers.set('Authorization', `Bearer ${token}`);
}
// JSON-заголовок только для строкового тела; FormData и др. отдаём браузеру как есть (он сам задаёт boundary)
if (typeof init.body === 'string' && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
export async function apiFetch<T>(
path: string,
init: RequestInit = {},
): Promise<T> {
const headers = new Headers(init.headers);
const token = getToken();
if (token) {
headers.set("Authorization", `Bearer ${token}`);
}
// JSON-заголовок только для строкового тела; FormData и др. отдаём браузеру как есть (он сам задаёт boundary)
if (typeof init.body === "string" && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, { ...init, headers });
} catch (e: unknown) {
// отмену запроса (AbortController) вызывающий код обрабатывает сам; остальное — сеть/CORS/DNS: текст браузера не показываем
if (e instanceof DOMException && e.name === 'AbortError') {
throw e;
}
throw new ApiError(0, 'NETWORK', 'Нет связи с сервером');
}
let response: Response;
try {
response = await fetch(`${API_BASE}${path}`, { ...init, headers });
} catch (e: unknown) {
// отмену запроса (AbortController) вызывающий код обрабатывает сам; остальное — сеть/CORS/DNS: текст браузера не показываем
if (e instanceof DOMException && e.name === "AbortError") {
throw e;
}
throw new ApiError(0, "NETWORK", "Нет связи с сервером");
}
if (response.status === 401) {
clearToken();
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
const info = await readErrorInfo(response);
throw new ApiError(401, info.code, info.message);
}
if (!response.ok) {
const info = await readErrorInfo(response);
throw new ApiError(response.status, info.code, info.message);
}
if (response.status === 204) {
return undefined as T;
}
// 2xx с пустым или не-JSON телом (страница прокси, 200 без содержимого) не должен давать «сырой» SyntaxError
const text = await response.text();
if (text === '') {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new ApiError(response.status, 'BAD_RESPONSE', 'Некорректный ответ сервера');
}
if (response.status === 401) {
clearToken();
window.dispatchEvent(new Event(UNAUTHORIZED_EVENT));
const info = await readErrorInfo(response);
throw new ApiError(401, info.code, info.message);
}
if (!response.ok) {
const info = await readErrorInfo(response);
throw new ApiError(response.status, info.code, info.message);
}
if (response.status === 204) {
return undefined as T;
}
// 2xx с пустым или не-JSON телом (страница прокси, 200 без содержимого) не должен давать «сырой» SyntaxError
const text = await response.text();
if (text === "") {
return undefined as T;
}
try {
return JSON.parse(text) as T;
} catch {
throw new ApiError(
response.status,
"BAD_RESPONSE",
"Некорректный ответ сервера",
);
}
}
function withJsonBody(method: string, body: unknown): RequestInit {
return body === undefined ? { method } : { method, body: JSON.stringify(body) };
return body === undefined
? { method }
: { method, body: JSON.stringify(body) };
}
export const api = {
get: <T>(path: string) => apiFetch<T>(path),
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)),
del: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
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)),
del: <T>(path: string) => apiFetch<T>(path, { method: "DELETE" }),
};

View File

@ -3,10 +3,17 @@ import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App";
import { ConfigProvider } from "antd";
import locale from "antd/locale/ru_RU";
import dayjs from "dayjs";
import "dayjs/locale/ru";
dayjs.locale("ru");
createRoot(document.getElementById("root")!).render(
// <StrictMode>
<ConfigProvider
locale={locale}
theme={{
token: {
colorPrimary: "rgba(46, 166, 75, 0.8)",

View File

@ -1,3 +1,31 @@
import PageTitle from "../components/PageTitle";
import { CatalogueJurTable } from "../widgets/CatalogueJurTable/CatalogueJurTable";
export function CatalogueJur() {
return <h3>ЮЛ вне ГК</h3>;
return (
<div
style={{
padding: 12,
display: "flex",
flexDirection: "column",
height: "100vh",
}}
>
<PageTitle
header="Юридические лица"
location="Справочники"
/>
<main
style={{
flex: "1 1 auto",
minHeight: 0,
overflow: "auto",
backgroundColor: "white",
borderRadius: 12,
}}
>
<CatalogueJurTable />
</main>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { api } from "../../../api/client";
import {
GetCatalogueJurPropsI,
} from "../../../store/slices/catalogueJurSlice/types";
import { ICatalogueJur } from "../../../types/catalogueJur/catalogueJur";
export async function fetchCatalogueJur(params: GetCatalogueJurPropsI) {
// #TODO поменять на outside_group=true
const res = await api.get<ICatalogueJur[]>(
"/directories/legal-entities",
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;
}

View File

@ -0,0 +1,36 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { useSelector } from "react-redux";
import { Config } from "../../../conf/config";
import {CatalogueJurSliceType } from "./types";
import { getCatalogueJurThunk } from "./thunks";
const initialState:CatalogueJurSliceType = {
raw_rows: [],
total_groups: 0,
loading: false,
error: null,
};
const catalogueJurSlice = createSlice({
name: "catalogue_jur",
initialState,
reducers: {},
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 default catalogueJurSlice.reducer;

View File

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

View File

@ -0,0 +1,14 @@
import { ICatalogueJur } from "../../../types/catalogueJur/catalogueJur";
export interface CatalogueJurSliceType{
total_groups: number;
raw_rows: ICatalogueJur[];
loading: boolean;
error: string | null;
}
export interface GetCatalogueJurPropsI{
closed?:boolean
outside_group?:boolean
}

View File

@ -2,6 +2,7 @@ import { configureStore } from "@reduxjs/toolkit";
import filesReducer from "./slices/fileSlice";
import comGroupsReducer from "./slices/companyGroupsSlice/companyGroupsSlice";
import consolidationsReducer from "./slices/consolidations/consolidationsSlice";
import catalogueJurReducer from "./slices/catalogueJurSlice/catalogueJurSlice";
import { useDispatch, useSelector } from "react-redux";
export const store = configureStore({
@ -9,6 +10,7 @@ export const store = configureStore({
files: filesReducer,
company_groups: comGroupsReducer,
consolidations: consolidationsReducer,
catalogue_jur:catalogueJurReducer,
},
});

View File

@ -0,0 +1,24 @@
export interface ICatalogueJur {
id: string;
inn: string;
kpp: string | null;
crm_id: string | null;
entity_type: string;
name: string;
company_group_id: string | null;
company_group_name: string | null;
regional_office_id: string | null;
close_date: Date;
valid_from: Date;
created_at: Date;
updated_at: Date;
outside_group: boolean;
closed: boolean | string;
memberships?: unknown[];
aliases?: unknown[];
regional_office: {
id: string;
name: string;
is_head_office: boolean;
};
}

View File

@ -0,0 +1,177 @@
import { useEffect, useMemo, useState } from "react";
import { Button, Checkbox, Dropdown, Input, 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 { catalogueJurConfig } from "./config";
import {
EditOutlined,
EllipsisOutlined,
HistoryOutlined,
LogoutOutlined,
SearchOutlined,
SwapOutlined,
} from "@ant-design/icons";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
import { useTableFilters } from "../../components/GenericTable/useTableFilters";
import { ICatalogueJur } from "../../types/catalogueJur/catalogueJur";
import { getCatalogueJurThunk } from "../../store/slices/catalogueJurSlice/thunks";
import { MoveCatalogueJurForm } from "../MoveCatalogueJurForm/MoveCatalogueJurForm";
export function CatalogueJurTable() {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const rawRows = useAppSelector((state) => state.catalogue_jur.raw_rows);
const loading = useAppSelector((state) => state.catalogue_jur.loading);
const [isGettingArchive, setIsGettingArchive] = useState(false);
const [jurId, setJurId] = useState("");
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, totalCount, displayedCount } = useFilteredRows(
rawRows,
catalogueJurConfig,
rawQuery,
columnFilters,
sort,
);
useEffect(() => {
dispatch(
getCatalogueJurThunk({ closed: isGettingArchive, outside_group: false }),
);
}, [isGettingArchive]);
const extraColumns: ColumnsType<ICatalogueJur> = useMemo(
() => [
{
key: "actions",
title: "",
width: 30,
render: (_: unknown, row: ICatalogueJur) => {
const items = [
{
key: "edit",
icon: <EditOutlined />,
label: "Изменить ЮЛ",
},
{
key: "history",
icon: <HistoryOutlined />,
label: "История изменений",
onClick: () => navigate("/logging"),
},
{
key: "move",
icon: <SwapOutlined />,
label: "Перевести в другую ГК",
onClick: () => setJurId(row.id),
},
{
key: "withdraw",
icon: <LogoutOutlined />,
label: "Вывести из состава ГК",
danger: true,
},
];
return (
<Dropdown menu={{ items }} trigger={["click"]}>
<Button type="default">
<EllipsisOutlined />
</Button>
</Dropdown>
);
},
},
],
[navigate],
);
console.log(jurId, "test");
return (
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Space
style={{
padding: "16px 12px",
width: "100%",
justifyContent: "space-between",
borderBottom: "1px",
}}
>
<Input
allowClear
prefix={<SearchOutlined />}
placeholder="Поиск по ГК, CRM ID или РФ"
value={rawQuery}
onChange={(e) => setRawQuery(e.target.value)}
style={{ maxWidth: 320 }}
/>
<Space>
{hasActiveFilters && (
<Button
type="default"
onClick={() => {
resetAll();
}}
>
Сбросить фильтры
</Button>
)}
<ColumnSettings
defs={catalogueJurConfig.columns}
visibleKeys={visibleKeys}
onChange={setVisibleKeys}
/>
<Checkbox
checked={isGettingArchive}
onChange={(e) => setIsGettingArchive(e.target.checked)}
>
Архив
</Checkbox>
</Space>
</Space>
<DataTable
sortState={sort}
onSortChange={setSort}
config={catalogueJurConfig}
rows={rows}
allRows={rawRows}
loading={loading}
visibleKeys={visibleKeys}
globalQuery={rawQuery}
columnFilters={columnFilters}
onColumnFiltersChange={setColumnFilters}
extraColumns={extraColumns}
/>
{!!jurId && (
<MoveCatalogueJurForm
jurId={jurId}
open={!!jurId}
onCancel={() => {
setJurId("");
}}
onOk={async (values) => {
// await api.create(values)
console.log(values, 'test2')
setJurId("");
}}
/>
)}
</div>
);
}

View File

@ -0,0 +1,49 @@
import { Tag } from "antd";
import type { TableConfig } from "../../components/GenericTable";
import { 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) =>{
// #TODO поменять логику если запись не активна
return <Tag variant="outlined" color={'success'}>{!row.closed ? 'Активно' : 'Не активно'}</Tag>
}
},
{
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) =>{
return <Tag variant="outlined" color={val === 'ЮЛ' ? 'purple' : 'orange'}>{val as string}</Tag>
}
},
{
key: "regional_office",
title: "Основной РФ",
render: (val:any) =>{
return val?.name
}
},
],
};

View File

@ -0,0 +1,131 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Modal, Form, Select, Row, Col, Input, DatePicker } from "antd";
import { fetchGSKByComGroup } from "../../services/api/directories/company_groups";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
import FormItem from "antd/es/form/FormItem";
import { fetchOneCatalogueJur } from "../../services/api/directories/catalogue_jur";
import type { Dayjs } from "dayjs";
import dayjs from "dayjs";
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: FormValues) => void | Promise<void>;
jurId: string;
};
export const MoveCatalogueJurForm: React.FC<Props> = ({
open,
onCancel,
onOk,
jurId,
}) => {
const [form] = Form.useForm<FormValues>();
const dispatch = useAppDispatch();
const { raw_rows, loading } = useAppSelector((state) => state.company_groups);
const [initialDataLoading, setInitialDataLoading] = useState(false);
async function getInitialData() {
setInitialDataLoading(true);
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: "",
effective_date: "",
});
setInitialDataLoading(false);
}
const handleOk = async () => {
try {
const values = await form.validateFields();
await onOk(values);
form.resetFields();
} catch {
// validation error - keep modal open
}
};
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}
>
<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 ? "Загрузка" : "Нет вариантов"}
/>
</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
style={{ width: "100%" }}
format="DD.MM.YYYY"
placeholder="ДД.ММ.ГГГГ"
/>
</Form.Item>
</Form>
</Modal>
);
};