From 8e8e46ecbca3e79ff53b143df54fd59b985ad16c Mon Sep 17 00:00:00 2001 From: Grog Date: Thu, 8 Oct 2026 17:04:35 +0300 Subject: [PATCH 1/9] addded legalEntities table and move form --- src/api/client.ts | 195 ++++++++++-------- src/index.tsx | 7 + src/layouts/CatalogueJur.tsx | 30 ++- src/services/api/directories/catalogue_jur.ts | 24 +++ .../catalogueJurSlice/catalogueJurSlice.ts | 36 ++++ src/store/slices/catalogueJurSlice/thunks.ts | 18 ++ src/store/slices/catalogueJurSlice/types.ts | 14 ++ src/store/store.ts | 2 + src/types/catalogueJur/catalogueJur.ts | 24 +++ .../CatalogueJurTable/CatalogueJurTable.tsx | 177 ++++++++++++++++ src/widgets/CatalogueJurTable/config.tsx | 49 +++++ .../MoveCatalogueJurForm.tsx | 131 ++++++++++++ 12 files changed, 621 insertions(+), 86 deletions(-) create mode 100644 src/services/api/directories/catalogue_jur.ts create mode 100644 src/store/slices/catalogueJurSlice/catalogueJurSlice.ts create mode 100644 src/store/slices/catalogueJurSlice/thunks.ts create mode 100644 src/store/slices/catalogueJurSlice/types.ts create mode 100644 src/types/catalogueJur/catalogueJur.ts create mode 100644 src/widgets/CatalogueJurTable/CatalogueJurTable.tsx create mode 100644 src/widgets/CatalogueJurTable/config.tsx create mode 100644 src/widgets/MoveCatalogueJurForm/MoveCatalogueJurForm.tsx diff --git a/src/api/client.ts b/src/api/client.ts index 29aa352..3a5c78c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 { - 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 { * массив (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 { - 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(path: string, init: RequestInit = {}): Promise { - 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( + path: string, + init: RequestInit = {}, +): Promise { + 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: (path: string) => apiFetch(path), - post: (path: string, body?: unknown) => apiFetch(path, withJsonBody('POST', body)), - patch: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PATCH', body)), - put: (path: string, body?: unknown) => apiFetch(path, withJsonBody('PUT', body)), - del: (path: string) => apiFetch(path, { method: 'DELETE' }), + get: (path: string, params?:object) => + apiFetch(`${path}${buildQuery(params)}`), + post: (path: string, body?: unknown) => + apiFetch(path, withJsonBody("POST", body)), + patch: (path: string, body?: unknown) => + apiFetch(path, withJsonBody("PATCH", body)), + put: (path: string, body?: unknown) => + apiFetch(path, withJsonBody("PUT", body)), + del: (path: string) => apiFetch(path, { method: "DELETE" }), }; diff --git a/src/index.tsx b/src/index.tsx index 09e0174..d663559 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( // ЮЛ вне ГК; + return ( +
+ +
+ +
+
+ ); } diff --git a/src/services/api/directories/catalogue_jur.ts b/src/services/api/directories/catalogue_jur.ts new file mode 100644 index 0000000..57b23e6 --- /dev/null +++ b/src/services/api/directories/catalogue_jur.ts @@ -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( + "/directories/legal-entities", + params, + ); + + return res; +} + +export async function fetchOneCatalogueJur( + catalogue_jur: string, +): Promise { + const res = await api.get( + `/directories/legal-entities/${catalogue_jur}`, + ); + return res; +} diff --git a/src/store/slices/catalogueJurSlice/catalogueJurSlice.ts b/src/store/slices/catalogueJurSlice/catalogueJurSlice.ts new file mode 100644 index 0000000..262ccb4 --- /dev/null +++ b/src/store/slices/catalogueJurSlice/catalogueJurSlice.ts @@ -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; diff --git a/src/store/slices/catalogueJurSlice/thunks.ts b/src/store/slices/catalogueJurSlice/thunks.ts new file mode 100644 index 0000000..fb4ba32 --- /dev/null +++ b/src/store/slices/catalogueJurSlice/thunks.ts @@ -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 : "Ошибка", + ); + } + }, +); diff --git a/src/store/slices/catalogueJurSlice/types.ts b/src/store/slices/catalogueJurSlice/types.ts new file mode 100644 index 0000000..b19fb4c --- /dev/null +++ b/src/store/slices/catalogueJurSlice/types.ts @@ -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 +} diff --git a/src/store/store.ts b/src/store/store.ts index 08c374e..bae9104 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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, }, }); diff --git a/src/types/catalogueJur/catalogueJur.ts b/src/types/catalogueJur/catalogueJur.ts new file mode 100644 index 0000000..ae39d83 --- /dev/null +++ b/src/types/catalogueJur/catalogueJur.ts @@ -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; + }; +} diff --git a/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx b/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx new file mode 100644 index 0000000..ee2fe59 --- /dev/null +++ b/src/widgets/CatalogueJurTable/CatalogueJurTable.tsx @@ -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(); + // 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 = useMemo( + () => [ + { + key: "actions", + title: "", + width: 30, + render: (_: unknown, row: ICatalogueJur) => { + const items = [ + { + key: "edit", + icon: , + label: "Изменить ЮЛ", + }, + { + key: "history", + icon: , + label: "История изменений", + onClick: () => navigate("/logging"), + }, + { + key: "move", + icon: , + label: "Перевести в другую ГК", + onClick: () => setJurId(row.id), + }, + { + key: "withdraw", + icon: , + label: "Вывести из состава ГК", + danger: true, + }, + ]; + return ( + + + + ); + }, + }, + ], + [navigate], + ); + console.log(jurId, "test"); + return ( +
+ + } + placeholder="Поиск по ГК, CRM ID или РФ" + value={rawQuery} + onChange={(e) => setRawQuery(e.target.value)} + style={{ maxWidth: 320 }} + /> + + {hasActiveFilters && ( + + )} + + setIsGettingArchive(e.target.checked)} + > + Архив + + + + + + {!!jurId && ( + { + setJurId(""); + }} + onOk={async (values) => { + // await api.create(values) + console.log(values, 'test2') + setJurId(""); + }} + /> + )} +
+ ); +} diff --git a/src/widgets/CatalogueJurTable/config.tsx b/src/widgets/CatalogueJurTable/config.tsx new file mode 100644 index 0000000..9aca89d --- /dev/null +++ b/src/widgets/CatalogueJurTable/config.tsx @@ -0,0 +1,49 @@ +import { Tag } from "antd"; +import type { TableConfig } from "../../components/GenericTable"; +import { ICatalogueJur } from "../../types/catalogueJur/catalogueJur"; + +export const catalogueJurConfig: TableConfig = { + rowKey: "id", + searchableKeys: ["closed", "inn", "kpp", "name", "entity_type"], + columns: [ + { + key: "closed", + title: "Статус", + features: { sort: true }, + render: (_:unknown, row) =>{ + // #TODO поменять логику если запись не активна + return {!row.closed ? 'Активно' : 'Не активно'} + } + }, + { + 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 {val as string} + } + }, + { + key: "regional_office", + title: "Основной РФ", + render: (val:any) =>{ + return val?.name + } + }, + ], +}; diff --git a/src/widgets/MoveCatalogueJurForm/MoveCatalogueJurForm.tsx b/src/widgets/MoveCatalogueJurForm/MoveCatalogueJurForm.tsx new file mode 100644 index 0000000..75b6680 --- /dev/null +++ b/src/widgets/MoveCatalogueJurForm/MoveCatalogueJurForm.tsx @@ -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; + jurId: string; +}; + +export const MoveCatalogueJurForm: React.FC = ({ + open, + onCancel, + onOk, + jurId, +}) => { + const [form] = Form.useForm(); + 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 ( + +
+ + + + + + + + + + + + + + + + + ({ value: g.id, label: g.name }))} + showSearch + optionFilterProp="label" + notFoundContent={loading ? "Загрузка" : "Нет вариантов"} + /> + + + + + + + +