Add consolidation POST + file upload

This commit is contained in:
maxim 2026-10-07 22:48:30 +03:00
parent 81548eb4ae
commit 8747f6318c
27 changed files with 749 additions and 166 deletions

View File

@ -33,6 +33,9 @@ interface DataTableProps<T extends Record<string, any>> {
scrollY?: number | string;
onRow?: TableProps<T>["onRow"];
}
const locale = {
emptyText: "Нет данных, чтобы отобразить",
};
export function DataTable<T extends Record<string, any>>({
config,
@ -209,6 +212,7 @@ export function DataTable<T extends Record<string, any>>({
return (
<div ref={wrapperRef} style={{ flex: 1, height: "100%" }}>
<Table<T>
locale={locale}
rowKey={rowKey}
loading={loading}
columns={columns}

View File

@ -0,0 +1,47 @@
import React from "react";
type TableTagProps = {
text: string;
color?: string; // text color
bgColor?: string; // background color
borderColor?: string; // border color
icon?: React.ReactNode;
rounded?: boolean | string; // true = fully rounded
style?: React.CSSProperties;
className?: string;
};
export const TableTag: React.FC<TableTagProps> = ({
text,
color = "#1677ff",
bgColor = "#e6f4ff",
borderColor = "#91caff",
icon,
rounded = true,
style,
className,
}) => {
return (
<span
className={className}
style={{
display: "inline-flex",
alignItems: "center",
gap: 6,
padding: "2px 10px",
fontSize: 13,
lineHeight: "20px",
fontWeight: 500,
color: color,
backgroundColor: bgColor,
border: `1px solid ${borderColor}`,
borderRadius: rounded === true ? 999 : (rounded as string),
whiteSpace: "nowrap",
...style,
}}
>
{icon && <span style={{ display: "inline-flex" }}>{icon}</span>}
{text}
</span>
);
};

View File

@ -0,0 +1,42 @@
export type Preset = {
color: string;
bgColor: string;
borderColor: string;
};
export const TAG_PRESETS = {
green: {
color: "rgb(22, 101, 52)",
bgColor: "rgb(236, 253, 243)",
borderColor: "rgba(58, 150, 71, 0.2)",
},
red: {
color: "rgb(153, 27, 27)",
bgColor: "rgb(254, 226, 226)",
borderColor: "rgba(239, 68, 68, 0.2)",
},
orange: {
color: "rgb(154, 52, 18)",
bgColor: "rgb(255, 237, 213)",
borderColor: "rgba(251, 146, 60, 0.28)",
},
geekblue: {
color: "rgb(36, 86, 197)",
bgColor: "rgb(238, 244, 255)",
borderColor: "rgba(37, 99, 235, 0.2)",
},
purple: {
bgColor: "rgb(237, 233, 254)",
borderColor: "rgba(124, 58, 237, 0.2)",
color: "rgb(91, 33, 182)",
},
default: {
bgColor: "rgb(241, 245, 249)",
borderColor: "rgba(0, 0, 0, 0.08)",
color: "rgb(102, 112, 133)",
},
gold: {
bgColor: "rgb(254, 243, 199)",
borderColor: "rgba(245, 158, 11, 0.25)",
color: "rgb(146, 64, 14)",
},
} as const;

View File

@ -16,30 +16,8 @@ import PageTitle from "../components/PageTitle";
import { fetchOneCompanyGroup } from "../services/api/directories/company_groups";
import { getConsolidationsListThunk } from "../store/slices/consolidations/thunks";
import { ConsoleTableWidget } from "../widgets/ConsoleTable/ConsoleTable";
function DrawerContent() {
const { current_version_id, current_legal_entity_id } = useAppSelector(
(state) => state.consolidations,
);
const [drawerContent, setDrawerContent] = useState([]);
// if (!drawerContent.length) return;
if (!current_legal_entity_id) return;
return (
<>
<div>
Version_id: {current_version_id}, legal_entity_id:{" "}
{current_legal_entity_id}
</div>
<FileUploaderModal
legal_entity_id={current_legal_entity_id}
trigger_sse_cb={() => {}}
/>
{drawerContent.map((f) => (
<FileItem key={f.id} file={f} />
))}
</>
);
}
import { setSelectedVersion } from "../store/slices/consolidations/consolidationsSlice";
import { DocumentsDrawer } from "../widgets/FilesDrawer/FilesDrawer";
export function ConsolidationPage() {
const [legalEntities, setLegalEntities] = useState([]);
@ -65,6 +43,9 @@ export function ConsolidationPage() {
// dispatch()
}
getAllInfo();
return () => {
dispatch(setSelectedVersion(null));
};
}, [dispatch, group_id]);
return (
<div
@ -124,15 +105,12 @@ export function ConsolidationPage() {
/>
</div>
</main>
<Drawer
size="large"
<DocumentsDrawer
open={drawerOpen}
onClose={() => {
setDrawerOpen(false);
}}
>
<DrawerContent />
</Drawer>
/>
</div>
);
}

View File

@ -10,7 +10,7 @@ import { Select } from "antd";
import PdfDataViewer from "../widgets/PDFResultViewer/PDFDataViewer";
import { Config } from "../conf/config";
import IFrameBlock from "../widgets/PDFResultViewer/IFrameBlock";
import { getFilesThunk } from "../store/slices/fileSlice";
import { getFilesThunk } from "../store/slices/files/thunks";
function PdfViewer({ fileId }: { fileId: string }) {
return (

View File

@ -2,14 +2,28 @@ import { api } from "../../api/client";
import { MOCK_CONS, versionCardsMock } from "./mocks";
export async function getConsolidations(company_group_id: string) {
// const res = await api.get(
// `/company-groups/${company_group_id}/consolidations`,
// );
const res = await api.get(
`/company-groups/${company_group_id}/consolidations`,
);
return res;
return MOCK_CONS.map((c) => ({ ...c, versions: [...c.versions] }));
// return MOCK_CONS.map((c) => ({ ...c, versions: [...c.versions] }));
}
export async function getVersionData(version_id: string) {
const res = versionCardsMock[version_id] ?? null;
// const res = versionCardsMock[version_id] ?? null;
const res = await api.get(`/consolidations/versions/${version_id}`);
return res;
}
type FormValues = {
company_group_id: string;
related_group_id: string;
year: number;
quarter: string; // example of 2nd static field
legal_entity_ids: string[];
};
export async function createConsolidation(values: FormValues) {
const res = await api.post("/consolidations", values);
return res;
}

View File

@ -20,3 +20,22 @@ export async function fetchGSKByComGroup(com_group_id: string) {
);
return res;
}
// export async function fetchGroupMembers(related_group_id: string) {
// const res = await api.get(`/directories/related-groups/${related_group_id}`);
// const members = res?.members ?? [];
// console.log(members);
// return members;
// }
// legal-entities?company_group_id=<ГК>&closed=false
export async function fetchGroupMembers(company_group_id: string) {
const res = await api.get(
`/directories/legal-entities?company_group_id=${company_group_id}&closed=false`,
);
console.log(res);
const members = res?.items ?? [];
console.log(members);
return members;
}

27
src/services/api/files.ts Normal file
View File

@ -0,0 +1,27 @@
import { api, apiFetch } from "../../api/client";
export async function uploadOneFile(
legal_entity_id: string,
file: any,
consolidation_version_id: string | null,
) {
console.log("DATA", file);
console.log(legal_entity_id);
console.log(consolidation_version_id);
const formData = new FormData();
formData.append("files", file);
formData.append("legal_entity_id", legal_entity_id);
formData.append("year", "2025");
formData.append("quarter", "Q3");
console.log(formData);
apiFetch(
`/legal-entities/${legal_entity_id}/files?consolidation_version_id=${consolidation_version_id}`,
{ method: "POST", body: formData },
);
// if (!res.ok) throw new Error((await res.text()) || "Upload error");
}
export async function fetchLegalEntityFiles(legal_entity_id: string) {
const res = api.get(`/legal-entities/${legal_entity_id}/files`);
return res;
}

View File

@ -1,37 +0,0 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { useSelector } from "react-redux";
import { Config } from "../../conf/config";
const initialState = {
currentOpenFiles: [],
};
export const getFilesThunk = createAsyncThunk(
"files/getCurrentFiles",
async (legalEntityId, thunkApi) => {
const res = await fetch(
`${Config.API_URL}/files/by-legal-entity?legal_entity_id=${legalEntityId}`,
);
if (!res.ok) throw new Error("Error in fetch");
const json = await res.json();
return thunkApi.fulfillWithValue(json);
},
);
const filesSlice = createSlice({
name: "files",
initialState,
reducers: {
setCurrentFiles: (state, action) => {
state.currentOpenFiles = action.payload;
},
},
extraReducers(builder) {
builder.addCase(getFilesThunk.fulfilled, (state, action) => {
state.currentOpenFiles = action.payload;
});
},
});
export const { setCurrentFiles } = filesSlice.actions;
export default filesSlice.reducer;

View File

@ -0,0 +1,39 @@
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
import { useSelector } from "react-redux";
import { Config } from "../../../conf/config";
import { getFilesThunk } from "./thunks";
const initialState = {
currentOpenFiles: [],
loading: false,
error: null,
};
const filesSlice = createSlice({
name: "files",
initialState,
reducers: {
setCurrentFiles: (state, action) => {
state.currentOpenFiles = action.payload;
},
},
extraReducers(builder) {
builder
.addCase(getFilesThunk.pending, (state, action) => {
state.loading = true;
state.error = null;
})
.addCase(getFilesThunk.fulfilled, (state, action) => {
state.currentOpenFiles = action.payload;
state.loading = false;
})
.addCase(getFilesThunk.rejected, (state, action) => {
state.error = action.payload;
state.currentOpenFiles = [];
state.loading = false;
});
},
});
export const { setCurrentFiles } = filesSlice.actions;
export default filesSlice.reducer;

View File

@ -0,0 +1,11 @@
import { createAsyncThunk } from "@reduxjs/toolkit";
import { fetchLegalEntityFiles } from "../../../services/api/files";
export const getFilesThunk = createAsyncThunk(
"files/getCurrentFiles",
async (legalEntityId: string, thunkApi) => {
const res = await fetchLegalEntityFiles(legalEntityId);
console.log("RESULT", res);
return thunkApi.fulfillWithValue(res.items);
},
);

View File

@ -1,5 +1,5 @@
import { configureStore } from "@reduxjs/toolkit";
import filesReducer from "./slices/fileSlice";
import filesReducer from "./slices/files/fileSlice";
import comGroupsReducer from "./slices/companyGroupsSlice/companyGroupsSlice";
import consolidationsReducer from "./slices/consolidations/consolidationsSlice";
import { useDispatch, useSelector } from "react-redux";

View File

@ -19,7 +19,7 @@ interface IConsolidationVersion {
}
export interface IConsolidation {
id: string;
consolidation_id: string;
year: number;
quarter: QuarterEnum;
reporting_date: string;

View File

@ -28,7 +28,14 @@ type VersionHistory = {
comment: string | null;
};
type FileStatus =
export type DocumentType =
| "BALANCE"
| "INCOME"
| "OSV"
| "DISCLOSURE"
| "OTHER";
export type FileStatus =
| "CONFIRMED"
| "NEEDS_CHECK"
| "RECOGNIZED"
@ -36,10 +43,20 @@ type FileStatus =
| "ERROR"
| "MISSING";
export type FileRussianStatus =
| "Требуется проверка"
| "Ошибка"
| "Отсутствует"
| "Загружен"
| "В обработке"
| "Распознан";
type ChecklistItem = {
document_ids: string[];
document_type: DocumentType;
status: FileStatus;
missing_quarters: QuarterEnum[];
account?: string | null;
};
type VersionMember = {
@ -48,6 +65,7 @@ type VersionMember = {
inn: string;
is_head_entity: string;
checklist: ChecklistItem[];
entity_type: "ИП" | "ЮЛ";
};
export interface IVersionCard {
@ -78,4 +96,8 @@ export type VersionCardRow = {
inn: string;
name: string;
version_id: string;
bb_status: FileRussianStatus;
uploaded_osvs: string;
ofr_status: FileRussianStatus;
entity_type: "ЮЛ" | "ИП";
};

View File

@ -1,4 +1,3 @@
import { Tag } from "antd";
import type { TableConfig } from "../../components/GenericTable";
import { ICompanyGroup } from "../../types/companyGroups/companyGroups";

View File

@ -5,6 +5,9 @@ import {
VersionCardRow,
} from "../../types/consolidations/version_members";
import { FileOutlined, FileTextOutlined } from "@ant-design/icons";
import { TableTag } from "../../components/TableTag/TableTag";
import { statusColorMapping } from "./consts";
import { TAG_PRESETS } from "../../components/TableTag/presets";
export const membersConfig: TableConfig<
VersionCardRow & { docs: "Документы" }
@ -27,6 +30,27 @@ export const membersConfig: TableConfig<
</Button>
),
},
{
key: "ofr_status",
title: "ОФР",
features: { multiselect: true },
render: (v) => <TableTag {...statusColorMapping[v]} text={v} />,
},
{
key: "bb_status",
title: "Бух.баланс",
features: { multiselect: true },
render: (v) => <TableTag {...statusColorMapping[v]} text={v} />,
},
{
key: "uploaded_osvs",
title: "ОСВ",
features: { multiselect: true },
render: (v) => <TableTag {...TAG_PRESETS.default} text={v} />,
},
{ key: "inn", title: "ИНН", features: { search: true, multiselect: true } },
{ key: "entity_type", title: "Тип", features: { multiselect: true } },
],
};

View File

@ -0,0 +1,23 @@
import { Preset, TAG_PRESETS } from "../../components/TableTag/presets";
import {
FileRussianStatus,
FileStatus,
} from "../../types/consolidations/version_members";
export const statusMapping: Record<FileStatus, FileRussianStatus> = {
CONFIRMED: "Загружен",
ERROR: "Ошибка",
MISSING: "Отсутствует",
NEEDS_CHECK: "Требуется проверка",
PROCESSING: "В обработке",
RECOGNIZED: "Распознан",
};
export const statusColorMapping: Record<FileRussianStatus, Preset> = {
"В обработке": TAG_PRESETS.geekblue,
Загружен: TAG_PRESETS.green,
Отсутствует: TAG_PRESETS.red,
Ошибка: TAG_PRESETS.red,
Распознан: TAG_PRESETS.gold,
"Требуется проверка": TAG_PRESETS.orange,
};

View File

@ -2,6 +2,7 @@ import {
IVersionCard,
VersionCardRow,
} from "../../types/consolidations/version_members";
import { statusMapping } from "./consts";
export function normalizeData(
cons_data: IVersionCard | null,
@ -10,13 +11,30 @@ export function normalizeData(
if (cons_data === null) {
return result;
}
console.log(cons_data);
for (const m of cons_data.members) {
result.push({
let row: VersionCardRow = {
id: m.id,
name: m.name,
inn: m.inn,
version_id: cons_data.id,
});
entity_type: m.entity_type,
};
let uploaded_osvs = [];
for (let c of m.checklist) {
if (c.document_type === "BALANCE") {
row.bb_status = statusMapping[c.status];
} else if (c.document_type === "INCOME") {
row.ofr_status = statusMapping[c.status];
} else if (c.document_type === "OSV") {
if (c.status !== "MISSING") {
uploaded_osvs.push(c.account);
}
}
}
row.uploaded_osvs = [...uploaded_osvs].sort().join(", ");
result.push(row);
}
return result;
}

View File

@ -169,9 +169,7 @@ export function ConsoleTableWidget() {
</Button>
<Button
type="default"
onClick={() =>
navigate(`/cons/${row.group_id}/versions/${row.id}`)
}
onClick={() => navigate(`/cons/${row.con_id}/versions/${row.id}`)}
>
К отчетности <ArrowRightOutlined />
</Button>

View File

@ -1,16 +1,8 @@
import { Tag } from "antd";
import { TableConfig } from "../../components/GenericTable";
import { ConsRow, LockOut } from "../../types/consolidations/consolidations";
import { statusColorMapping } from "./consts";
const statusColor: Record<string, string> = {
INITIATED: "gold",
DOCUMENTS_PROCESSING: "blue",
VERIFIED: "green",
CANCELLED: "default",
REJECTED: "red",
ARCHIVED: "default",
};
import { TableTag } from "../../components/TableTag/TableTag";
import { TAG_PRESETS } from "../../components/TableTag/presets";
export const consConfigBase: TableConfig<ConsRow> = {
rowKey: "id",
@ -24,11 +16,7 @@ export const consConfigBase: TableConfig<ConsRow> = {
key: "status",
title: "Статус",
features: { multiselect: true, sort: true },
render: (v) => (
<Tag color={statusColorMapping[v]} variant="outlined">
{v}
</Tag>
),
render: (v) => <TableTag {...statusColorMapping[v]} text={v as string} />,
},
{
key: "reporting_date",

View File

@ -1,3 +1,4 @@
import { TAG_PRESETS, type Preset } from "../../components/TableTag/presets";
import {
ConsRussianStatus,
ConsStatus,
@ -18,17 +19,17 @@ export const statusMapping: Record<ConsStatus, ConsRussianStatus> = {
REJECTED: "Отклонён",
};
export const statusColorMapping: Record<ConsRussianStatus, string> = {
"В архиве": "default",
"Верификация ГО": "geekblue",
"Верификация РФ": "geekblue",
Верифицирован: "green",
Инициация: "default",
"Консолидация ГО": "purple",
"Консолидация РФ": "purple",
Корректировка: "gold",
"Коррекция данных": "gold",
"Обработка документов": "gold",
Отклонён: "red",
Отменён: "red",
export const statusColorMapping: Record<ConsRussianStatus, Preset> = {
"В архиве": TAG_PRESETS.default,
"Верификация ГО": TAG_PRESETS.geekblue,
"Верификация РФ": TAG_PRESETS.geekblue,
Верифицирован: TAG_PRESETS.green,
Инициация: TAG_PRESETS.default,
"Консолидация ГО": TAG_PRESETS.purple,
"Консолидация РФ": TAG_PRESETS.purple,
Корректировка: TAG_PRESETS.gold,
"Коррекция данных": TAG_PRESETS.gold,
"Обработка документов": TAG_PRESETS.gold,
Отклонён: TAG_PRESETS.red,
Отменён: TAG_PRESETS.red,
};

View File

@ -16,11 +16,11 @@ export function flattenCons(cons: IConsolidation[]): ConsRow[] {
let rep_date = new Date(con.reporting_date) ?? null;
out.push({
id: v.id,
group_id: con.id,
group_id: con.consolidation_id,
isPrimary: i === 0,
versionIndex: i,
groupSize: con.versions.length,
con_id: con.id,
con_id: con.consolidation_id,
year: con.year,
quarter: con.quarter,
reporting_date: rep_date,

View File

@ -0,0 +1,62 @@
// DocumentsDrawer.tsx
import { Drawer, List, Button } from "antd";
import { useState } from "react";
import { useDocumentsPolling } from "./useDocumentsPolling";
import { UploadDocumentsModal } from "./UploadDocumentsModal";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getFilesThunk } from "../../store/slices/files/thunks";
export function DocumentsDrawer({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dispatch = useAppDispatch();
const [uploadOpen, setUploadOpen] = useState(false);
const { current_version_id, current_legal_entity_id } = useAppSelector(
(state) => state.consolidations,
);
const { data, loading } = useDocumentsPolling(current_legal_entity_id, open);
const handleClose = () => {
// optional: clear on close so next open doesn't flash stale list
// dispatch(clearDocuments());
onClose();
};
return (
<Drawer
open={open}
onClose={handleClose}
destroyOnClose
title="Документы"
extra={
<Button type="primary" onClick={() => setUploadOpen(true)}>
Upload documents
</Button>
}
>
<List
loading={loading && !data.length}
dataSource={data}
renderItem={(item) => (
<List.Item>
{item.original_name} - {item.file_type} - {item.status}
</List.Item>
)}
/>
{current_legal_entity_id && (
<UploadDocumentsModal
entityId={current_legal_entity_id}
consolidation_version_id={current_version_id}
open={uploadOpen}
onClose={() => setUploadOpen(false)}
onUploaded={() => dispatch(getFilesThunk(current_legal_entity_id))} // instant refresh, polling covers steady state
/>
)}
</Drawer>
);
}

View File

@ -0,0 +1,104 @@
// UploadDocumentsModal.tsx
import { Modal, Upload, message } from "antd";
import { InboxOutlined } from "@ant-design/icons";
import { useState } from "react";
import type { UploadFile, RcFile } from "antd/es/upload/interface";
import { uploadOneFile } from "../../services/api/files";
type Props = {
entityId: string;
open: boolean;
consolidation_version_id: string | null;
onClose: () => void;
onUploaded?: () => void; // trigger refetch after done
};
export function UploadDocumentsModal({
entityId,
open,
onClose,
onUploaded,
consolidation_version_id = null,
}: Props) {
const [fileList, setFileList] = useState<UploadFile[]>([]);
const [uploading, setUploading] = useState(false);
const handleOk = async () => {
if (!fileList.length) {
message.warning("No files selected");
return;
}
setUploading(true);
const failed: string[] = [];
// one-by-one (sequential)
for (const file of fileList) {
const raw = (file.originFileObj ?? file) as RcFile;
try {
await uploadOneFile(entityId, raw, consolidation_version_id);
} catch (e) {
failed.push(file.name);
}
}
setUploading(false);
if (failed.length) {
message.error(`Failed: ${failed.join(", ")}`);
// keep only failed files so user can retry
setFileList((prev) => prev.filter((f) => failed.includes(f.name)));
return;
}
message.success("All documents uploaded");
setFileList([]);
onUploaded?.(); // refetch -> polling will also catch up, but immediate feels better
onClose();
};
const handleCancel = () => {
if (uploading) return; // block closing mid-upload
setFileList([]);
onClose();
};
return (
<Modal
open={open}
title="Загрузить документы"
onOk={handleOk}
onCancel={handleCancel}
okText="Upload"
confirmLoading={uploading}
okButtonProps={{ disabled: !fileList.length }}
closable={!uploading}
destroyOnHidden
>
<Upload.Dragger
multiple
fileList={fileList}
accept=".xlsx,.xls,.pdf,.jpg,.jpeg"
beforeUpload={(file) => {
// dedupe by uid protects from React StrictMode double invoke
setFileList((prev) =>
prev.some((f) => f.uid === file.uid)
? prev
: [...prev, file as UploadFile],
);
return false; // prevent antd auto-upload
}}
onRemove={(file) => {
setFileList((prev) => prev.filter((f) => f.uid !== file.uid));
}}
// we manage fileList ourselves, so disable antd status updates
showUploadList={{ showRemoveIcon: !uploading }}
>
<p className="ant-upload-drag-icon">
<InboxOutlined />
</p>
<p className="ant-upload-text">Выберите файлы</p>
</Upload.Dragger>
</Modal>
);
}

View File

@ -0,0 +1,27 @@
// useDocumentsPolling.ts
import { useEffect } from "react";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getFilesThunk } from "../../store/slices/files/thunks";
const POLL_INTERVAL = 5000;
export function useDocumentsPolling(rowId: string | null, enabled: boolean) {
const dispatch = useAppDispatch();
const data = useAppSelector((s) => s.files.currentOpenFiles);
const loading = useAppSelector((s) => s.files.loading);
useEffect(() => {
if (!enabled || !rowId) return;
// immediate fetch
dispatch(getFilesThunk(rowId));
const id = window.setInterval(() => {
dispatch(getFilesThunk(rowId));
}, POLL_INTERVAL);
return () => clearInterval(id);
}, [dispatch, rowId, enabled]);
return { data, loading };
}

View File

@ -1,14 +1,28 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Modal, Form, Select, Row, Col } from "antd";
import { fetchGSKByComGroup } from "../../services/api/directories/company_groups";
import {
Modal,
Form,
Select,
Row,
Col,
Checkbox,
Input,
Empty,
Spin,
} from "antd";
import {
fetchGroupMembers,
fetchGSKByComGroup,
} from "../../services/api/directories/company_groups";
import { useAppDispatch, useAppSelector } from "../../store/store";
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
type FormValues = {
groupId: string;
subGroupId: string;
company_group_id: string;
related_group_id: string;
year: number;
quarter: string; // example of 2nd static field
legal_entity_ids: string[];
};
type Props = {
@ -23,27 +37,21 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
const { raw_rows, loading } = useAppSelector((state) => state.company_groups);
// Subgroups are local — only this modal needs them
const [subgroups, setSubgroups] = useState([]);
const [subgroups, setSubgroups] = useState<any[]>([]);
const [loadingSubgroups, setLoadingSubgroups] = useState(false);
// Guards against out-of-order responses when group is changed quickly
const requestIdRef = useRef(0);
const subgroupReqRef = useRef(0);
const groupId = Form.useWatch("groupId", form);
const subGroupId = Form.useWatch("subGroupId", form);
const [legEntities, setLegEntities] = useState<any[]>([]);
const [loadingEntities, setLoadingEntities] = useState(false);
const legEntReqRef = useRef(0);
const [legEntSearch, setLegEntSearch] = useState("");
const company_group_id = Form.useWatch("company_group_id", form);
const related_group_id = Form.useWatch("related_group_id", form);
const year = Form.useWatch("year", form);
const quarter = Form.useWatch("quarter", form);
const isSubmitDisabled = !groupId || !subGroupId || !year || !quarter;
// Year options: current year and 3 years before
const yearOptions = useMemo(() => {
const currentYear = new Date().getFullYear();
return Array.from({ length: 4 }, (_, i) => {
const y = currentYear - i;
return { value: y, label: String(y) };
});
}, []);
const semester = Form.useWatch("quarter", form);
const legEntIds = Form.useWatch("legal_entity_ids", form);
const quarterOptions = [
{ value: "Q1", label: "1 квартал" },
@ -51,36 +59,94 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
{ value: "Q3", label: "3 квартал" },
{ value: "Q4", label: "4 квартал" },
];
// Load groups if store is empty when modal opens
useEffect(() => {
if (open && raw_rows.length === 0 && !loading) {
dispatch(getCompanyGroupsThunk());
}
}, [open, raw_rows.length, loading, dispatch]);
// When groupId changes -> reset second field, fetch subgroups locally
const isSubmitDisabled =
!company_group_id ||
!related_group_id ||
!year ||
!semester ||
!legEntIds?.length;
const yearOptions = useMemo(() => {
const y = new Date().getFullYear();
return Array.from({ length: 4 }, (_, i) => ({
value: y - i,
label: String(y - i),
}));
}, []);
const clearLegEnts = () => {
setLegEntities([]);
setLoadingEntities(false);
setLegEntSearch("");
legEntReqRef.current++;
};
// const handleGroupChange = async (value?: string) => {
// form.setFieldsValue({
// related_group_id: undefined as any,
// legal_entity_ids: [],
// });
// setSubgroups([]);
// clearLegEnts();
// if (!value) return;
// const reqId = ++subgroupReqRef.current;
// setLoadingSubgroups(true);
// try {
// const data = await fetchGSKByComGroup(value);
// if (reqId === subgroupReqRef.current) setSubgroups(data);
// } finally {
// if (reqId === subgroupReqRef.current) setLoadingSubgroups(false);
// }
// };
// const handleSubGroupChange = async (value?: string) => {
// form.setFieldsValue({ legal_entity_ids: [] });
// clearLegEnts();
// if (!value) return;
// const reqId = ++legEntReqRef.current;
// setLoadingEntities(true);
// try {
// const data = await fetchGroupMembers(value);
// if (reqId === legEntReqRef.current) setLegEntities(data);
// } finally {
// if (reqId === legEntReqRef.current) setLoadingEntities(false);
// }
// };
const handleGroupChange = async (value?: string) => {
form.setFieldsValue({ subGroupId: undefined as any });
form.setFieldsValue({
related_group_id: undefined as any,
legal_entity_ids: [],
});
setSubgroups([]);
clearLegEnts();
if (!value) return;
const reqId = ++requestIdRef.current;
const reqId = ++subgroupReqRef.current;
const leReqId = ++legEntReqRef.current;
setLoadingSubgroups(true);
setLoadingEntities(true);
try {
const data = await fetchGSKByComGroup(value);
// Ignore stale responses
if (reqId === requestIdRef.current) setSubgroups(data);
const dataSubgroups = await fetchGSKByComGroup(value);
const dataLegEnts = await fetchGroupMembers(value);
if (reqId === subgroupReqRef.current) setSubgroups(dataSubgroups);
if (leReqId === legEntReqRef.current) setLegEntities(dataLegEnts);
} finally {
if (reqId === requestIdRef.current) setLoadingSubgroups(false);
if (reqId === subgroupReqRef.current) setLoadingSubgroups(false);
if (leReqId === legEntReqRef.current) setLoadingEntities(false);
}
};
const resetLocal = () => {
setSubgroups([]);
setLoadingSubgroups(false);
requestIdRef.current++; // invalidate any in-flight request
subgroupReqRef.current++;
clearLegEnts();
setLegEntSearch("");
};
const handleOk = async () => {
@ -89,9 +155,7 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
await onOk(values);
form.resetFields();
resetLocal();
} catch {
// validation error - keep modal open
}
} catch {}
};
const handleCancel = () => {
@ -100,6 +164,12 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
onCancel();
};
const filteredLegEnts = useMemo(() => {
if (!legEntSearch) return legEntities;
const q = legEntSearch.toLowerCase();
return legEntities.filter((s) => s.name.toLowerCase().includes(q));
}, [legEntities, legEntSearch]);
return (
<Modal
title="Новая консолидация"
@ -107,20 +177,25 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
onCancel={handleCancel}
onOk={handleOk}
okButtonProps={{ disabled: isSubmitDisabled }}
okText="+ Создать"
cancelText="Отмена"
okText="Submit"
destroyOnHidden
width={640}
>
<Form form={form} layout="vertical" preserve={false}>
<Form
form={form}
layout="vertical"
preserve={false}
initialValues={{ legEntIds: [] }}
>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="groupId"
label="ГК"
rules={[{ required: true, message: "Укажите группу" }]}
name="company_group_id"
label="Группа компаний"
rules={[{ required: true, message: "Укажите ГК" }]}
>
<Select
placeholder="Выберите группу"
placeholder="Выберите ГК"
loading={loading}
showSearch
allowClear
@ -131,20 +206,20 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
}
options={raw_rows.map((g) => ({ value: g.id, label: g.name }))}
onChange={handleGroupChange}
notFoundContent={loading ? "Загрузка" : "Нет вариантов"}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="subGroupId"
label="ГСК/ГСЗ"
name="related_group_id"
label="ГСК"
rules={[{ required: true, message: "Укажите ГСК" }]}
>
<Select
placeholder={groupId ? "Выберите ГСК" : "Сначала выберите ГК"}
disabled={!groupId}
placeholder={
company_group_id ? "Выберите ГСК" : "Сначала выберите ГК"
}
disabled={!company_group_id}
loading={loadingSubgroups}
showSearch
allowClear
@ -154,9 +229,7 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
.includes(input.toLowerCase())
}
options={subgroups.map((s) => ({ value: s.id, label: s.name }))}
notFoundContent={
loadingSubgroups ? "Загрузка" : "Нет вариантов"
}
// onChange={handleSubGroupChange}
/>
</Form.Item>
</Col>
@ -170,7 +243,6 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
<Select placeholder="Выберите год" options={yearOptions} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="quarter"
@ -180,6 +252,105 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
<Select placeholder="Выберите квартал" options={quarterOptions} />
</Form.Item>
</Col>
{/* ROW 3 - full width block, only after subgroup is selected */}
{related_group_id && (
<Col span={24}>
<Form.Item
name="legal_entity_ids"
label="ЮЛ консолидации"
rules={[
{ required: true, message: "Выберите хотя бы одно ЮЛ" },
]}
>
<Checkbox.Group style={{ width: "100%" }}>
<div
style={{
border: "1px solid #d9d9d9",
borderRadius: 8,
padding: 12,
width: "100%",
}}
>
<Input.Search
placeholder="Поиск ЮЛ..."
allowClear
value={legEntSearch}
onChange={(e) => setLegEntSearch(e.target.value)}
style={{ marginBottom: 12 }}
/>
{loadingEntities ? (
<div style={{ textAlign: "center", padding: 24 }}>
<Spin />
</div>
) : filteredLegEnts.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
legEntities.length === 0
? "No students"
: "No matches"
}
/>
) : (
<div
style={{
maxHeight: 200,
overflowY: "auto",
display: "flex",
flexDirection: "column",
width: "100%",
gap: 8,
}}
>
{filteredLegEnts.map((s) => (
<Checkbox
key={s.id}
value={s.id}
style={{ width: "100%", display: "flex" }}
>
<div
style={{
flex: 1,
justifySelf: "flex-start",
display: "flex",
justifyContent: "space-between",
width: "100%",
gap: "30px",
}}
>
<span style={{ display: "inline-block" }}>
{s.name}
</span>
<span
style={{
display: "inline-block",
color: "rgb(102, 112, 133)",
fontSize: 11,
}}
>
{s.inn}
</span>
</div>
</Checkbox>
))}
</div>
)}
{!loadingEntities && legEntities.length > 0 && (
<div style={{ marginTop: 8, display: "flex", gap: 12 }}>
<span style={{ marginLeft: "auto", color: "#8c8c8c" }}>
Выбрано: {legEntIds?.length ?? 0} /{" "}
{legEntities.length}
</span>
</div>
)}
</div>
</Checkbox.Group>
</Form.Item>
</Col>
)}
</Row>
</Form>
</Modal>

View File

@ -24,6 +24,8 @@ import {
import { USERS_ADMIN } from "../../auth/permissions";
import { useCurrentUser } from "../../auth/MeContext";
import { MyModal } from "../NewConsoleModal/NewConsoleModal";
import { api } from "../../api/client";
import { createConsolidation } from "../../services/api/consolidations";
type MenuItem = Required<MenuProps>["items"][number];
@ -245,7 +247,7 @@ export function Sidebar() {
setModalOpen(false);
}}
onOk={async (values) => {
// await api.create(values)
await createConsolidation(values);
setModalOpen(false);
}}
/>