second update
This commit is contained in:
parent
383e4e3e5d
commit
81548eb4ae
74
src/App.tsx
74
src/App.tsx
@ -1,33 +1,53 @@
|
||||
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 } from './layouts/Admin';
|
||||
import { Main } from './layouts/Main';
|
||||
import { store } from './store/store';
|
||||
import { Sidebar } from './widgets/Sidebar/Sidebar';
|
||||
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,
|
||||
ConsolidationPage,
|
||||
DocStorage,
|
||||
GroupsLayout,
|
||||
Main,
|
||||
Mono,
|
||||
} from "./layouts/";
|
||||
import { store } from "./store/store";
|
||||
import { Sidebar } from "./widgets/Sidebar/Sidebar";
|
||||
import { FileViewer } from "./layouts/FileViewer";
|
||||
|
||||
// Каркас: экраны по 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='/' element={<Main />} />
|
||||
<Route path='/admin' element={<Admin />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
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="monozaemchiki" element={<Mono />}></Route>
|
||||
</Route>
|
||||
<Route path="/catalogue/groups" element={<CatalogueGroup />} />
|
||||
<Route path="/catalogue/jur_l" element={<CatalogueJur />} />
|
||||
<Route path="/files/:file_id" element={<FileViewer />} />
|
||||
<Route path="/docstorage" element={<DocStorage />} />
|
||||
<Route path="/logging" element={<AuditLogging />} />
|
||||
</Routes>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</AuthGate>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
BIN
src/assets/fonts/Geist-Bold.woff2
Executable file
BIN
src/assets/fonts/Geist-Bold.woff2
Executable file
Binary file not shown.
BIN
src/assets/fonts/Geist-Light.woff2
Executable file
BIN
src/assets/fonts/Geist-Light.woff2
Executable file
Binary file not shown.
BIN
src/assets/fonts/Geist-Medium.woff2
Executable file
BIN
src/assets/fonts/Geist-Medium.woff2
Executable file
Binary file not shown.
BIN
src/assets/fonts/Geist-Regular.woff2
Executable file
BIN
src/assets/fonts/Geist-Regular.woff2
Executable file
Binary file not shown.
BIN
src/assets/fonts/Geist-SemiBold.woff2
Executable file
BIN
src/assets/fonts/Geist-SemiBold.woff2
Executable file
Binary file not shown.
BIN
src/assets/fonts/Geist-Thin.woff2
Executable file
BIN
src/assets/fonts/Geist-Thin.woff2
Executable file
Binary file not shown.
161
src/components/FileUploaderModel.tsx
Normal file
161
src/components/FileUploaderModel.tsx
Normal file
@ -0,0 +1,161 @@
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
FileOutlined,
|
||||
UploadOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Avatar, List, Modal, Typography, Upload, Button } from "antd";
|
||||
import { useState } from "react";
|
||||
import { Config } from "../conf/config";
|
||||
|
||||
type FileItem = {
|
||||
id: string;
|
||||
file: File;
|
||||
status: "queue" | "success" | "error";
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export default function FileUploaderModal({
|
||||
legal_entity_id,
|
||||
trigger_sse_cb,
|
||||
}: {
|
||||
legal_entity_id: string;
|
||||
trigger_sse_cb: () => void;
|
||||
}) {
|
||||
const [currentFiles, setCurrentFiles] = useState<FileItem[]>([]);
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
// const [setDisplayedFiles, ]
|
||||
const beforeUpload = (file: File) => {
|
||||
const newItem: FileItem = {
|
||||
id: crypto.randomUUID(),
|
||||
file,
|
||||
status: "queue",
|
||||
};
|
||||
setCurrentFiles((prev) => [...prev, newItem]);
|
||||
return false;
|
||||
};
|
||||
const uploadOne = async (item) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", item);
|
||||
formData.append("legal_entity_id", legal_entity_id);
|
||||
formData.append("year", "2025");
|
||||
formData.append("quarter", "Q3");
|
||||
const res = await fetch(`${Config.API_URL}/files`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) throw new Error((await res.text()) || "Upload error");
|
||||
};
|
||||
const handleSubmit = async () => {
|
||||
setSubmitting(true);
|
||||
for (const item of currentFiles) {
|
||||
if (item.status === "success") continue;
|
||||
|
||||
try {
|
||||
await uploadOne(item.file);
|
||||
setCurrentFiles((prev) =>
|
||||
prev.map((f) => (f.id === item.id ? { ...f, status: "success" } : f)),
|
||||
);
|
||||
} catch (e) {
|
||||
setCurrentFiles((prev) =>
|
||||
prev.map((f) =>
|
||||
f.id === item.id
|
||||
? {
|
||||
...f,
|
||||
status: "error",
|
||||
errorMessage: e instanceof Error ? e.message : "Ошибка",
|
||||
}
|
||||
: f,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
setSubmitting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
style={{
|
||||
backgroundColor: "rgba(46, 166, 75, 0.8)",
|
||||
color: "white",
|
||||
padding: "10px 20px",
|
||||
border: "none",
|
||||
borderRadius: "5px",
|
||||
cursor: "pointer",
|
||||
width: "30%",
|
||||
marginLeft: "35%",
|
||||
// margin: "30px auto 20px auto",
|
||||
marginBottom: "20px",
|
||||
}}
|
||||
>
|
||||
<UploadOutlined style={{ marginRight: "10px" }} /> Добавить файлы
|
||||
</button>
|
||||
<Modal
|
||||
title="Загрузка файлов"
|
||||
onCancel={() => {
|
||||
setCurrentFiles([]);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onOk={async () => {
|
||||
trigger_sse_cb();
|
||||
setIsOpen(false);
|
||||
setCurrentFiles([]);
|
||||
}}
|
||||
cancelText="Отмена"
|
||||
okText="Готово"
|
||||
open={isOpen}
|
||||
>
|
||||
{currentFiles.length === 0 ? (
|
||||
<>
|
||||
<Upload.Dragger
|
||||
multiple
|
||||
beforeUpload={beforeUpload}
|
||||
showUploadList={false}
|
||||
accept=".xlsx,.xls,.pdf,.jpg,.jpeg"
|
||||
>
|
||||
<Avatar>
|
||||
<UploadOutlined />
|
||||
</Avatar>
|
||||
</Upload.Dragger>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<List
|
||||
style={{ marginTop: 16 }}
|
||||
dataSource={currentFiles}
|
||||
renderItem={(item) => (
|
||||
<List.Item>
|
||||
<div style={{ width: "100%" }}>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "center", gap: 8 }}
|
||||
>
|
||||
<FileOutlined />
|
||||
<Typography.Text>{item.file.name}</Typography.Text>
|
||||
{item.status === "success" && (
|
||||
<CheckCircleOutlined style={{ color: "green" }} />
|
||||
)}
|
||||
{item.status === "error" && (
|
||||
<CloseCircleOutlined style={{ color: "red" }} />
|
||||
)}
|
||||
</div>
|
||||
{item.status === "error" && (
|
||||
<Typography.Text type="danger">
|
||||
{item.errorMessage}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
<Button type="button" onClick={handleSubmit}>
|
||||
Отправить
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
62
src/components/FilesList.tsx
Normal file
62
src/components/FilesList.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import {
|
||||
FileImageOutlined,
|
||||
FileExcelOutlined,
|
||||
FilePdfOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { Flex } from "antd";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export type FileDisplayItem = {
|
||||
id: string;
|
||||
legal_entity_id: string;
|
||||
status: string;
|
||||
file_type: FileTypeEnum;
|
||||
original_name: string;
|
||||
document_type: string | null;
|
||||
};
|
||||
export type FileTypeEnum = "XLSX" | "JPG" | "PDF" | "XLS" | "JPEG";
|
||||
|
||||
const typeToIconMapping: Record<FileTypeEnum, any> = {
|
||||
JPEG: <FileImageOutlined style={{ fontSize: 30 }} />,
|
||||
XLSX: (
|
||||
<FileExcelOutlined
|
||||
style={{ fontSize: 30, color: "green", marginLeft: 20, marginRight: 20 }}
|
||||
/>
|
||||
),
|
||||
JPG: <FileImageOutlined style={{ fontSize: 30 }} />,
|
||||
XLS: <FileExcelOutlined style={{ fontSize: 30 }} />,
|
||||
PDF: (
|
||||
<FilePdfOutlined
|
||||
style={{ fontSize: 30, color: "red", marginLeft: 20, marginRight: 20 }}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export function FileItem({ file }: { file: FileDisplayItem }) {
|
||||
const icon = typeToIconMapping[file.file_type];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
border: "1px solid black",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
}}
|
||||
>
|
||||
{/* <div style={{ marginLeft: "10px" }}> */}
|
||||
{typeToIconMapping[file.file_type]}
|
||||
{/* </div> */}
|
||||
|
||||
{/* <FilePdfOutlined /> */}
|
||||
<div>
|
||||
<Link
|
||||
to={`/files/${file.id}?file_type=${file.file_type}&entity_id=${file.legal_entity_id}`}
|
||||
>
|
||||
<h4>{file.original_name}</h4>
|
||||
</Link>
|
||||
<p>{file.status ?? "В очереди"}</p>
|
||||
<p>{file.document_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/GenericTable/ColumnSettings.tsx
Normal file
51
src/components/GenericTable/ColumnSettings.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import { Button, Checkbox, Dropdown } from "antd";
|
||||
import { SettingOutlined } from "@ant-design/icons";
|
||||
import type { MenuProps } from "antd";
|
||||
import { isGroup, type ColumnDef } from "./tableTypes";
|
||||
|
||||
interface Props<T> {
|
||||
defs: ColumnDef<T>[];
|
||||
visibleKeys: Array<Extract<keyof T, string>>;
|
||||
onChange: (keys: Array<Extract<keyof T, string>>) => void;
|
||||
}
|
||||
export function ColumnSettings<T>({ defs, visibleKeys, onChange }: Props<T>) {
|
||||
const toggle = (key: Extract<keyof T, string>, checked: boolean) => {
|
||||
onChange(
|
||||
checked ? [...visibleKeys, key] : visibleKeys.filter((k) => k !== key),
|
||||
);
|
||||
};
|
||||
const items: MenuProps["items"] = defs.flatMap((def) => {
|
||||
if (isGroup(def))
|
||||
return def.children.map((child) => ({
|
||||
key: child.key,
|
||||
label: (
|
||||
<Checkbox
|
||||
checked={visibleKeys.includes(child.key)}
|
||||
onChange={(e) => toggle(child.key, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{def.title} - {child.title}
|
||||
</Checkbox>
|
||||
),
|
||||
}));
|
||||
return [
|
||||
{
|
||||
key: def.key,
|
||||
label: (
|
||||
<Checkbox
|
||||
checked={visibleKeys.includes(def.key)}
|
||||
onChange={(e) => toggle(def.key, e.target.checked)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{def.title}
|
||||
</Checkbox>
|
||||
),
|
||||
},
|
||||
];
|
||||
});
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={["click"]} placement="bottomRight">
|
||||
<Button icon={<SettingOutlined />}>Настройка таблицы</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
217
src/components/GenericTable/CustomFilterDropdown.tsx
Normal file
217
src/components/GenericTable/CustomFilterDropdown.tsx
Normal file
@ -0,0 +1,217 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Input, Checkbox, Button, Radio, Divider, Empty, Space } from "antd";
|
||||
import {
|
||||
MinusOutlined,
|
||||
SearchOutlined,
|
||||
SortAscendingOutlined,
|
||||
SortDescendingOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import type { FilterDropdownProps } from "antd/es/table/interface";
|
||||
import type { FilterFeatures, FilterOption, SortDirection } from "./tableTypes";
|
||||
const NO_SORT = "__none__";
|
||||
|
||||
interface CustomFilterDropdownProps extends Pick<
|
||||
FilterDropdownProps,
|
||||
"selectedKeys" | "setSelectedKeys" | "confirm" | "clearFilters"
|
||||
> {
|
||||
options: FilterOption[];
|
||||
features: FilterFeatures;
|
||||
onSort?: (direction: SortDirection) => void;
|
||||
sortDirection?: SortDirection;
|
||||
}
|
||||
|
||||
export function CustomFilterDropdown({
|
||||
selectedKeys,
|
||||
setSelectedKeys,
|
||||
confirm,
|
||||
clearFilters,
|
||||
options,
|
||||
features,
|
||||
onSort,
|
||||
sortDirection = null,
|
||||
}: CustomFilterDropdownProps) {
|
||||
const { multiselect, search, sort } = features;
|
||||
|
||||
// DRAFTS — nothing is committed until OK
|
||||
const defaultKeys = useMemo(
|
||||
() => options.map((o) => o.value as React.Key),
|
||||
[options],
|
||||
);
|
||||
|
||||
const [draftKeys, setDraftKeys] = useState<React.Key[]>(
|
||||
selectedKeys.length ? selectedKeys : defaultKeys,
|
||||
);
|
||||
const [draftSort, setDraftSort] = useState<SortDirection>(sortDirection);
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
setDraftKeys(selectedKeys.length ? selectedKeys : defaultKeys);
|
||||
}, [selectedKeys, defaultKeys]);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftSort(sortDirection);
|
||||
}, [sortDirection]);
|
||||
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!search || !query) return options;
|
||||
const q = query.toLowerCase();
|
||||
return options.filter((o) =>
|
||||
String(o.label ?? o.value)
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [options, query, search]);
|
||||
|
||||
// all derived state reads DRAFT, not committed selectedKeys
|
||||
const visibleValues = filteredOptions.map((o) => o.value);
|
||||
const allChecked =
|
||||
visibleValues.length > 0 &&
|
||||
visibleValues.every((v) => draftKeys.includes(v as React.Key));
|
||||
const someChecked =
|
||||
visibleValues.some((v) => draftKeys.includes(v as React.Key)) &&
|
||||
!allChecked;
|
||||
|
||||
const toggleAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
const merged = new Set<React.Key>([
|
||||
...draftKeys,
|
||||
...(visibleValues as React.Key[]),
|
||||
]);
|
||||
setDraftKeys([...merged]);
|
||||
} else {
|
||||
const visible = new Set(visibleValues);
|
||||
setDraftKeys(draftKeys.filter((k) => !visible.has(k as any)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
if (multiselect) {
|
||||
const allValues = options.map((o) => o.value as React.Key);
|
||||
const isAll =
|
||||
draftKeys.length === allValues.length &&
|
||||
allValues.every((v) => draftKeys.includes(v));
|
||||
setSelectedKeys(isAll ? [] : draftKeys);
|
||||
}
|
||||
if (sort) onSort?.(draftSort);
|
||||
confirm();
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (multiselect) setDraftKeys([...defaultKeys]); // reset draft to all checked (="no filter")
|
||||
if (search) setQuery("");
|
||||
if (sort) setDraftSort(null);
|
||||
};
|
||||
|
||||
const draftHasState =
|
||||
(multiselect && draftKeys.length > 0) ||
|
||||
(search && query.length > 0) ||
|
||||
(sort && draftSort !== null);
|
||||
|
||||
if (!multiselect && !search && !sort) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ padding: 8, width: 240 }}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{sort && (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, fontSize: 12, color: "#888" }}>
|
||||
Сортировка
|
||||
</div>
|
||||
<Radio.Group
|
||||
value={draftSort ?? NO_SORT}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setDraftSort(v === NO_SORT ? null : (v as "asc" | "desc"));
|
||||
}}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
<Radio value={NO_SORT}>
|
||||
<MinusOutlined /> Нет
|
||||
</Radio>
|
||||
<Radio value="asc">
|
||||
<SortAscendingOutlined /> По возрастанию
|
||||
</Radio>
|
||||
<Radio value="desc">
|
||||
<SortDescendingOutlined /> По убыванию
|
||||
</Radio>
|
||||
</Radio.Group>
|
||||
{(search || multiselect) && <Divider style={{ margin: "8px 0" }} />}
|
||||
</>
|
||||
)}
|
||||
|
||||
{search && (
|
||||
<Input
|
||||
allowClear
|
||||
size="small"
|
||||
placeholder="Поиск..."
|
||||
prefix={<SearchOutlined />}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
style={{ marginBottom: 8 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{multiselect && filteredOptions.length > 0 && (
|
||||
<>
|
||||
<Space style={{ marginBottom: 4 }}>
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
// style={{ paddingLeft: 0 }}
|
||||
disabled={allChecked}
|
||||
onClick={() => {
|
||||
const merged = new Set<React.Key>([
|
||||
...draftKeys,
|
||||
...(visibleValues as React.Key[]),
|
||||
]);
|
||||
setDraftKeys([...merged]);
|
||||
}}
|
||||
>
|
||||
Выбрать все
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
style={{ paddingLeft: 0 }}
|
||||
disabled={draftKeys.length === 0}
|
||||
onClick={() => {
|
||||
const visible = new Set(visibleValues);
|
||||
setDraftKeys(draftKeys.filter((k) => !visible.has(k as any)));
|
||||
}}
|
||||
>
|
||||
Очистить
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: "4px 0" }} />
|
||||
<div style={{ maxHeight: 220, overflowY: "auto" }}>
|
||||
<Checkbox.Group
|
||||
value={draftKeys as (string | number)[]}
|
||||
onChange={(vals) => setDraftKeys(vals as React.Key[])}
|
||||
style={{ display: "flex", flexDirection: "column", gap: 4 }}
|
||||
>
|
||||
{filteredOptions.map((o) => (
|
||||
<Checkbox key={String(o.value)} value={o.value}>
|
||||
{o.label}
|
||||
</Checkbox>
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider style={{ margin: "8px 0" }} />
|
||||
<Space style={{ display: "flex", justifyContent: "space-between" }}>
|
||||
<Button size="small" onClick={handleReset} disabled={!draftHasState}>
|
||||
Сбросить
|
||||
</Button>
|
||||
<Button type="primary" size="small" onClick={handleOk}>
|
||||
OK
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
223
src/components/GenericTable/DataTable.tsx
Normal file
223
src/components/GenericTable/DataTable.tsx
Normal file
@ -0,0 +1,223 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { Table } from "antd";
|
||||
import Icon, { FilterFilled, FilterOutlined } from "@ant-design/icons";
|
||||
import type { ColumnGroupType, ColumnsType, ColumnType } from "antd/es/table";
|
||||
import type { TableProps } from "antd";
|
||||
import { CustomFilterDropdown } from "./CustomFilterDropdown";
|
||||
import { Highlight } from "./Highlight";
|
||||
import {
|
||||
isGroup,
|
||||
LeafColumnDef,
|
||||
type ColumnFilters,
|
||||
type FilterOption,
|
||||
type SortState,
|
||||
type TableConfig,
|
||||
} from "./tableTypes";
|
||||
type ExtraColumn<T> = ColumnType<T> & {
|
||||
position?: number | "start" | "end";
|
||||
afterKey?: Extract<keyof T, string>;
|
||||
};
|
||||
interface DataTableProps<T extends Record<string, any>> {
|
||||
config: TableConfig<T>;
|
||||
rows: T[];
|
||||
allRows: T[];
|
||||
loading?: boolean;
|
||||
visibleKeys: Array<Extract<keyof T, string>>;
|
||||
globalQuery?: string;
|
||||
columnFilters: ColumnFilters<T>;
|
||||
onColumnFiltersChange: (next: ColumnFilters<T>) => void;
|
||||
sortState: SortState<T>;
|
||||
onSortChange: (next: SortState<T>) => void;
|
||||
extraColumns?: ColumnsType<T>;
|
||||
rowSelection?: TableProps<T>["rowSelection"];
|
||||
scrollY?: number | string;
|
||||
onRow?: TableProps<T>["onRow"];
|
||||
}
|
||||
|
||||
export function DataTable<T extends Record<string, any>>({
|
||||
config,
|
||||
rows,
|
||||
allRows,
|
||||
loading,
|
||||
visibleKeys,
|
||||
globalQuery = "",
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
sortState,
|
||||
onSortChange,
|
||||
extraColumns = [],
|
||||
rowSelection,
|
||||
scrollY = 480,
|
||||
onRow,
|
||||
}: DataTableProps<T>) {
|
||||
const { columns: defs, searchableKeys = [], rowKey } = config;
|
||||
const normalizedQuery = globalQuery.trim().toLowerCase();
|
||||
const hasGlobalSearch =
|
||||
searchableKeys.length > 0 && normalizedQuery.length > 0;
|
||||
|
||||
const getOptions = (key: Extract<keyof T, string>): FilterOption[] => {
|
||||
const seen = new Set<string | number>();
|
||||
const opts: FilterOption[] = [];
|
||||
for (const r of allRows) {
|
||||
const v = r[key];
|
||||
if (!seen.has(v)) {
|
||||
seen.add(v);
|
||||
opts.push({ label: String(v), value: v });
|
||||
}
|
||||
}
|
||||
return opts;
|
||||
};
|
||||
|
||||
const buildLeaf = (def: LeafColumnDef<T>): ColumnType<T> => {
|
||||
const { key, title, features = {}, render, width, fixed } = def;
|
||||
const hasDropdown =
|
||||
features.multiselect || features.search || features.sort;
|
||||
const isSearchable = searchableKeys.includes(key);
|
||||
return {
|
||||
key,
|
||||
title,
|
||||
dataIndex: key,
|
||||
width,
|
||||
fixed,
|
||||
render: (value: T[keyof T], row: T) => {
|
||||
if (render) return render(value, row);
|
||||
const text = String(value ?? "");
|
||||
return isSearchable && hasGlobalSearch ? (
|
||||
<Highlight text={text} query={globalQuery.trim()} />
|
||||
) : (
|
||||
text
|
||||
);
|
||||
},
|
||||
...(hasDropdown
|
||||
? {
|
||||
filterIcon: () => {
|
||||
const active =
|
||||
(columnFilters[key]?.length ?? 0) > 0 || sortState?.key === key;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: "4px 6px",
|
||||
borderRadius: 6,
|
||||
background: active
|
||||
? "rgba(245, 158, 11, 0.14)"
|
||||
: "transparent",
|
||||
color: active ? "rgb(22, 101, 52)" : "#bfbfbf",
|
||||
transition: "background 0.15s",
|
||||
}}
|
||||
>
|
||||
<FilterOutlined style={{ fontSize: 12 }} />
|
||||
</span>
|
||||
);
|
||||
},
|
||||
filterDropdown: ({ confirm, clearFilters }) => (
|
||||
<CustomFilterDropdown
|
||||
selectedKeys={(columnFilters[key] ?? []) as React.Key[]}
|
||||
setSelectedKeys={(keys) =>
|
||||
onColumnFiltersChange({
|
||||
...columnFilters,
|
||||
[key]: keys as (string | number)[],
|
||||
})
|
||||
}
|
||||
confirm={confirm}
|
||||
clearFilters={clearFilters}
|
||||
features={features}
|
||||
options={
|
||||
features.multiselect || features.search ? getOptions(key) : []
|
||||
}
|
||||
sortDirection={sortState?.key === key ? sortState.dir : null}
|
||||
onSort={(dir) => onSortChange(dir ? { key, dir } : null)}
|
||||
/>
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
};
|
||||
const columns: ColumnsType<T> = useMemo(() => {
|
||||
const built: ColumnsType<T> = [];
|
||||
for (const def of defs) {
|
||||
if (isGroup(def)) {
|
||||
const visibleChildren = def.children.filter((c) =>
|
||||
visibleKeys.includes(c.key),
|
||||
);
|
||||
if (visibleChildren.length === 0) continue;
|
||||
built.push({
|
||||
key: def.groupKey,
|
||||
title: def.title,
|
||||
fixed: def.fixed,
|
||||
children: visibleChildren.map(buildLeaf),
|
||||
} as ColumnGroupType<T>);
|
||||
} else {
|
||||
if (!visibleKeys.includes(def.key)) continue;
|
||||
built.push(buildLeaf(def));
|
||||
}
|
||||
}
|
||||
for (const col of extraColumns) {
|
||||
const { position, afterKey, ...rest } = col as ExtraColumn<T> & {
|
||||
position?: any;
|
||||
afterKey?: any;
|
||||
};
|
||||
let idx: number;
|
||||
if (afterKey) {
|
||||
const f = built.findIndex(
|
||||
(c: any) => c.key === afterKey || c.dataIndex === afterKey,
|
||||
);
|
||||
idx = f === -1 ? built.length : f + 1;
|
||||
} else if (position === "start") idx = 0;
|
||||
else if (position === "end" || position === undefined) idx = built.length;
|
||||
else idx = Math.max(0, Math.min(position, built.length));
|
||||
built.splice(idx, 0, rest as ColumnsType<T>[number]);
|
||||
}
|
||||
return built;
|
||||
}, [
|
||||
defs,
|
||||
visibleKeys,
|
||||
sortState,
|
||||
allRows,
|
||||
searchableKeys,
|
||||
globalQuery,
|
||||
hasGlobalSearch,
|
||||
columnFilters,
|
||||
onColumnFiltersChange,
|
||||
onSortChange,
|
||||
extraColumns,
|
||||
]);
|
||||
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const [appliedScrollY, setAppliedScrollY] = useState(scrollY);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const wrapper = wrapperRef.current;
|
||||
if (!wrapper) return;
|
||||
|
||||
const update = () => {
|
||||
const total = wrapper.clientHeight;
|
||||
|
||||
const header = wrapper.querySelector<HTMLElement>(".ant-table-thead");
|
||||
const headerH = header?.offsetHeight ?? 55;
|
||||
setAppliedScrollY(Math.max(0, total - headerH));
|
||||
};
|
||||
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(wrapper);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} style={{ flex: 1, height: "100%" }}>
|
||||
<Table<T>
|
||||
rowKey={rowKey}
|
||||
loading={loading}
|
||||
columns={columns}
|
||||
dataSource={rows}
|
||||
rowSelection={rowSelection}
|
||||
pagination={false}
|
||||
scroll={{ x: "max-content", y: appliedScrollY }}
|
||||
onRow={onRow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/GenericTable/Highlight.tsx
Normal file
21
src/components/GenericTable/Highlight.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
function escapeRegExp(s: string) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
export function Highlight({ text, query }: { text: string; query: string }) {
|
||||
if (!query) return <>{text}</>;
|
||||
const parts = text.split(new RegExp(`(${escapeRegExp(query)})`, "gi"));
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
part.toLowerCase() === query.toLowerCase() ? (
|
||||
<mark key={i} style={{ background: "#ffd666", padding: 0 }}>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<span key={i}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
4
src/components/GenericTable/columnUtils.ts
Normal file
4
src/components/GenericTable/columnUtils.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { isGroup, type ColumnDef, type LeafColumnDef } from "./tableTypes";
|
||||
export function collectLeaves<T>(cols: ColumnDef<T>[]): LeafColumnDef<T>[] {
|
||||
return cols.flatMap((c) => (isGroup(c) ? c.children : [c]));
|
||||
}
|
||||
9
src/components/GenericTable/index.ts
Normal file
9
src/components/GenericTable/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export * from "./tableTypes";
|
||||
export { Highlight } from "./Highlight";
|
||||
export { CustomFilterDropdown } from "./CustomFilterDropdown";
|
||||
export { ColumnSettings } from "./ColumnSettings";
|
||||
export { DataTable } from "./DataTable";
|
||||
export { useVisibleColumns } from "./useVisibleColumns";
|
||||
export { useFilteredRows } from "./useFilteredRows";
|
||||
export { useTableFilters } from "./useTableFilters";
|
||||
export { collectLeaves } from "./columnUtils";
|
||||
45
src/components/GenericTable/tableTypes.ts
Normal file
45
src/components/GenericTable/tableTypes.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export type SortDirection = "asc" | "desc" | null;
|
||||
export type SortState<T> = {
|
||||
key: Extract<keyof T, string>;
|
||||
dir: "asc" | "desc";
|
||||
} | null;
|
||||
export interface FilterFeatures {
|
||||
multiselect?: boolean;
|
||||
search?: boolean;
|
||||
sort?: boolean;
|
||||
}
|
||||
export interface FilterOption {
|
||||
label: ReactNode;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
export interface LeafColumnDef<T> {
|
||||
key: Extract<keyof T, string>;
|
||||
title: string;
|
||||
features?: FilterFeatures;
|
||||
width?: number;
|
||||
fixed?: "left" | "right";
|
||||
render?: (value: T[keyof T], row: T) => ReactNode;
|
||||
comparator?: (a: T, b: T) => number;
|
||||
}
|
||||
export interface GroupColumnDef<T> {
|
||||
title: string;
|
||||
groupKey: string;
|
||||
children: LeafColumnDef<T>[];
|
||||
fixed?: "left" | "right";
|
||||
}
|
||||
export type ColumnDef<T> = LeafColumnDef<T> | GroupColumnDef<T>;
|
||||
|
||||
export interface TableConfig<T> {
|
||||
rowKey: Extract<keyof T, string>;
|
||||
columns: ColumnDef<T>[];
|
||||
searchableKeys?: Array<Extract<keyof T, string>>;
|
||||
}
|
||||
export type ColumnFilters<T> = Partial<
|
||||
Record<Extract<keyof T, string>, Array<string | number>>
|
||||
>;
|
||||
export function isGroup<T>(c: ColumnDef<T>): c is GroupColumnDef<T> {
|
||||
return (c as GroupColumnDef<T>).children !== undefined;
|
||||
}
|
||||
62
src/components/GenericTable/useFilteredRows.ts
Normal file
62
src/components/GenericTable/useFilteredRows.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { useMemo } from "react";
|
||||
import { ColumnFilters, SortState, TableConfig } from "./tableTypes";
|
||||
|
||||
export function useFilteredRows<T extends Record<string, any>>(
|
||||
rawRows: T[],
|
||||
config: TableConfig<T>,
|
||||
globalQuery: string,
|
||||
columnFilters: ColumnFilters<T>,
|
||||
sort: SortState<T>,
|
||||
) {
|
||||
const filtered = useMemo(() => {
|
||||
const q = globalQuery.trim().toLowerCase();
|
||||
const searchable = config.searchableKeys ?? [];
|
||||
|
||||
let rows = rawRows;
|
||||
|
||||
// global Ctrl+F search across searchable columns
|
||||
if (q && searchable.length > 0) {
|
||||
rows = rows.filter((row) =>
|
||||
searchable.some((k) =>
|
||||
String(row[k] ?? "")
|
||||
.toLowerCase()
|
||||
.includes(q),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// per-column multiselect
|
||||
for (const key in columnFilters) {
|
||||
const selected = columnFilters[key as Extract<keyof T, string>];
|
||||
if (selected && selected.length > 0) {
|
||||
rows = rows.filter((row) =>
|
||||
selected.includes(row[key as keyof T] as unknown as string | number),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}, [rawRows, config, globalQuery, columnFilters]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
if (!sort) return filtered; // null = original backend order
|
||||
const def = config.columns.find((c) => c.key === sort.key);
|
||||
const { key, dir } = sort;
|
||||
return [...filtered].sort((a, b) => {
|
||||
const cmp = def?.comparator
|
||||
? def.comparator(a, b)
|
||||
: a[key] < b[key]
|
||||
? -1
|
||||
: a[key] > b[key]
|
||||
? 1
|
||||
: 0;
|
||||
return dir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
}, [filtered, sort, config.columns]);
|
||||
|
||||
return {
|
||||
rows: sorted,
|
||||
displayedCount: sorted.length,
|
||||
totalCount: rawRows.length,
|
||||
};
|
||||
}
|
||||
25
src/components/GenericTable/useTableData.ts
Normal file
25
src/components/GenericTable/useTableData.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Fetches once per fetcher identity. Wrap per-widget fetchers in useCallback
|
||||
// so visibility/search/filter state changes never trigger a refetch.
|
||||
export function useTableData<T>(fetcher: () => Promise<T[]>) {
|
||||
const [rows, setRows] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
setLoading(true);
|
||||
fetcher()
|
||||
.then((data) => {
|
||||
if (alive) setRows(data);
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [fetcher]);
|
||||
|
||||
return { rows, loading };
|
||||
}
|
||||
31
src/components/GenericTable/useTableFilters.ts
Normal file
31
src/components/GenericTable/useTableFilters.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// components/DataTable/useTableFilters.ts
|
||||
import { useCallback, useState } from "react";
|
||||
import type { ColumnFilters, SortState } from "./tableTypes";
|
||||
|
||||
export function useTableFilters<T>() {
|
||||
const [rawQuery, setRawQuery] = useState("");
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFilters<T>>({});
|
||||
const [sort, setSort] = useState<SortState<T>>(null);
|
||||
|
||||
const resetAll = useCallback(() => {
|
||||
setRawQuery("");
|
||||
setColumnFilters({});
|
||||
setSort(null);
|
||||
}, []);
|
||||
|
||||
const hasActiveFilters =
|
||||
rawQuery.trim().length > 0 ||
|
||||
sort !== null ||
|
||||
Object.values(columnFilters).some((v) => v && v.length > 0);
|
||||
|
||||
return {
|
||||
rawQuery,
|
||||
setRawQuery,
|
||||
columnFilters,
|
||||
setColumnFilters,
|
||||
sort,
|
||||
setSort,
|
||||
resetAll,
|
||||
hasActiveFilters,
|
||||
};
|
||||
}
|
||||
10
src/components/GenericTable/useVisibleColumns.ts
Normal file
10
src/components/GenericTable/useVisibleColumns.ts
Normal file
@ -0,0 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import type { TableConfig } from "./tableTypes";
|
||||
|
||||
// All columns visible by default.
|
||||
export function useVisibleColumns<T>(config: TableConfig<T>) {
|
||||
const [visibleKeys, setVisibleKeys] = useState<
|
||||
Array<Extract<keyof T, string>>
|
||||
>(() => config.columns.map((c) => c.key));
|
||||
return { visibleKeys, setVisibleKeys };
|
||||
}
|
||||
54
src/components/PageTitle.tsx
Normal file
54
src/components/PageTitle.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { ArrowLeftOutlined } from "@ant-design/icons";
|
||||
import { Button } from "antd";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
interface PageTitleProps {
|
||||
location: string;
|
||||
header: string;
|
||||
actions?: React.FC;
|
||||
description?: string;
|
||||
backable?: boolean;
|
||||
}
|
||||
export default function PageTitle({
|
||||
location,
|
||||
header,
|
||||
actions,
|
||||
description,
|
||||
backable = false,
|
||||
}: PageTitleProps) {
|
||||
const navigate = useNavigate();
|
||||
// const location = useLocation();
|
||||
// const
|
||||
return (
|
||||
<header style={{ display: "flex", marginBottom: 12, flex: "0 0 auto" }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: "#667085" }}>
|
||||
{backable && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => navigate(-1)}
|
||||
style={{ marginRight: "5px" }}
|
||||
>
|
||||
<ArrowLeftOutlined />
|
||||
Назад
|
||||
</Button>
|
||||
)}
|
||||
{location}
|
||||
</div>
|
||||
<h1
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
lineHeight: 1.18,
|
||||
margin: 0,
|
||||
marginTop: 4,
|
||||
fontSize: 22,
|
||||
}}
|
||||
>
|
||||
{header}
|
||||
</h1>
|
||||
{description && <div style={{ color: "#667085" }}>{description}</div>}
|
||||
</div>
|
||||
<div style={{}}></div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,13 @@
|
||||
// biome-ignore lint/suspicious/noExplicitAny: process.env подставляется rsbuild на этапе сборки
|
||||
const environment = (process as any).env;
|
||||
//@ts-ignore
|
||||
const environment = process.env;
|
||||
|
||||
export const Config = {
|
||||
API_URL: (environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '') as string,
|
||||
APP_ADDRESS: `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}` as string,
|
||||
};
|
||||
export class Config {
|
||||
static API_URL: string = environment.REACT_APP_API_URL ?? environment.REACT_APP_ROOT_PATH ?? '';
|
||||
static APP_ADDRESS: string = `${environment.REACT_APP_ROOT_PATH || '/aurora/apps/fastapi-dkb-armka'}`
|
||||
|
||||
static KEYCLOAK_URL: string = environment.REACT_APP_KEYCLOAK_URL ?? '';
|
||||
static REALM: string = environment.REACT_APP_REALM ?? '';
|
||||
static CLIENT_ID: string = environment.REACT_APP_CLIENT_ID ?? '';
|
||||
static NAMESPACE: string = environment.REACT_APP_NAMESPACE ?? '';
|
||||
static APP_NAME: string = environment.REACT_APP_APP_NAME ?? '';
|
||||
}
|
||||
1
src/env.d.ts
vendored
1
src/env.d.ts
vendored
@ -1 +0,0 @@
|
||||
/// <reference types="@rsbuild/core/types" />
|
||||
@ -109,8 +109,50 @@ code {
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
} */
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("./assets/fonts/Geist-SemiBold.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("./assets/fonts/Geist-Light.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("./assets/fonts/Geist-Bold.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("./assets/fonts/Geist-Regular.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("./assets/fonts/Geist-Thin.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "Geist";
|
||||
src: url("./assets/fonts/Geist-Medium.woff2") format("woff2");
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Gill Sans", "Gill Sans MT", Calibri, "Trebuchet MS", sans-serif;
|
||||
margin: 0px;
|
||||
font-family: 'Geist', 'Gill Sans MT', Calibri, 'Trebuchet MS', sans-serif;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
@ -1,44 +1,44 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import { App as AntApp, ConfigProvider } from 'antd';
|
||||
import App from './App';
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App";
|
||||
import { ConfigProvider } from "antd";
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Элемент #root не найден');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
// <StrictMode>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: 'rgba(0, 255, 0, 0.5)',
|
||||
colorPrimaryHover: 'rgba(0, 255, 0, 1)',
|
||||
colorBgLayout: 'rgb(247, 248, 250)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: '#fff',
|
||||
},
|
||||
Button: {
|
||||
// textHoverBg: "green",
|
||||
colorBorder: 'rgba(0,0,0,0.5)',
|
||||
defaultActiveBorderColor: 'rgba(0,0,0,0.5)',
|
||||
defaultBorderColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
Input: {
|
||||
activeBorderColor: 'rgba(46, 166, 75, 0.35)',
|
||||
activeShadow: '0 0 0 1px #2ea64b59,0 1px 2px #0000000f',
|
||||
hoverBorderColor: 'rgba(46, 166, 75, 0.35)',
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{/* AntApp даёт message/notification через App.useApp() с темой ConfigProvider; component={false} — без лишнего div */}
|
||||
<AntApp component={false}>
|
||||
<App />
|
||||
</AntApp>
|
||||
</ConfigProvider>,
|
||||
// </StrictMode>,
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
// <StrictMode>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: "rgba(46, 166, 75, 0.8)",
|
||||
fontFamily: "Geist",
|
||||
// colorPrimaryHover: "black",
|
||||
colorBgLayout: "rgb(247, 248, 250)",
|
||||
colorBgTextHover: "#f8fafc",
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
siderBg: "#fff",
|
||||
},
|
||||
Button: {
|
||||
defaultBorderColor: "#c7cdd5",
|
||||
defaultHoverBg: "#f8fafc",
|
||||
defaultHoverColor: "black",
|
||||
defaultHoverBorderColor: "#c7cdd5",
|
||||
fontWeight: 500,
|
||||
},
|
||||
Input: {
|
||||
activeBorderColor: "rgba(46, 166, 75, 0.35)",
|
||||
activeShadow: "0 0 0 1px #2ea64b59,0 1px 2px #0000000f",
|
||||
hoverBorderColor: "rgba(46, 166, 75, 0.35)",
|
||||
},
|
||||
Table: {
|
||||
headerColor: "#667085",
|
||||
headerBg: "rgb(248, 250, 252)",
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<App />
|
||||
</ConfigProvider>,
|
||||
// </StrictMode>,
|
||||
);
|
||||
|
||||
3
src/layouts/AuditLogging.tsx
Normal file
3
src/layouts/AuditLogging.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function AuditLogging() {
|
||||
return <h3>Журнал действий</h3>;
|
||||
}
|
||||
7
src/layouts/Catalogue.tsx
Normal file
7
src/layouts/Catalogue.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export function Catalogue() {
|
||||
return (
|
||||
<>
|
||||
<h3>Справочники</h3>
|
||||
</>
|
||||
);
|
||||
}
|
||||
3
src/layouts/CatalogueGroups.tsx
Normal file
3
src/layouts/CatalogueGroups.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function CatalogueGroup() {
|
||||
return <h3>ГК и организации</h3>;
|
||||
}
|
||||
3
src/layouts/CatalogueJur.tsx
Normal file
3
src/layouts/CatalogueJur.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function CatalogueJur() {
|
||||
return <h3>ЮЛ вне ГК</h3>;
|
||||
}
|
||||
138
src/layouts/ConsolidationsList.tsx
Normal file
138
src/layouts/ConsolidationsList.tsx
Normal file
@ -0,0 +1,138 @@
|
||||
import {
|
||||
Button,
|
||||
Drawer,
|
||||
Splitter,
|
||||
Table,
|
||||
Tooltip,
|
||||
type TableProps,
|
||||
} from "antd";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FileItem } from "../components/FilesList";
|
||||
import { useParams } from "react-router-dom";
|
||||
import FileUploaderModal from "../components/FileUploaderModel";
|
||||
import { useAppDispatch, useAppSelector } from "../store/store";
|
||||
import { ConsoleLegalEntitiesTable } from "../widgets/ConsoleLegalEntities/ConsoleLegalEntities";
|
||||
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} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConsolidationPage() {
|
||||
const [legalEntities, setLegalEntities] = useState([]);
|
||||
const [legEntId, setLegEntId] = useState(null);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
// const [drawerContent, setDrawerContent] = useState([]);
|
||||
|
||||
const { group_id } = useParams();
|
||||
const dispatch = useAppDispatch();
|
||||
const drawerContent = useAppSelector((state) => state.files.currentOpenFiles);
|
||||
|
||||
const [comGroupName, setComGroupName] = useState("");
|
||||
const [locationPath, setLocationPath] = useState("Отчетность>");
|
||||
const [desc, setDesc] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
async function getAllInfo() {
|
||||
const data = await fetchOneCompanyGroup(group_id);
|
||||
setComGroupName(data.name);
|
||||
setLocationPath(`Отчетность>${data.name}>Консолидации`);
|
||||
setDesc(`${data.legal_entities_count} организаций`);
|
||||
dispatch(getConsolidationsListThunk(group_id));
|
||||
// dispatch()
|
||||
}
|
||||
getAllInfo();
|
||||
}, [dispatch, group_id]);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<PageTitle
|
||||
backable
|
||||
location={locationPath}
|
||||
header={comGroupName}
|
||||
description={desc}
|
||||
/>
|
||||
<main
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 10,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flex: "2 1 0",
|
||||
backgroundColor: "white",
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
overflowY: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<ConsoleTableWidget />
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: "3 1 0",
|
||||
backgroundColor: "white",
|
||||
borderTopLeftRadius: 12,
|
||||
borderTopRightRadius: 12,
|
||||
overflowY: "hidden",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<ConsoleLegalEntitiesTable
|
||||
onDrawerOpen={() => {
|
||||
setDrawerOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
<Drawer
|
||||
size="large"
|
||||
open={drawerOpen}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
>
|
||||
<DrawerContent />
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/layouts/DocStorage.tsx
Normal file
3
src/layouts/DocStorage.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export function DocStorage() {
|
||||
return <h3>Единое хранилище</h3>;
|
||||
}
|
||||
109
src/layouts/FileViewer.tsx
Normal file
109
src/layouts/FileViewer.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Link,
|
||||
useNavigate,
|
||||
useParams,
|
||||
useSearchParams,
|
||||
} from "react-router-dom";
|
||||
import { useAppDispatch, useAppSelector } from "../store/store";
|
||||
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";
|
||||
|
||||
function PdfViewer({ fileId }: { fileId: string }) {
|
||||
return (
|
||||
<>
|
||||
<h2>PDF</h2>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
// flexDirection: "row",
|
||||
gridTemplateColumns: "minmax(0,1fr) minmax(0,1fr)",
|
||||
height: "80vh",
|
||||
gap: 10,
|
||||
margin: 20,
|
||||
}}
|
||||
>
|
||||
<IFrameBlock fileId={fileId} />
|
||||
<PdfDataViewer fileId={fileId} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function XLSXViewer({ fileId }: { fileId: string }) {
|
||||
const [data, setData] = useState();
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const data_res = await fetch(
|
||||
`${Config.API_URL}/osv/by-file-id?file_id=${fileId}`,
|
||||
);
|
||||
const json = await data_res.json();
|
||||
setData(json);
|
||||
}
|
||||
fetchData();
|
||||
}, [fileId]);
|
||||
return (
|
||||
<>
|
||||
<h2>XLSX</h2> <div>{JSON.stringify(data)}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileViewer() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { file_id } = useParams();
|
||||
const files = useAppSelector((state) => state.files.currentOpenFiles);
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const entityId = searchParams.get("entity_id");
|
||||
const fileType = searchParams.get("file_type");
|
||||
const normFileType: "image" | "tabular" = useMemo(() => {
|
||||
const lowerExtension = (fileType || "").toLowerCase();
|
||||
if (["xlsx", "xls"].includes(lowerExtension)) {
|
||||
return "tabular";
|
||||
} else {
|
||||
return "image";
|
||||
}
|
||||
}, [fileType]);
|
||||
|
||||
useEffect(() => {
|
||||
async function getFilesIfNone() {
|
||||
if (files.length === 0 && entityId && file_id) {
|
||||
await dispatch(getFilesThunk(entityId));
|
||||
}
|
||||
}
|
||||
getFilesIfNone();
|
||||
}, [file_id, files.length, entityId]);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
<Select
|
||||
defaultValue={file_id}
|
||||
options={files.map((f) => ({
|
||||
value: f.id,
|
||||
label: f.original_name,
|
||||
...f,
|
||||
}))}
|
||||
optionRender={(f) => {
|
||||
return (
|
||||
<span>
|
||||
{f.data.original_name} -- {f.data.status}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
onChange={(e, option) => {
|
||||
navigate(`/files/${option.id}?file_type=${option.file_type}`);
|
||||
}}
|
||||
></Select>
|
||||
{normFileType === "image" ? (
|
||||
<PdfViewer fileId={file_id} />
|
||||
) : (
|
||||
<XLSXViewer fileId={file_id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/layouts/Groups.tsx
Normal file
41
src/layouts/Groups.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import { Typography } from "antd";
|
||||
import { CompanyGroupsTable } from "../widgets/CompanyGroupsTable/CompanyGroupsTable";
|
||||
import PageTitle from "../components/PageTitle";
|
||||
import { useAppSelector } from "../store/store";
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function GroupsLayout() {
|
||||
const totalGroupsNum = useAppSelector(
|
||||
(state) => state.company_groups.total_groups,
|
||||
);
|
||||
const desc = useMemo(() => {
|
||||
return `${totalGroupsNum} групп компаний`;
|
||||
}, [totalGroupsNum]);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100vh",
|
||||
}}
|
||||
>
|
||||
<PageTitle
|
||||
header="Группы компаний"
|
||||
location="Отчётность>Группы компаний"
|
||||
description={desc}
|
||||
/>
|
||||
<main
|
||||
style={{
|
||||
flex: "1 1 auto",
|
||||
minHeight: 0,
|
||||
overflow: "auto",
|
||||
backgroundColor: "white",
|
||||
borderRadius: 12,
|
||||
}}
|
||||
>
|
||||
<CompanyGroupsTable />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
src/layouts/Monozaemchiki.tsx
Normal file
7
src/layouts/Monozaemchiki.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
export function Mono() {
|
||||
return (
|
||||
<>
|
||||
<h3>Монозаёмщики</h3>
|
||||
</>
|
||||
);
|
||||
}
|
||||
10
src/layouts/index.ts
Normal file
10
src/layouts/index.ts
Normal file
@ -0,0 +1,10 @@
|
||||
export { Main } from "./Main";
|
||||
export { CatalogueJur } from "./CatalogueJur";
|
||||
export { ConsolidationPage } from "./ConsolidationsList";
|
||||
export { DocStorage } from "./DocStorage";
|
||||
export { Mono } from "./Monozaemchiki";
|
||||
export { Catalogue } from "./Catalogue";
|
||||
export { CatalogueGroup } from "./CatalogueGroups";
|
||||
export { GroupsLayout } from "./Groups";
|
||||
export { AuditLogging } from "./AuditLogging";
|
||||
export { Admin } from "./Admin";
|
||||
15
src/services/api/consolidations.ts
Normal file
15
src/services/api/consolidations.ts
Normal file
@ -0,0 +1,15 @@
|
||||
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`,
|
||||
// );
|
||||
|
||||
return MOCK_CONS.map((c) => ({ ...c, versions: [...c.versions] }));
|
||||
}
|
||||
|
||||
export async function getVersionData(version_id: string) {
|
||||
const res = versionCardsMock[version_id] ?? null;
|
||||
return res;
|
||||
}
|
||||
22
src/services/api/directories/company_groups.ts
Normal file
22
src/services/api/directories/company_groups.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import { api } from "../../../api/client";
|
||||
|
||||
export async function fetchCompanyGroups() {
|
||||
// const res = await fetch(`${Config.API_URL}/company-groups`);
|
||||
// const json = await res.json();
|
||||
const res = await api.get("/directories/company-groups");
|
||||
// const json = await res.json();
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function fetchOneCompanyGroup(com_group_id: string) {
|
||||
const res = await api.get(`/directories/company-groups/${com_group_id}`);
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function fetchGSKByComGroup(com_group_id: string) {
|
||||
const res = await api.get(
|
||||
`/directories/company-groups/${com_group_id}/related-groups`,
|
||||
);
|
||||
return res;
|
||||
}
|
||||
441
src/services/api/mocks.ts
Normal file
441
src/services/api/mocks.ts
Normal file
@ -0,0 +1,441 @@
|
||||
import { IConsolidation } from "../../types/consolidations/consolidations";
|
||||
import { IVersionCard } from "../../types/consolidations/version_members";
|
||||
|
||||
export const versionCardsMock: Record<string, IVersionCard> = {
|
||||
"ver-1001": {
|
||||
id: "ver-1001",
|
||||
version_no: 1,
|
||||
status: "INITIATED",
|
||||
scenario: "CREATE",
|
||||
approach: "BASIC",
|
||||
lock: null,
|
||||
created_at: "2026-01-15T09:30:00Z",
|
||||
members_count: 2,
|
||||
consolidation_id: "cons-5001",
|
||||
year: 2025,
|
||||
quarter: "Q4",
|
||||
reporting_date: "2025-12-31",
|
||||
company_group: {
|
||||
id: "grp-100",
|
||||
name: "Alpha Holding Group",
|
||||
},
|
||||
initiator: {
|
||||
id: "usr-200",
|
||||
name: "Ivan Petrov",
|
||||
},
|
||||
initiator_office: {
|
||||
id: "off-300",
|
||||
name: "Moscow Central Office",
|
||||
},
|
||||
goodwill_enabled: true,
|
||||
is_in_work: false,
|
||||
previous_version_id: null,
|
||||
history: [
|
||||
{
|
||||
from_status: null,
|
||||
to_status: "INITIATED",
|
||||
changed_by: "Ivan Petrov",
|
||||
changed_at: "2026-01-15T09:30:00Z",
|
||||
comment: "Version created",
|
||||
},
|
||||
],
|
||||
members: [
|
||||
{
|
||||
id: "mem-001",
|
||||
name: "Alpha Parent LLC",
|
||||
inn: "7701234567",
|
||||
is_head_entity: "true",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: ["doc-001", "doc-002"],
|
||||
status: "RECOGNIZED",
|
||||
missing_quarters: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mem-002",
|
||||
name: "Alpha Subsidiary One LLC",
|
||||
inn: "7707654321",
|
||||
is_head_entity: "false",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: [],
|
||||
status: "MISSING",
|
||||
missing_quarters: ["Q1", "Q2", "Q3", "Q4"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"ver-1000": {
|
||||
id: "ver-1000",
|
||||
version_no: 2,
|
||||
status: "DOCUMENTS_PROCESSING",
|
||||
scenario: "CREATE",
|
||||
approach: "EXTENDED",
|
||||
lock: {
|
||||
locked_by: "usr-201",
|
||||
locked_by_name: "Maria Sidorova",
|
||||
locked_at: "2026-02-10T14:12:00Z",
|
||||
taken_over_from: null,
|
||||
takeover_reason: null,
|
||||
},
|
||||
created_at: "2026-02-01T11:00:00Z",
|
||||
members_count: 3,
|
||||
consolidation_id: "cons-5002",
|
||||
year: 2026,
|
||||
quarter: "Q1",
|
||||
reporting_date: "2026-03-31",
|
||||
company_group: {
|
||||
id: "grp-101",
|
||||
name: "Beta Industrial Group",
|
||||
},
|
||||
initiator: {
|
||||
id: "usr-201",
|
||||
name: "Maria Sidorova",
|
||||
},
|
||||
initiator_office: {
|
||||
id: "off-301",
|
||||
name: "Saint Petersburg Office",
|
||||
},
|
||||
goodwill_enabled: true,
|
||||
is_in_work: true,
|
||||
previous_version_id: "ver-0001",
|
||||
history: [
|
||||
{
|
||||
from_status: null,
|
||||
to_status: "INITIATED",
|
||||
changed_by: "Maria Sidorova",
|
||||
changed_at: "2026-02-01T11:00:00Z",
|
||||
comment: "Version created from previous",
|
||||
},
|
||||
{
|
||||
from_status: "INITIATED",
|
||||
to_status: "DOCUMENTS_PROCESSING",
|
||||
changed_by: "Maria Sidorova",
|
||||
changed_at: "2026-02-10T14:12:00Z",
|
||||
comment: "Documents uploaded, processing started",
|
||||
},
|
||||
],
|
||||
members: [
|
||||
{
|
||||
id: "mem-010",
|
||||
name: "Beta Parent JSC",
|
||||
inn: "7801112233",
|
||||
is_head_entity: "true",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: ["doc-010", "doc-011", "doc-012"],
|
||||
status: "PROCESSING",
|
||||
missing_quarters: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mem-011",
|
||||
name: "Beta Logistics LLC",
|
||||
inn: "7802223344",
|
||||
is_head_entity: "false",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: ["doc-013"],
|
||||
status: "NEEDS_CHECK",
|
||||
missing_quarters: ["Q1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mem-012",
|
||||
name: "Beta Trading LLC",
|
||||
inn: "7803334455",
|
||||
is_head_entity: "false",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: ["doc-014"],
|
||||
status: "ERROR",
|
||||
missing_quarters: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"ver-2000": {
|
||||
id: "ver-2000",
|
||||
version_no: 5,
|
||||
status: "VERIFIED",
|
||||
scenario: "CONFIRM",
|
||||
approach: "EXTENDED",
|
||||
lock: {
|
||||
locked_by: "usr-205",
|
||||
locked_by_name: "Sergey Volkov",
|
||||
locked_at: "2026-03-05T08:45:00Z",
|
||||
taken_over_from: "usr-204",
|
||||
takeover_reason: "Original reviewer on leave",
|
||||
},
|
||||
created_at: "2026-02-20T10:15:00Z",
|
||||
members_count: 2,
|
||||
consolidation_id: "cons-5003",
|
||||
year: 2025,
|
||||
quarter: "Q3",
|
||||
reporting_date: "2025-09-30",
|
||||
company_group: {
|
||||
id: "grp-102",
|
||||
name: "Gamma Financial Group",
|
||||
},
|
||||
initiator: {
|
||||
id: "usr-204",
|
||||
name: "Elena Kuznetsova",
|
||||
},
|
||||
initiator_office: {
|
||||
id: "off-302",
|
||||
name: "Kazan Regional Office",
|
||||
},
|
||||
goodwill_enabled: true,
|
||||
is_in_work: true,
|
||||
previous_version_id: "ver-0999",
|
||||
history: [
|
||||
{
|
||||
from_status: null,
|
||||
to_status: "INITIATED",
|
||||
changed_by: "Elena Kuznetsova",
|
||||
changed_at: "2026-02-20T10:15:00Z",
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
from_status: "INITIATED",
|
||||
to_status: "DOCUMENTS_PROCESSING",
|
||||
changed_by: "Elena Kuznetsova",
|
||||
changed_at: "2026-02-25T12:00:00Z",
|
||||
comment: "Processing documents",
|
||||
},
|
||||
{
|
||||
from_status: "DOCUMENTS_PROCESSING",
|
||||
to_status: "VERIFIED",
|
||||
changed_by: "Sergey Volkov",
|
||||
changed_at: "2026-03-05T08:45:00Z",
|
||||
comment: "All documents confirmed",
|
||||
},
|
||||
],
|
||||
members: [
|
||||
{
|
||||
id: "mem-020",
|
||||
name: "Gamma Parent PJSC",
|
||||
inn: "1601112233",
|
||||
is_head_entity: "true",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: ["doc-020", "doc-021"],
|
||||
status: "CONFIRMED",
|
||||
missing_quarters: [],
|
||||
},
|
||||
{
|
||||
document_ids: ["doc-022"],
|
||||
status: "CONFIRMED",
|
||||
missing_quarters: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mem-021",
|
||||
name: "Gamma Capital LLC",
|
||||
inn: "1602223344",
|
||||
is_head_entity: "false",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: ["doc-023", "doc-024"],
|
||||
status: "CONFIRMED",
|
||||
missing_quarters: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
"ver-3002": {
|
||||
id: "ver-3002",
|
||||
version_no: 3,
|
||||
status: "REJECTED",
|
||||
scenario: "CONFIRM",
|
||||
approach: "BASIC",
|
||||
lock: null,
|
||||
created_at: "2026-01-28T16:20:00Z",
|
||||
members_count: 1,
|
||||
consolidation_id: "cons-5004",
|
||||
year: 2025,
|
||||
quarter: "Q2",
|
||||
reporting_date: "2025-06-30",
|
||||
company_group: {
|
||||
id: "grp-103",
|
||||
name: "Delta Services Group",
|
||||
},
|
||||
initiator: {
|
||||
id: "usr-210",
|
||||
name: "Dmitry Orlov",
|
||||
},
|
||||
initiator_office: {
|
||||
id: "off-303",
|
||||
name: "Novosibirsk Office",
|
||||
},
|
||||
goodwill_enabled: false,
|
||||
is_in_work: false,
|
||||
previous_version_id: "ver-2001",
|
||||
history: [
|
||||
{
|
||||
from_status: null,
|
||||
to_status: "INITIATED",
|
||||
changed_by: "Dmitry Orlov",
|
||||
changed_at: "2026-01-28T16:20:00Z",
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
from_status: "INITIATED",
|
||||
to_status: "DOCUMENTS_PROCESSING",
|
||||
changed_by: "Dmitry Orlov",
|
||||
changed_at: "2026-01-30T09:00:00Z",
|
||||
comment: null,
|
||||
},
|
||||
{
|
||||
from_status: "DOCUMENTS_PROCESSING",
|
||||
to_status: "REJECTED",
|
||||
changed_by: "Anna Fedorova",
|
||||
changed_at: "2026-02-03T17:40:00Z",
|
||||
comment: "Inconsistent figures in Q2 reporting",
|
||||
},
|
||||
],
|
||||
members: [
|
||||
{
|
||||
id: "mem-030",
|
||||
name: "Delta Parent LLC",
|
||||
inn: "5401112233",
|
||||
is_head_entity: "true",
|
||||
checklist: [
|
||||
{
|
||||
document_ids: ["doc-030"],
|
||||
status: "NEEDS_CHECK",
|
||||
missing_quarters: ["Q2"],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const MOCK_CONS: IConsolidation[] = [
|
||||
{
|
||||
id: "con-001",
|
||||
year: 2024,
|
||||
quarter: "Q1",
|
||||
reporting_date: "2024-03-31",
|
||||
versions: [
|
||||
{
|
||||
id: "ver-1001",
|
||||
version_no: 2,
|
||||
status: "VERIFIED",
|
||||
scenario: "CONFIRM",
|
||||
approach: "EXTENDED",
|
||||
lock: {
|
||||
locked_by: "u-42",
|
||||
locked_by_name: "Ada Lovelace",
|
||||
locked_at: "2024-04-02T10:15:00Z",
|
||||
taken_over_from: null,
|
||||
takeover_reason: null,
|
||||
},
|
||||
created_at: "2024-04-01T09:00:00Z",
|
||||
members_count: 12,
|
||||
},
|
||||
{
|
||||
id: "ver-1000",
|
||||
version_no: 1,
|
||||
status: "DOCUMENTS_PROCESSING",
|
||||
scenario: "CREATE",
|
||||
approach: "BASIC",
|
||||
lock: null,
|
||||
created_at: "2024-03-20T14:30:00Z",
|
||||
members_count: 8,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "con-002",
|
||||
year: 2024,
|
||||
quarter: "Q2",
|
||||
reporting_date: "2024-06-30",
|
||||
versions: [
|
||||
{
|
||||
id: "ver-2000",
|
||||
version_no: 1,
|
||||
status: "INITIATED",
|
||||
scenario: "CREATE",
|
||||
approach: "BASIC",
|
||||
lock: null,
|
||||
created_at: "2024-06-10T08:00:00Z",
|
||||
members_count: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "con-003",
|
||||
year: 2023,
|
||||
quarter: "Q4",
|
||||
reporting_date: "2023-12-31",
|
||||
versions: [
|
||||
{
|
||||
id: "ver-3002",
|
||||
version_no: 3,
|
||||
status: "ARCHIVED",
|
||||
scenario: "CONFIRM",
|
||||
approach: "EXTENDED",
|
||||
lock: null,
|
||||
created_at: "2024-01-15T11:00:00Z",
|
||||
members_count: 20,
|
||||
},
|
||||
{
|
||||
id: "ver-3001",
|
||||
version_no: 2,
|
||||
status: "REJECTED",
|
||||
scenario: "CONFIRM",
|
||||
approach: "EXTENDED",
|
||||
lock: {
|
||||
locked_by: "u-7",
|
||||
locked_by_name: "Grace Hopper",
|
||||
locked_at: "2023-12-28T16:45:00Z",
|
||||
taken_over_from: "u-42",
|
||||
takeover_reason: "Original owner unavailable",
|
||||
},
|
||||
created_at: "2023-12-20T10:00:00Z",
|
||||
members_count: 18,
|
||||
},
|
||||
{
|
||||
id: "ver-3000",
|
||||
version_no: 1,
|
||||
status: "CANCELLED",
|
||||
scenario: "CREATE",
|
||||
approach: "BASIC",
|
||||
lock: null,
|
||||
created_at: null,
|
||||
members_count: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "con-004",
|
||||
year: 2023,
|
||||
quarter: "Q3",
|
||||
reporting_date: "2023-09-30",
|
||||
versions: [
|
||||
{
|
||||
id: "ver-4000",
|
||||
version_no: 1,
|
||||
status: "VERIFIED",
|
||||
scenario: "CONFIRM",
|
||||
approach: "BASIC",
|
||||
lock: null,
|
||||
created_at: "2023-09-25T13:20:00Z",
|
||||
members_count: 9,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
36
src/store/slices/companyGroupsSlice/companyGroupsSlice.ts
Normal file
36
src/store/slices/companyGroupsSlice/companyGroupsSlice.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Config } from "../../../conf/config";
|
||||
import { CompanyGroupSliceType } from "./types";
|
||||
import { getCompanyGroupsThunk } from "./thunks";
|
||||
|
||||
const initialState: CompanyGroupSliceType = {
|
||||
raw_rows: [],
|
||||
total_groups: 0,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const comGroupsSlice = createSlice({
|
||||
name: "company_groups",
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(getCompanyGroupsThunk.pending, (state, _) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(getCompanyGroupsThunk.fulfilled, (state, action) => {
|
||||
state.raw_rows = action.payload.items;
|
||||
state.total_groups = action.payload.total;
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(getCompanyGroupsThunk.rejected, (state, action) => {
|
||||
state.error = action.payload ?? "Неизвестная ошибка";
|
||||
state.loading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default comGroupsSlice.reducer;
|
||||
15
src/store/slices/companyGroupsSlice/thunks.ts
Normal file
15
src/store/slices/companyGroupsSlice/thunks.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { fetchCompanyGroups } from "../../../services/api/directories/company_groups";
|
||||
|
||||
export const getCompanyGroupsThunk = createAsyncThunk(
|
||||
"company_groups/getAll",
|
||||
async (_, thunkApi) => {
|
||||
try {
|
||||
return await fetchCompanyGroups();
|
||||
} catch (e) {
|
||||
return thunkApi.rejectWithValue(
|
||||
e instanceof Error ? e.message : "Ошибка",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
8
src/store/slices/companyGroupsSlice/types.ts
Normal file
8
src/store/slices/companyGroupsSlice/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { ICompanyGroup } from "../../../types/companyGroups/companyGroups";
|
||||
|
||||
export interface CompanyGroupSliceType {
|
||||
total_groups: number;
|
||||
raw_rows: ICompanyGroup[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
45
src/store/slices/consolidations/consolidationsSlice.ts
Normal file
45
src/store/slices/consolidations/consolidationsSlice.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { createAsyncThunk, createSlice } from "@reduxjs/toolkit";
|
||||
import { useSelector } from "react-redux";
|
||||
import { Config } from "../../../conf/config";
|
||||
import { getConsolidationsListThunk } from "./thunks";
|
||||
import { ConsolidationsSliceType } from "./types";
|
||||
|
||||
const initialState: ConsolidationsSliceType = {
|
||||
consolidations: [],
|
||||
current_version_id: null,
|
||||
current_legal_entity_id: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const consolidationsSlice = createSlice({
|
||||
name: "consolidations",
|
||||
initialState,
|
||||
reducers: {
|
||||
setSelectedVersion: (state, action) => {
|
||||
state.current_version_id = action.payload;
|
||||
},
|
||||
setSelectedLegalEntity: (state, action) => {
|
||||
state.current_legal_entity_id = action.payload;
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(getConsolidationsListThunk.pending, (state, _) => {
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
})
|
||||
.addCase(getConsolidationsListThunk.fulfilled, (state, action) => {
|
||||
state.consolidations = action.payload;
|
||||
state.loading = false;
|
||||
})
|
||||
.addCase(getConsolidationsListThunk.rejected, (state, action) => {
|
||||
state.error = action.payload ?? "Неизвестная ошибка";
|
||||
state.loading = false;
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export default consolidationsSlice.reducer;
|
||||
export const { setSelectedVersion, setSelectedLegalEntity } =
|
||||
consolidationsSlice.actions;
|
||||
15
src/store/slices/consolidations/thunks.ts
Normal file
15
src/store/slices/consolidations/thunks.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { createAsyncThunk } from "@reduxjs/toolkit";
|
||||
import { getConsolidations } from "../../../services/api/consolidations";
|
||||
|
||||
export const getConsolidationsListThunk = createAsyncThunk(
|
||||
"consolidations/getByCompanyGroup",
|
||||
async (company_group_id: string, thunkApi) => {
|
||||
try {
|
||||
return await getConsolidations(company_group_id);
|
||||
} catch (e) {
|
||||
return thunkApi.rejectWithValue(
|
||||
e instanceof Error ? e.message : "Ошибка",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
9
src/store/slices/consolidations/types.ts
Normal file
9
src/store/slices/consolidations/types.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { IConsolidation } from "../../../types/consolidations/consolidations";
|
||||
|
||||
export type ConsolidationsSliceType = {
|
||||
consolidations: IConsolidation[];
|
||||
loading: boolean;
|
||||
current_version_id: string | null;
|
||||
error: string | null;
|
||||
current_legal_entity_id: string | null;
|
||||
};
|
||||
37
src/store/slices/fileSlice.ts
Normal file
37
src/store/slices/fileSlice.ts
Normal file
@ -0,0 +1,37 @@
|
||||
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;
|
||||
@ -1,9 +1,15 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import filesReducer from "./slices/fileSlice";
|
||||
import comGroupsReducer from "./slices/companyGroupsSlice/companyGroupsSlice";
|
||||
import consolidationsReducer from "./slices/consolidations/consolidationsSlice";
|
||||
import { useDispatch, useSelector } from "react-redux";
|
||||
|
||||
// Каркас: слайсы добавляются срезами в ./slices.
|
||||
export const store = configureStore({
|
||||
reducer: {},
|
||||
reducer: {
|
||||
files: filesReducer,
|
||||
company_groups: comGroupsReducer,
|
||||
consolidations: consolidationsReducer,
|
||||
},
|
||||
});
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
|
||||
7
src/types/companyGroups/companyGroups.ts
Normal file
7
src/types/companyGroups/companyGroups.ts
Normal file
@ -0,0 +1,7 @@
|
||||
export interface ICompanyGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
crm_id: string;
|
||||
base_regional_office_id: string;
|
||||
legal_entities_count: number;
|
||||
}
|
||||
40
src/types/consolidations/common_types.ts
Normal file
40
src/types/consolidations/common_types.ts
Normal file
@ -0,0 +1,40 @@
|
||||
export type ConsStatus =
|
||||
| "INITIATED"
|
||||
| "DOCUMENTS_PROCESSING"
|
||||
| "DATA_CORRECTION"
|
||||
| "CONSOLIDATION_RF"
|
||||
| "ADJUSTMENT"
|
||||
| "VERIFICATION_RF"
|
||||
| "CONSOLIDATION_GO"
|
||||
| "VERIFICATION_GO"
|
||||
| "VERIFIED"
|
||||
| "CANCELLED"
|
||||
| "REJECTED"
|
||||
| "ARCHIVED";
|
||||
|
||||
export type ConsRussianStatus =
|
||||
| "Верифицирован"
|
||||
| "Верификация ГО"
|
||||
| "Верификация РФ"
|
||||
| "Инициация"
|
||||
| "Обработка документов"
|
||||
| "Коррекция данных"
|
||||
| "Консолидация ГО"
|
||||
| "Консолидация РФ"
|
||||
| "Отменён"
|
||||
| "Корректировка"
|
||||
| "В архиве"
|
||||
| "Отклонён";
|
||||
export type Scenario = "CREATE" | "CONFIRM";
|
||||
|
||||
export type Approach = "BASIC" | "EXTENDED";
|
||||
|
||||
export type QuarterEnum = "Q1" | "Q2" | "Q3" | "Q4";
|
||||
|
||||
export interface LockOut {
|
||||
locked_by: string;
|
||||
locked_by_name: string;
|
||||
locked_at: string;
|
||||
taken_over_from: string | null;
|
||||
takeover_reason: string | null;
|
||||
}
|
||||
49
src/types/consolidations/consolidations.ts
Normal file
49
src/types/consolidations/consolidations.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import {
|
||||
Approach,
|
||||
ConsStatus,
|
||||
LockOut,
|
||||
QuarterEnum,
|
||||
Scenario,
|
||||
ConsRussianStatus,
|
||||
} from "./common_types";
|
||||
|
||||
interface IConsolidationVersion {
|
||||
id: string;
|
||||
version_no: number;
|
||||
status: ConsStatus;
|
||||
scenario: Scenario;
|
||||
approach: Approach;
|
||||
lock: LockOut | null;
|
||||
created_at: string | null;
|
||||
members_count: number;
|
||||
}
|
||||
|
||||
export interface IConsolidation {
|
||||
id: string;
|
||||
year: number;
|
||||
quarter: QuarterEnum;
|
||||
reporting_date: string;
|
||||
versions: IConsolidationVersion[];
|
||||
}
|
||||
|
||||
export interface ConsRow {
|
||||
id: string;
|
||||
|
||||
group_id: string;
|
||||
isPrimary: boolean;
|
||||
versionIndex: number;
|
||||
groupSize: number;
|
||||
|
||||
con_id: string;
|
||||
year: number;
|
||||
quarter: QuarterEnum;
|
||||
reporting_date: Date | null;
|
||||
|
||||
version_no: number;
|
||||
status: ConsRussianStatus;
|
||||
scenario: Scenario;
|
||||
approach: Approach;
|
||||
lock: LockOut | null;
|
||||
created_at: string | null;
|
||||
members_count: number;
|
||||
}
|
||||
81
src/types/consolidations/version_members.ts
Normal file
81
src/types/consolidations/version_members.ts
Normal file
@ -0,0 +1,81 @@
|
||||
import {
|
||||
Approach,
|
||||
ConsStatus,
|
||||
LockOut,
|
||||
QuarterEnum,
|
||||
Scenario,
|
||||
ConsRussianStatus,
|
||||
} from "./common_types";
|
||||
|
||||
type CompanyGroup = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
type Initiator = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
type InitiatorOffice = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type VersionHistory = {
|
||||
from_status: string | null;
|
||||
to_status: string;
|
||||
changed_by: string;
|
||||
changed_at: string;
|
||||
comment: string | null;
|
||||
};
|
||||
|
||||
type FileStatus =
|
||||
| "CONFIRMED"
|
||||
| "NEEDS_CHECK"
|
||||
| "RECOGNIZED"
|
||||
| "PROCESSING"
|
||||
| "ERROR"
|
||||
| "MISSING";
|
||||
|
||||
type ChecklistItem = {
|
||||
document_ids: string[];
|
||||
status: FileStatus;
|
||||
missing_quarters: QuarterEnum[];
|
||||
};
|
||||
|
||||
type VersionMember = {
|
||||
id: string;
|
||||
name: string;
|
||||
inn: string;
|
||||
is_head_entity: string;
|
||||
checklist: ChecklistItem[];
|
||||
};
|
||||
|
||||
export interface IVersionCard {
|
||||
id: string;
|
||||
version_no: number;
|
||||
status: ConsStatus;
|
||||
scenario: Scenario;
|
||||
approach: Approach;
|
||||
lock: LockOut | null;
|
||||
created_at: string | null;
|
||||
members_count: number;
|
||||
consolidation_id: string;
|
||||
year: number;
|
||||
quarter: QuarterEnum;
|
||||
reporting_date: string;
|
||||
company_group: CompanyGroup;
|
||||
initiator: Initiator;
|
||||
initiator_office: InitiatorOffice;
|
||||
goodwill_enabled: boolean;
|
||||
is_in_work: boolean;
|
||||
previous_version_id: string | null;
|
||||
history: VersionHistory[];
|
||||
members: VersionMember[];
|
||||
}
|
||||
|
||||
export type VersionCardRow = {
|
||||
id: string;
|
||||
inn: string;
|
||||
name: string;
|
||||
version_id: string;
|
||||
};
|
||||
125
src/widgets/CompanyGroupsTable/CompanyGroupsTable.tsx
Normal file
125
src/widgets/CompanyGroupsTable/CompanyGroupsTable.tsx
Normal file
@ -0,0 +1,125 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Input, Space, Tooltip, Typography } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import {
|
||||
DataTable,
|
||||
ColumnSettings,
|
||||
useVisibleColumns,
|
||||
useFilteredRows,
|
||||
type ColumnFilters,
|
||||
SortState,
|
||||
} from "../../components/GenericTable";
|
||||
import { groupsConfig } from "./config";
|
||||
import { ArrowRightOutlined, SearchOutlined } from "@ant-design/icons";
|
||||
import { useAppDispatch, useAppSelector } from "../../store/store";
|
||||
import { ICompanyGroup } from "../../types/companyGroups/companyGroups";
|
||||
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
|
||||
import { useTableFilters } from "../../components/GenericTable/useTableFilters";
|
||||
|
||||
export function CompanyGroupsTable() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const rawRows = useAppSelector((state) => state.company_groups.raw_rows);
|
||||
const totalGroupsNum = useAppSelector(
|
||||
(state) => state.company_groups.total_groups,
|
||||
);
|
||||
const loading = useAppSelector((state) => state.company_groups.loading);
|
||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns(groupsConfig);
|
||||
const {
|
||||
rawQuery,
|
||||
setRawQuery,
|
||||
columnFilters,
|
||||
setColumnFilters,
|
||||
sort,
|
||||
setSort,
|
||||
resetAll,
|
||||
hasActiveFilters,
|
||||
} = useTableFilters<ICompanyGroup>();
|
||||
// single filtered array — title count and table share it
|
||||
const { rows, totalCount, displayedCount } = useFilteredRows(
|
||||
rawRows,
|
||||
groupsConfig,
|
||||
rawQuery,
|
||||
columnFilters,
|
||||
sort,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getCompanyGroupsThunk());
|
||||
}, []);
|
||||
|
||||
// static, always-visible actions column navigating to /ICompanyGroups/{id}
|
||||
const extraColumns: ColumnsType<ICompanyGroup> = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "actions",
|
||||
title: "",
|
||||
width: 120,
|
||||
render: (_: unknown, row: ICompanyGroup) => (
|
||||
<Tooltip placement="right" title={"Консолидации"}>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => navigate(`/groups/${row.id}`)}
|
||||
>
|
||||
<ArrowRightOutlined />
|
||||
Консолидации
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
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 }}
|
||||
/>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
resetAll();
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
)}
|
||||
<ColumnSettings
|
||||
defs={groupsConfig.columns}
|
||||
visibleKeys={visibleKeys}
|
||||
onChange={setVisibleKeys}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<DataTable
|
||||
sortState={sort}
|
||||
onSortChange={setSort}
|
||||
config={groupsConfig}
|
||||
rows={rows}
|
||||
allRows={rawRows}
|
||||
loading={loading}
|
||||
visibleKeys={visibleKeys}
|
||||
globalQuery={rawQuery}
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={setColumnFilters}
|
||||
extraColumns={extraColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/widgets/CompanyGroupsTable/config.tsx
Normal file
30
src/widgets/CompanyGroupsTable/config.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import { Tag } from "antd";
|
||||
import type { TableConfig } from "../../components/GenericTable";
|
||||
import { ICompanyGroup } from "../../types/companyGroups/companyGroups";
|
||||
|
||||
export const groupsConfig: TableConfig<ICompanyGroup> = {
|
||||
rowKey: "id",
|
||||
searchableKeys: ["name", "crm_id", "base_regional_office_id"],
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
title: "Группа компаний",
|
||||
features: { search: true, multiselect: true, sort: true },
|
||||
},
|
||||
{
|
||||
key: "crm_id",
|
||||
title: "CRM ID",
|
||||
features: { search: true, multiselect: true },
|
||||
},
|
||||
{
|
||||
key: "base_regional_office_id",
|
||||
title: "Наименование РФ",
|
||||
features: { search: true, multiselect: true, sort: true },
|
||||
},
|
||||
{
|
||||
key: "legal_entities_count",
|
||||
title: "Организаций",
|
||||
features: { multiselect: true },
|
||||
},
|
||||
],
|
||||
};
|
||||
142
src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx
Normal file
142
src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx
Normal file
@ -0,0 +1,142 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Space } from "antd";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ColumnsType, TableProps } from "antd/es/table";
|
||||
import {
|
||||
DataTable,
|
||||
ColumnSettings,
|
||||
useVisibleColumns,
|
||||
useFilteredRows,
|
||||
type ColumnFilters,
|
||||
type SortState,
|
||||
type TableConfig,
|
||||
} from "../../components/GenericTable";
|
||||
import { useAppDispatch, useAppSelector } from "../../store/store";
|
||||
import { useTableFilters } from "../../components/GenericTable/useTableFilters";
|
||||
import {
|
||||
setSelectedLegalEntity,
|
||||
setSelectedVersion,
|
||||
} from "../../store/slices/consolidations/consolidationsSlice";
|
||||
import { getVersionData } from "../../services/api/consolidations";
|
||||
import {
|
||||
IVersionCard,
|
||||
VersionCardRow,
|
||||
} from "../../types/consolidations/version_members";
|
||||
import { membersConfig } from "./config";
|
||||
import { normalizeData } from "./normalize";
|
||||
|
||||
export function ConsoleLegalEntitiesTable({ onDrawerOpen }) {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const consolidation_id = useAppSelector(
|
||||
(s) => s.consolidations.current_version_id,
|
||||
);
|
||||
|
||||
const [consolidationData, setConsolidationData] =
|
||||
useState<IVersionCard | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function getLegalEntities() {
|
||||
if (!consolidation_id) return;
|
||||
setLoading(true);
|
||||
const res = await getVersionData(consolidation_id);
|
||||
setConsolidationData(res);
|
||||
setLoading(false);
|
||||
}
|
||||
getLegalEntities();
|
||||
}, [consolidation_id]);
|
||||
const rawRows = useMemo(
|
||||
() => normalizeData(consolidationData),
|
||||
[consolidationData],
|
||||
);
|
||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns(membersConfig);
|
||||
const {
|
||||
rawQuery,
|
||||
setRawQuery,
|
||||
columnFilters,
|
||||
setColumnFilters,
|
||||
sort,
|
||||
setSort,
|
||||
resetAll,
|
||||
hasActiveFilters,
|
||||
} = useTableFilters<VersionCardRow>();
|
||||
// single filtered array — title count and table share it
|
||||
const { rows: filtered } = useFilteredRows(
|
||||
rawRows,
|
||||
membersConfig,
|
||||
rawQuery,
|
||||
columnFilters,
|
||||
sort,
|
||||
);
|
||||
const onRow = (row: VersionCardRow) => ({
|
||||
onClick: () => {
|
||||
dispatch(setSelectedVersion(row.version_id));
|
||||
dispatch(setSelectedLegalEntity(row.id));
|
||||
onDrawerOpen();
|
||||
},
|
||||
});
|
||||
|
||||
// static, always-visible actions column navigating to /ICompanyGroups/{id}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Space
|
||||
style={{
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
flex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "baseline", gap: 8, margin: 0 }}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
display: "block",
|
||||
boxSizing: "border-box",
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Организации
|
||||
</h2>
|
||||
<span style={{ fontSize: 13, color: "rgb(102, 112, 133)" }}>
|
||||
{rawRows.length} шт.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
resetAll();
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
)}
|
||||
<ColumnSettings
|
||||
defs={membersConfig.columns}
|
||||
visibleKeys={visibleKeys}
|
||||
onChange={setVisibleKeys}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<DataTable
|
||||
config={membersConfig}
|
||||
rows={filtered}
|
||||
allRows={rawRows}
|
||||
loading={loading}
|
||||
visibleKeys={visibleKeys}
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={setColumnFilters}
|
||||
sortState={sort}
|
||||
onSortChange={setSort}
|
||||
onRow={onRow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
src/widgets/ConsoleLegalEntities/config.tsx
Normal file
32
src/widgets/ConsoleLegalEntities/config.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import { Button, Tag } from "antd";
|
||||
import type { TableConfig } from "../../components/GenericTable";
|
||||
import {
|
||||
IVersionCard,
|
||||
VersionCardRow,
|
||||
} from "../../types/consolidations/version_members";
|
||||
import { FileOutlined, FileTextOutlined } from "@ant-design/icons";
|
||||
|
||||
export const membersConfig: TableConfig<
|
||||
VersionCardRow & { docs: "Документы" }
|
||||
> = {
|
||||
rowKey: "id",
|
||||
columns: [
|
||||
{
|
||||
key: "name",
|
||||
title: "Наименование",
|
||||
features: { search: true, multiselect: true },
|
||||
},
|
||||
{
|
||||
key: "docs",
|
||||
title: "Документы",
|
||||
features: {},
|
||||
render: (v) => (
|
||||
<Button type="default">
|
||||
<FileTextOutlined />
|
||||
Документы
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{ key: "inn", title: "ИНН", features: { search: true, multiselect: true } },
|
||||
],
|
||||
};
|
||||
22
src/widgets/ConsoleLegalEntities/normalize.ts
Normal file
22
src/widgets/ConsoleLegalEntities/normalize.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import {
|
||||
IVersionCard,
|
||||
VersionCardRow,
|
||||
} from "../../types/consolidations/version_members";
|
||||
|
||||
export function normalizeData(
|
||||
cons_data: IVersionCard | null,
|
||||
): VersionCardRow[] {
|
||||
let result: VersionCardRow[] = [];
|
||||
if (cons_data === null) {
|
||||
return result;
|
||||
}
|
||||
for (const m of cons_data.members) {
|
||||
result.push({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
inn: m.inn,
|
||||
version_id: cons_data.id,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
250
src/widgets/ConsoleTable/ConsoleTable.tsx
Normal file
250
src/widgets/ConsoleTable/ConsoleTable.tsx
Normal file
@ -0,0 +1,250 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { Button, Dropdown, Row, Space } from "antd";
|
||||
import {
|
||||
RightOutlined,
|
||||
DownOutlined,
|
||||
MoreOutlined,
|
||||
IdcardOutlined,
|
||||
ArrowRightOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import type { ColumnsType, TableProps } from "antd/es/table";
|
||||
import {
|
||||
DataTable,
|
||||
ColumnSettings,
|
||||
useVisibleColumns,
|
||||
useFilteredRows,
|
||||
type ColumnFilters,
|
||||
type SortState,
|
||||
type TableConfig,
|
||||
} from "../../components/GenericTable";
|
||||
import { useAppDispatch, useAppSelector } from "../../store/store";
|
||||
import { flattenCons } from "./flatten";
|
||||
import { useGrouping } from "./useGrouping";
|
||||
import { groupAdjacent } from "./groupAdjacent";
|
||||
import { consConfigBase } from "./config";
|
||||
import { useTableFilters } from "../../components/GenericTable/useTableFilters";
|
||||
import { ConsRow } from "../../types/consolidations/consolidations";
|
||||
import { setSelectedVersion } from "../../store/slices/consolidations/consolidationsSlice";
|
||||
|
||||
type ActionKey = "copy" | "disable_confirm" | "history";
|
||||
const ITEMS = [
|
||||
{ key: "copy", label: "Добавить версию" },
|
||||
{ key: "disable_confirm", label: "Отключить подвтерждение КФО" },
|
||||
{ key: "history", label: "История изменений" },
|
||||
{},
|
||||
] as const;
|
||||
|
||||
const RowActions = memo(function RowActions({
|
||||
row,
|
||||
onAction,
|
||||
}: {
|
||||
row: ConsRow;
|
||||
onAction: (key: ActionKey, row: ConsRow) => void;
|
||||
}) {
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
items: ITEMS as any,
|
||||
onClick: ({ key, domEvent }) => {
|
||||
domEvent.stopPropagation();
|
||||
onAction(key as ActionKey, row);
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button icon={<MoreOutlined />} onClick={(e) => e.stopPropagation()} />
|
||||
</Dropdown>
|
||||
);
|
||||
});
|
||||
export function ConsoleTableWidget() {
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const consolidations = useAppSelector((s) => s.consolidations.consolidations);
|
||||
const loading = useAppSelector((s) => s.consolidations.loading);
|
||||
|
||||
const {
|
||||
columnFilters,
|
||||
setColumnFilters,
|
||||
sort,
|
||||
setSort,
|
||||
resetAll,
|
||||
hasActiveFilters,
|
||||
} = useTableFilters<ConsRow>();
|
||||
const rawRows = useMemo(() => flattenCons(consolidations), [consolidations]);
|
||||
|
||||
// const [sort, setSort] = useState<SortState<ConsRow>>(null);
|
||||
|
||||
// no globalQuery state at all
|
||||
const { rows: filtered } = useFilteredRows(
|
||||
rawRows,
|
||||
consConfigBase,
|
||||
"",
|
||||
columnFilters,
|
||||
sort,
|
||||
);
|
||||
const grouped = useMemo(() => groupAdjacent(filtered), [filtered]);
|
||||
const { visibleRows, expanded, toggle } = useGrouping(grouped);
|
||||
|
||||
const config: TableConfig<ConsRow> = useMemo(
|
||||
() => ({
|
||||
...consConfigBase,
|
||||
columns: consConfigBase.columns.map((col) =>
|
||||
col.key !== "year"
|
||||
? col
|
||||
: {
|
||||
...col,
|
||||
render: (value, row: ConsRow) => {
|
||||
if (row.isPrimary && row.groupSize > 1) {
|
||||
const open = expanded.has(row.group_id);
|
||||
return (
|
||||
<Space size={4}>
|
||||
<span
|
||||
role="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggle(row.group_id);
|
||||
}}
|
||||
style={{ cursor: "pointer", display: "inline-flex" }}
|
||||
>
|
||||
{open ? <DownOutlined /> : <RightOutlined />}
|
||||
</span>
|
||||
<span>{String(value)}</span>
|
||||
<span style={{ color: "#999", fontSize: 12 }}>
|
||||
({row.groupSize})
|
||||
</span>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
if (!row.isPrimary) {
|
||||
return (
|
||||
<span style={{ paddingLeft: 24, color: "#666" }}>
|
||||
<span style={{ marginRight: 6, color: "#bbb" }}>↳</span>
|
||||
{String(value)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <span style={{ paddingLeft: 22 }}>{String(value)}</span>;
|
||||
},
|
||||
},
|
||||
),
|
||||
}),
|
||||
[expanded, toggle],
|
||||
);
|
||||
|
||||
const { visibleKeys, setVisibleKeys } = useVisibleColumns(config);
|
||||
|
||||
const handleDropdownAction = useCallback(
|
||||
(key: ActionKey, row: ConsRow) => {
|
||||
if (key === "history") {
|
||||
navigate("/logging");
|
||||
} else {
|
||||
alert("Не реализовано");
|
||||
}
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
const extraColumns: ColumnsType<ConsRow> = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "actions",
|
||||
title: "",
|
||||
width: 120,
|
||||
render: (_: unknown, row: ConsRow) => (
|
||||
<div style={{ display: "flex" }}>
|
||||
<Button
|
||||
type="default"
|
||||
icon={<IdcardOutlined />}
|
||||
onClick={() => alert("Не реализован")}
|
||||
>
|
||||
Взять в работу
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() =>
|
||||
navigate(`/cons/${row.group_id}/versions/${row.id}`)
|
||||
}
|
||||
>
|
||||
К отчетности <ArrowRightOutlined />
|
||||
</Button>
|
||||
<RowActions row={row} onAction={handleDropdownAction} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const onRow: TableProps<ConsRow>["onRow"] = (row) => ({
|
||||
onClick: () => dispatch(setSelectedVersion(row.id)),
|
||||
});
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
|
||||
<Space
|
||||
style={{
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
padding: "12px 16px",
|
||||
flex: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{ display: "flex", alignItems: "baseline", gap: 8, margin: 0 }}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
display: "block",
|
||||
boxSizing: "border-box",
|
||||
fontSize: 17,
|
||||
fontWeight: 600,
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Консолидации
|
||||
</h2>
|
||||
<span style={{ fontSize: 13, color: "rgb(102, 112, 133)" }}>
|
||||
{rawRows.length} шт.
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
{hasActiveFilters && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
resetAll();
|
||||
}}
|
||||
>
|
||||
Сбросить фильтры
|
||||
</Button>
|
||||
)}
|
||||
<ColumnSettings
|
||||
defs={config.columns}
|
||||
visibleKeys={visibleKeys}
|
||||
onChange={setVisibleKeys}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
<DataTable
|
||||
config={config}
|
||||
rows={visibleRows}
|
||||
allRows={rawRows}
|
||||
loading={loading}
|
||||
visibleKeys={visibleKeys}
|
||||
columnFilters={columnFilters}
|
||||
onColumnFiltersChange={setColumnFilters}
|
||||
sortState={sort}
|
||||
onSortChange={setSort}
|
||||
extraColumns={extraColumns}
|
||||
onRow={onRow}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/widgets/ConsoleTable/config.tsx
Normal file
45
src/widgets/ConsoleTable/config.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
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",
|
||||
};
|
||||
|
||||
export const consConfigBase: TableConfig<ConsRow> = {
|
||||
rowKey: "id",
|
||||
// no searchableKeys — table has no cross-column search
|
||||
columns: [
|
||||
{ key: "year", title: "Год", features: { sort: true, multiselect: true } },
|
||||
{ key: "quarter", title: "Квартал", features: { multiselect: true } },
|
||||
|
||||
{ key: "version_no", title: "Версия", features: { sort: true } },
|
||||
{
|
||||
key: "status",
|
||||
title: "Статус",
|
||||
features: { multiselect: true, sort: true },
|
||||
render: (v) => (
|
||||
<Tag color={statusColorMapping[v]} variant="outlined">
|
||||
{v}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reporting_date",
|
||||
title: "Дата верификации",
|
||||
features: {},
|
||||
render: (v) => {
|
||||
v = v as Date | null;
|
||||
if (v === null) return "";
|
||||
else return v.toLocaleDateString("ru");
|
||||
},
|
||||
},
|
||||
{ key: "members_count", title: "Организаций", features: {} },
|
||||
],
|
||||
};
|
||||
34
src/widgets/ConsoleTable/consts.ts
Normal file
34
src/widgets/ConsoleTable/consts.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import {
|
||||
ConsRussianStatus,
|
||||
ConsStatus,
|
||||
} from "../../types/consolidations/common_types";
|
||||
|
||||
export const statusMapping: Record<ConsStatus, ConsRussianStatus> = {
|
||||
VERIFIED: "Верифицирован",
|
||||
VERIFICATION_GO: "Верификация ГО",
|
||||
VERIFICATION_RF: "Верификация РФ",
|
||||
INITIATED: "Инициация",
|
||||
DOCUMENTS_PROCESSING: "Обработка документов",
|
||||
DATA_CORRECTION: "Коррекция данных",
|
||||
CONSOLIDATION_GO: "Консолидация ГО",
|
||||
CONSOLIDATION_RF: "Консолидация РФ",
|
||||
CANCELLED: "Отменён",
|
||||
ADJUSTMENT: "Корректировка",
|
||||
ARCHIVED: "В архиве",
|
||||
REJECTED: "Отклонён",
|
||||
};
|
||||
|
||||
export const statusColorMapping: Record<ConsRussianStatus, string> = {
|
||||
"В архиве": "default",
|
||||
"Верификация ГО": "geekblue",
|
||||
"Верификация РФ": "geekblue",
|
||||
Верифицирован: "green",
|
||||
Инициация: "default",
|
||||
"Консолидация ГО": "purple",
|
||||
"Консолидация РФ": "purple",
|
||||
Корректировка: "gold",
|
||||
"Коррекция данных": "gold",
|
||||
"Обработка документов": "gold",
|
||||
Отклонён: "red",
|
||||
Отменён: "red",
|
||||
};
|
||||
38
src/widgets/ConsoleTable/flatten.ts
Normal file
38
src/widgets/ConsoleTable/flatten.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {
|
||||
ConsRussianStatus,
|
||||
ConsStatus,
|
||||
} from "../../types/consolidations/common_types";
|
||||
import {
|
||||
ConsRow,
|
||||
IConsolidation,
|
||||
} from "../../types/consolidations/consolidations";
|
||||
import { statusMapping } from "./consts";
|
||||
|
||||
export function flattenCons(cons: IConsolidation[]): ConsRow[] {
|
||||
const out: ConsRow[] = [];
|
||||
for (const con of cons) {
|
||||
// if a con has no versions, emit nothing — or emit a placeholder row if you want
|
||||
con.versions.forEach((v, i) => {
|
||||
let rep_date = new Date(con.reporting_date) ?? null;
|
||||
out.push({
|
||||
id: v.id,
|
||||
group_id: con.id,
|
||||
isPrimary: i === 0,
|
||||
versionIndex: i,
|
||||
groupSize: con.versions.length,
|
||||
con_id: con.id,
|
||||
year: con.year,
|
||||
quarter: con.quarter,
|
||||
reporting_date: rep_date,
|
||||
version_no: v.version_no,
|
||||
status: statusMapping[v.status],
|
||||
scenario: v.scenario,
|
||||
approach: v.approach,
|
||||
lock: v.lock,
|
||||
created_at: v.created_at,
|
||||
members_count: v.members_count,
|
||||
});
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
19
src/widgets/ConsoleTable/groupAdjacent.ts
Normal file
19
src/widgets/ConsoleTable/groupAdjacent.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import { ConsRow } from "../../types/consolidations/consolidations";
|
||||
export function groupAdjacent(sorted: ConsRow[]): ConsRow[] {
|
||||
const order: string[] = [];
|
||||
const byGroup = new Map<string, ConsRow[]>();
|
||||
for (const r of sorted) {
|
||||
if (!byGroup.has(r.group_id)) {
|
||||
byGroup.set(r.group_id, []);
|
||||
order.push(r.group_id);
|
||||
}
|
||||
byGroup.get(r.group_id)!.push(r);
|
||||
}
|
||||
const out: ConsRow[] = [];
|
||||
for (const k of order) {
|
||||
const g = byGroup.get(k)!;
|
||||
g.sort((a, b) => a.versionIndex - b.versionIndex);
|
||||
out.push(...g);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
17
src/widgets/ConsoleTable/useGrouping.ts
Normal file
17
src/widgets/ConsoleTable/useGrouping.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { ConsRow } from "../../types/consolidations/consolidations";
|
||||
|
||||
export function useGrouping(rows: ConsRow[]) {
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||
const toggle = (group_id: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(group_id) ? next.delete(group_id) : next.add(group_id);
|
||||
return next;
|
||||
});
|
||||
const visibleRows = useMemo(
|
||||
() => rows.filter((r) => r.isPrimary || expanded.has(r.group_id)),
|
||||
[rows, expanded],
|
||||
);
|
||||
return { visibleRows, expanded, toggle };
|
||||
}
|
||||
187
src/widgets/NewConsoleModal/NewConsoleModal.tsx
Normal file
187
src/widgets/NewConsoleModal/NewConsoleModal.tsx
Normal file
@ -0,0 +1,187 @@
|
||||
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 { useAppDispatch, useAppSelector } from "../../store/store";
|
||||
import { getCompanyGroupsThunk } from "../../store/slices/companyGroupsSlice/thunks";
|
||||
|
||||
type FormValues = {
|
||||
groupId: string;
|
||||
subGroupId: string;
|
||||
year: number;
|
||||
quarter: string; // example of 2nd static field
|
||||
};
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
onOk: (values: FormValues) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { raw_rows, loading } = useAppSelector((state) => state.company_groups);
|
||||
|
||||
// Subgroups are local — only this modal needs them
|
||||
const [subgroups, setSubgroups] = useState([]);
|
||||
const [loadingSubgroups, setLoadingSubgroups] = useState(false);
|
||||
// Guards against out-of-order responses when group is changed quickly
|
||||
const requestIdRef = useRef(0);
|
||||
|
||||
const groupId = Form.useWatch("groupId", form);
|
||||
const subGroupId = Form.useWatch("subGroupId", 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 quarterOptions = [
|
||||
{ value: "Q1", label: "1 квартал" },
|
||||
{ value: "Q2", label: "2 квартал" },
|
||||
{ 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 handleGroupChange = async (value?: string) => {
|
||||
form.setFieldsValue({ subGroupId: undefined as any });
|
||||
setSubgroups([]);
|
||||
|
||||
if (!value) return;
|
||||
|
||||
const reqId = ++requestIdRef.current;
|
||||
setLoadingSubgroups(true);
|
||||
try {
|
||||
const data = await fetchGSKByComGroup(value);
|
||||
// Ignore stale responses
|
||||
if (reqId === requestIdRef.current) setSubgroups(data);
|
||||
} finally {
|
||||
if (reqId === requestIdRef.current) setLoadingSubgroups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetLocal = () => {
|
||||
setSubgroups([]);
|
||||
setLoadingSubgroups(false);
|
||||
requestIdRef.current++; // invalidate any in-flight request
|
||||
};
|
||||
|
||||
const handleOk = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await onOk(values);
|
||||
form.resetFields();
|
||||
resetLocal();
|
||||
} catch {
|
||||
// validation error - keep modal open
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
form.resetFields();
|
||||
resetLocal();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Новая консолидация"
|
||||
open={open}
|
||||
onCancel={handleCancel}
|
||||
onOk={handleOk}
|
||||
okButtonProps={{ disabled: isSubmitDisabled }}
|
||||
okText="+ Создать"
|
||||
cancelText="Отмена"
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" preserve={false}>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="groupId"
|
||||
label="ГК"
|
||||
rules={[{ required: true, message: "Укажите группу" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Выберите группу"
|
||||
loading={loading}
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
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="ГСК/ГСЗ"
|
||||
rules={[{ required: true, message: "Укажите ГСК" }]}
|
||||
>
|
||||
<Select
|
||||
placeholder={groupId ? "Выберите ГСК" : "Сначала выберите ГК"}
|
||||
disabled={!groupId}
|
||||
loading={loadingSubgroups}
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={(input, option) =>
|
||||
(option?.label as string)
|
||||
?.toLowerCase()
|
||||
.includes(input.toLowerCase())
|
||||
}
|
||||
options={subgroups.map((s) => ({ value: s.id, label: s.name }))}
|
||||
notFoundContent={
|
||||
loadingSubgroups ? "Загрузка" : "Нет вариантов"
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="year"
|
||||
label="Год"
|
||||
rules={[{ required: true, message: "Укажите год" }]}
|
||||
>
|
||||
<Select placeholder="Выберите год" options={yearOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="quarter"
|
||||
label="Квартал"
|
||||
rules={[{ required: true, message: "Укажите квартал" }]}
|
||||
>
|
||||
<Select placeholder="Выберите квартал" options={quarterOptions} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
224
src/widgets/PDFResultViewer/IFrameBlock.tsx
Normal file
224
src/widgets/PDFResultViewer/IFrameBlock.tsx
Normal file
@ -0,0 +1,224 @@
|
||||
import styles from "./styles.module.css";
|
||||
import * as pdfjsLib from "pdfjs-dist";
|
||||
import workerSrc from "pdfjs-dist/legacy/build/pdf.mjs?url";
|
||||
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
"pdfjs-dist/legacy/build/pdf.worker.min.mjs?url",
|
||||
import.meta.url,
|
||||
).toString();
|
||||
import { Config } from "../../conf/config";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import type { PDFDocumentProxy } from "pdfjs-dist";
|
||||
const MIN_ZOOM = 0.5;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_STEP = 0.25;
|
||||
const PAGE_MARGIN = 10; // left + right margin of each page wrapper
|
||||
|
||||
export default function PdfJsBlock({ fileId }: { fileId: string }) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]);
|
||||
const [pdfDoc, setPdfDoc] = useState<PDFDocumentProxy | null>(null);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [zoom, setZoom] = useState(1); // 1 = fit wrapper width
|
||||
const [baseScale, setBaseScale] = useState(1);
|
||||
const [pageWidthPt, setPageWidthPt] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let pdf: PDFDocumentProxy | null = null;
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
const res = await fetch(`${Config.API_URL}/files/${fileId}`);
|
||||
const buffer = await res.arrayBuffer();
|
||||
pdf = await pdfjsLib.getDocument({ data: buffer }).promise;
|
||||
if (cancelled) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
}
|
||||
const firstPage = await pdf.getPage(1);
|
||||
setPageWidthPt(firstPage.getViewport({ scale: 1 }).width);
|
||||
canvasRefs.current = new Array(pdf.numPages).fill(null);
|
||||
setPdfDoc(pdf);
|
||||
setNumPages(pdf.numPages);
|
||||
}
|
||||
load();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (pdf) pdf.destroy();
|
||||
};
|
||||
}, [fileId]);
|
||||
|
||||
// fit scale = wrapper inner width / page width.
|
||||
// wrapper width = container client width - container padding - page margins
|
||||
const updateBaseScale = useCallback(() => {
|
||||
if (!containerRef.current || !pageWidthPt) return;
|
||||
const CONTAINER_PADDING = 0; // keep 0 horizontally so margins control it
|
||||
const available =
|
||||
containerRef.current.clientWidth - CONTAINER_PADDING - PAGE_MARGIN * 2;
|
||||
if (available <= 0) return;
|
||||
setBaseScale(available / pageWidthPt);
|
||||
}, [pageWidthPt]);
|
||||
|
||||
useEffect(() => {
|
||||
updateBaseScale();
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(updateBaseScale);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [updateBaseScale]);
|
||||
|
||||
const scale = baseScale * zoom;
|
||||
|
||||
useEffect(() => {
|
||||
if (!pdfDoc || !scale) return;
|
||||
let cancelled = false;
|
||||
const tasks: any[] = [];
|
||||
async function renderAll() {
|
||||
for (let i = 1; i <= pdfDoc!.numPages; i++) {
|
||||
const canvas = canvasRefs.current[i - 1];
|
||||
if (!canvas) continue;
|
||||
const page = await pdfDoc!.getPage(i);
|
||||
if (cancelled) return;
|
||||
const outputScale = window.devicePixelRatio || 1;
|
||||
const viewport = page.getViewport({ scale });
|
||||
const ctx = canvas.getContext("2d")!;
|
||||
canvas.width = Math.floor(viewport.width * outputScale);
|
||||
canvas.height = Math.floor(viewport.height * outputScale);
|
||||
canvas.style.width = `${Math.floor(viewport.width)}px`;
|
||||
canvas.style.height = `${Math.floor(viewport.height)}px`;
|
||||
const transform =
|
||||
outputScale !== 1
|
||||
? [outputScale, 0, 0, outputScale, 0, 0]
|
||||
: undefined;
|
||||
const task = page.render({
|
||||
canvasContext: ctx,
|
||||
viewport,
|
||||
transform,
|
||||
} as any);
|
||||
tasks.push(task);
|
||||
try {
|
||||
await task.promise;
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
renderAll();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
tasks.forEach((t) => t.cancel());
|
||||
};
|
||||
}, [pdfDoc, scale]);
|
||||
|
||||
const zoomIn = useCallback(
|
||||
() => setZoom((z) => Math.min(MAX_ZOOM, +(z + ZOOM_STEP).toFixed(2))),
|
||||
[],
|
||||
);
|
||||
const zoomOut = useCallback(
|
||||
() => setZoom((z) => Math.max(MIN_ZOOM, +(z - ZOOM_STEP).toFixed(2))),
|
||||
[],
|
||||
);
|
||||
const resetZoom = useCallback(() => setZoom(1), []);
|
||||
|
||||
if (!pdfDoc)
|
||||
return <div className={styles.result_container}>Loading PDF...</div>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.result_container}
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
height: "100%",
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={styles.result_header}
|
||||
style={{ display: "flex", alignItems: "center" }}
|
||||
>
|
||||
<span className={styles.result_title}>
|
||||
Source FILE — {numPages} pages
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
marginLeft: "auto",
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<button onClick={zoomOut} disabled={zoom <= MIN_ZOOM}>
|
||||
-
|
||||
</button>
|
||||
<span style={{ minWidth: 48, textAlign: "center" }}>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
<button onClick={zoomIn} disabled={zoom >= MAX_ZOOM}>
|
||||
+
|
||||
</button>
|
||||
<button onClick={resetZoom}>Fit</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* outer vertical scroll of pages */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={styles.results_content}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
minWidth: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden", // layout never widens
|
||||
background: "#525659",
|
||||
paddingTop: 16,
|
||||
paddingBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 16,
|
||||
alignItems: "stretch",
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: numPages }, (_, i) => (
|
||||
// page wrapper: fixed width via margins, does NOT grow.
|
||||
// canvas larger than this scrolls horizontally inside here.
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
margin: `0 ${PAGE_MARGIN}px`,
|
||||
background: "#fff",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
|
||||
overflowX: "auto", // internal horizontal scroll when zoomed
|
||||
overflowY: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* sticky so header stays visible while scrolling the page horizontally */}
|
||||
<div
|
||||
style={{
|
||||
position: "sticky",
|
||||
left: 0,
|
||||
padding: "6px 12px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: "#f1f1f1",
|
||||
borderBottom: "1px solid #ddd",
|
||||
}}
|
||||
>
|
||||
Page {i + 1} of {numPages}
|
||||
</div>
|
||||
<canvas
|
||||
ref={(el) => {
|
||||
canvasRefs.current[i] = el;
|
||||
}}
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
551
src/widgets/PDFResultViewer/PDFDataViewer.tsx
Normal file
551
src/widgets/PDFResultViewer/PDFDataViewer.tsx
Normal file
@ -0,0 +1,551 @@
|
||||
import { CheckOutlined, CloseOutlined, EditOutlined } from "@ant-design/icons";
|
||||
import { Table } from "antd";
|
||||
import styles from "./styles.module.css";
|
||||
import {StatementDraft, type Draft, MetaSchema, type MetaUpdate, type TableUpdate,
|
||||
type FileUpdateRequest, FileStatementResponse, ItemSchema,
|
||||
RowSchema, CellValue, MetaField, CellUpdate} from "./types"
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Config } from "../../conf/config";
|
||||
|
||||
const EMPTY_DRAFT: StatementDraft = {meta: {}, rows: {}}
|
||||
const META_KEYS: (keyof MetaSchema)[] = ["inn", "report_date", "units"]
|
||||
|
||||
function patchJson(json) {
|
||||
const initState = {
|
||||
'11': {
|
||||
header: 'ВНЕОБОРОТНЫЕ АКТИВЫ',
|
||||
is_found: false,
|
||||
},
|
||||
'12': {
|
||||
header: 'ОБОРОТНЫЕ АКТИВЫ',
|
||||
is_found: false,
|
||||
},
|
||||
'13': {
|
||||
header: 'КАПИТАЛ И РЕЗЕРВЫ',
|
||||
is_found: false,
|
||||
},
|
||||
'14': {
|
||||
header: 'ДОЛГОСРОЧНЫЕ ОБЯЗАТЕЛЬСТВА',
|
||||
is_found: false,
|
||||
},
|
||||
'15': {
|
||||
header: 'КРАТКОСРОЧНЫЕ ОБЯЗАТЕЛЬСТВА',
|
||||
is_found: false
|
||||
}
|
||||
}
|
||||
const ACTIVE_CODES = ['11', '12', '16']
|
||||
const PASSIVE_CODES = ['13', '14', '15', '17']
|
||||
|
||||
for (let j = 0; j < json.length; j++) {
|
||||
if (json[j].table.table_type === 'balance') {
|
||||
let source_rows = json[j].table.rows;
|
||||
let rows = [];
|
||||
let passive_added = false;
|
||||
let active_added = false;
|
||||
for (let i = 0; i < source_rows.length; i++) {
|
||||
let firstTwoSymbols = source_rows[i].line_code?.value || '';
|
||||
let firsts = firstTwoSymbols.slice(0,2)
|
||||
if (ACTIVE_CODES.includes(firsts) && !active_added) {
|
||||
|
||||
rows.push({line_id: 'active',
|
||||
cells: [],
|
||||
line_code: '',
|
||||
line_name: 'АКТИВ',
|
||||
row_type: 'section'
|
||||
})
|
||||
active_added = true
|
||||
|
||||
|
||||
|
||||
} else if (PASSIVE_CODES.includes(firsts) && !passive_added) {
|
||||
rows.push({line_id: 'passive',
|
||||
cells: [],
|
||||
line_code: '',
|
||||
line_name: 'ПАССИВ',
|
||||
row_type: 'section'})
|
||||
passive_added = true;
|
||||
}
|
||||
|
||||
if (initState[firsts] && !initState[firsts].is_found) {
|
||||
rows.push({line_id: `subsection_${firsts}`,
|
||||
cells: [],
|
||||
line_code: '',
|
||||
line_name: initState[firsts].header,
|
||||
row_type: 'subsection'})
|
||||
initState[firsts].is_found = true
|
||||
}
|
||||
rows.push(source_rows[i])
|
||||
}
|
||||
json[j].table.rows = rows;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeChanges(
|
||||
base: Draft,
|
||||
incoming: Draft
|
||||
): Draft {
|
||||
const result: Draft = { ...base };
|
||||
for (const [tableId, stmt] of Object.entries(incoming)) {
|
||||
const prev = result[tableId] ?? EMPTY_DRAFT;
|
||||
const mergedRows = { ...prev.rows };
|
||||
for (const [lineId, rowChanges] of Object.entries(stmt.rows)) {
|
||||
mergedRows[lineId] = {...(prev.rows[lineId] ??{}), ...rowChanges}
|
||||
}
|
||||
result[tableId] = {
|
||||
meta: {...prev.meta, ...stmt.meta},
|
||||
rows: mergedRows
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function buildPatchPayload(fileId: string, draft: Draft) {
|
||||
const tables: TableUpdate[] = Object.entries(draft).map(([tableId, statementDraft]) => {
|
||||
const metaUpdates: MetaUpdate[] = (
|
||||
Object.entries(statementDraft.meta) as [MetaField, ItemSchema][]
|
||||
).map(([key, cell]) => ({key, cell}));
|
||||
const cellUpdates: CellUpdate[] = [];
|
||||
for (const [lineId, rowDraft] of Object.entries(statementDraft.rows)) {
|
||||
for (const [cellKey, cell] of Object.entries(rowDraft)) {
|
||||
if (cellKey === 'line_code' || cellKey === 'line_name') {
|
||||
cellUpdates.push({
|
||||
line_id: lineId,
|
||||
field: cellKey,
|
||||
cell
|
||||
})
|
||||
} else {
|
||||
cellUpdates.push({
|
||||
line_id: lineId,
|
||||
value_id: cellKey,
|
||||
cell
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
table_id: tableId,
|
||||
meta: metaUpdates,
|
||||
cells: cellUpdates,
|
||||
}
|
||||
})
|
||||
return {file_id: fileId, tables}
|
||||
}
|
||||
|
||||
function getTableValueIds(rows) {
|
||||
const result = [];
|
||||
const seen = new Set();
|
||||
|
||||
rows.forEach((row) => {
|
||||
row.values.forEach((cell) => {
|
||||
if (!seen.has(cell.value_id)) {
|
||||
seen.add(cell.value_id);
|
||||
result.push(cell.value_id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function applyDraftToData(data: FileStatementResponse[], draft: Draft): FileStatementResponse[] {
|
||||
return data.map((item) => {
|
||||
const tableId = item.table.id;
|
||||
const itemDraft = draft[tableId];
|
||||
if (!itemDraft) return item
|
||||
|
||||
return {
|
||||
...item,
|
||||
meta: {
|
||||
inn: itemDraft.meta.inn ?? item.meta.inn,
|
||||
report_date: itemDraft.meta.report_date ?? item.meta.report_date,
|
||||
units: itemDraft.meta.units ?? item.meta.units,
|
||||
},
|
||||
table: {
|
||||
...item.table,
|
||||
rows: item.table.rows.map((row) => {
|
||||
const rowDraft = itemDraft.rows[row.line_id];
|
||||
if (!rowDraft) return row
|
||||
return {
|
||||
...row,
|
||||
line_code: rowDraft['line_code'] ?? row['line_code'],
|
||||
line_name: rowDraft['line_name'] ?? row['line_name'],
|
||||
cells: row.cells.map(cell => {
|
||||
if (!rowDraft[cell.value_id]) {
|
||||
return cell
|
||||
}
|
||||
let newVal = rowDraft[cell.value_id].value;
|
||||
if (row.row_type === 'row' || row.row_type === 'balance' || row.row_type === 'total') {
|
||||
// try {
|
||||
// newVal = normalizeEditedValue(newVal);
|
||||
|
||||
// } catch (err) {
|
||||
// console.log("err", err.message)
|
||||
// alert(`${err.message}`)
|
||||
// newVal = cell.value;
|
||||
// }
|
||||
newVal = normalizeEditedValue(newVal);
|
||||
}
|
||||
return {...rowDraft[cell.value_id], value: newVal}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type EditableCellProps = {
|
||||
cell: ItemSchema;
|
||||
isEditing: boolean;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
function normalizeEditedValue(nextValue: string) {
|
||||
if (nextValue.trim() === "") return "";
|
||||
const parsed = Number(nextValue.replace(',', '.'));
|
||||
// if (Number.isNaN(parsed)) {
|
||||
// throw new Error('Необходимо ввести корректное число')
|
||||
// }
|
||||
// return parsed;
|
||||
return Number.isNaN(parsed) ? nextValue : parsed;
|
||||
}
|
||||
|
||||
const FIXED_CELL_KEYS = ["line_name", "line_code"];
|
||||
|
||||
const EditableCell = memo(function EditableCell({
|
||||
cell,
|
||||
isEditing,
|
||||
onChange,
|
||||
// minWidth = 160,
|
||||
}: EditableCellProps) {
|
||||
const verified = !!cell?.is_verified;
|
||||
const defValue = typeof (cell?.value) === 'number' ? cell?.value.toLocaleString() : cell.value
|
||||
|
||||
return (
|
||||
<td
|
||||
className={`${styles.cell} ${verified ? "" : styles.unverified_cell}`}
|
||||
// style={{ minWidth }}
|
||||
>
|
||||
{isEditing ? (
|
||||
<input
|
||||
className={styles.input}
|
||||
value={cell?.value ?? ""}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
<span>{defValue ?? ""}</span>
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
});
|
||||
|
||||
type DataRowProps = {
|
||||
tableId: string;
|
||||
row: RowSchema;
|
||||
draftRow: Record<number, CellValue> | undefined;
|
||||
isEditing: boolean;
|
||||
onCellChange: (
|
||||
tableId: string,
|
||||
lineId: string,
|
||||
columnIndex: number,
|
||||
newCell: CellValue
|
||||
) => void;
|
||||
}
|
||||
|
||||
const DataRow = memo(function DataRow({
|
||||
tableId,
|
||||
row,
|
||||
// valueIds,
|
||||
draftRow,
|
||||
isEditing,
|
||||
onCellChange,
|
||||
}: DataRowProps) {
|
||||
const fixedCells = [
|
||||
{ key: "line_name", cell: row.line_name },
|
||||
{ key: "line_code", cell: row.line_code },
|
||||
];
|
||||
|
||||
const dynamicCells = [...row.cells].sort((a,b)=> a.column_index - b.column_index).map((val) => ({
|
||||
key: String(val.value_id),
|
||||
cell: val,
|
||||
}));
|
||||
|
||||
const allCells = [...fixedCells, ...dynamicCells];
|
||||
return (
|
||||
<tr>
|
||||
{allCells.map(({ key, cell }) => {
|
||||
const currentCell = Object.prototype.hasOwnProperty.call(
|
||||
draftRow || {},
|
||||
key,
|
||||
)
|
||||
? draftRow[key]
|
||||
: cell;
|
||||
|
||||
return (
|
||||
<EditableCell
|
||||
key={key}
|
||||
cell={currentCell}
|
||||
isEditing={isEditing}
|
||||
onChange={(nextValue) =>
|
||||
onCellChange(tableId, row.line_id, key, {
|
||||
...currentCell,
|
||||
...(key === "line_code" || key === "line_name"
|
||||
? {}
|
||||
: { value_id: key }),
|
||||
value: nextValue,
|
||||
})
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
|
||||
type MetaTableProps = {
|
||||
meta: MetaSchema;
|
||||
draftMeta: Partial<MetaSchema>;
|
||||
isEditing: boolean;
|
||||
onMetaChange: (key: keyof MetaSchema, newCell: ItemSchema) => void;
|
||||
}
|
||||
|
||||
const metaKeyMapping = {
|
||||
inn: 'ИНН',
|
||||
report_date: 'Дата отчета',
|
||||
units: 'Единица измерения'
|
||||
}
|
||||
|
||||
const MetaTable = memo(function MetaTable({
|
||||
meta,
|
||||
draftMeta,
|
||||
isEditing,
|
||||
onMetaChange,
|
||||
}: MetaTableProps) {
|
||||
return (
|
||||
<table cellSpacing={0} cellPadding={0}>
|
||||
<tbody>
|
||||
{Object.entries(meta).map(([metaKey, originalCell]) => {
|
||||
const currentCell = Object.prototype.hasOwnProperty.call(
|
||||
draftMeta,
|
||||
metaKey,
|
||||
)
|
||||
? draftMeta[metaKey]
|
||||
: originalCell;
|
||||
|
||||
return (
|
||||
<tr key={metaKey}>
|
||||
<td className={styles.meta_label}>{metaKeyMapping[metaKey]}</td>
|
||||
|
||||
<EditableCell
|
||||
cell={currentCell}
|
||||
isEditing={isEditing}
|
||||
onChange={(nextValue) =>
|
||||
onMetaChange(metaKey, {
|
||||
...currentCell,
|
||||
value: nextValue,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const Block = memo(function Block({item, draftBlock, isEditing, onMetaChange, onCellChange}) {
|
||||
const tableId = item.table.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{alignSelf: "flex-end", maxWidth: 'fit-content', marginBottom: 20 }}>
|
||||
<MetaTable
|
||||
meta={item.meta}
|
||||
draftMeta={draftBlock.meta}
|
||||
isEditing={isEditing}
|
||||
onMetaChange={(key, newCell) => onMetaChange(tableId, key, newCell)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{alignSelf:'flex-start'}}>{item.table.table_type === 'income' ? "Отчет о финансовых результатах" : 'Бухгалтерский баланс'}</div>
|
||||
|
||||
<div className={styles.table_wrapper}>
|
||||
<table className={styles.table} cellSpacing={0} cellPadding={0}>
|
||||
<tbody>
|
||||
{item.table.rows.map((row) => {
|
||||
|
||||
if (row.row_type !== 'section' && row.row_type !== 'subsection') {
|
||||
return <DataRow
|
||||
key={row.line_id}
|
||||
tableId={tableId}
|
||||
row={row}
|
||||
draftRow={draftBlock.rows[row.line_id]}
|
||||
isEditing={isEditing}
|
||||
onCellChange={onCellChange}
|
||||
|
||||
/> } else if (row.row_type === 'section') {
|
||||
return <tr key={row.line_id} style={{fontSize: 24}}><td>{row.line_name}</td></tr>
|
||||
} else {
|
||||
return <tr key={row.line_id}><td>{row.line_name}</td></tr>
|
||||
}
|
||||
|
||||
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
export default function PdfDataViewer({ fileId }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [error, setError] = useState<string|null>(null);
|
||||
const [data, setData] = useState(null);
|
||||
const [draft, setDraft] = useState<Draft>({});
|
||||
const [pending, setPending] = useState<Draft>({});
|
||||
useEffect(()=>{
|
||||
async function fetchData(){
|
||||
if (!fileId) return
|
||||
const res_fsl = await fetch(`${Config.API_URL}/fsl/by-file-id?file_id=${fileId}`)
|
||||
|
||||
if (res_fsl.status === 404) {
|
||||
setData(null);
|
||||
setError("Не найдены данные по файлу")
|
||||
} else if (res_fsl.status !== 200) {
|
||||
setData(null);
|
||||
setError("Непредвиденная ошибка при получении данных")
|
||||
} else {
|
||||
const json = await res_fsl.json()
|
||||
patchJson(json)
|
||||
setData(json)
|
||||
setError(null)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [fileId])
|
||||
|
||||
const updateMetaDraft = useCallback((tableId:string, key: keyof MetaSchema, newCell: ItemSchema) => {
|
||||
setDraft((prev) => {
|
||||
const res = {
|
||||
...prev,
|
||||
[tableId]: {
|
||||
...prev[tableId] ?? EMPTY_DRAFT,
|
||||
meta: {
|
||||
...(prev[tableId]?.meta ?? {}),
|
||||
[key]: newCell
|
||||
}
|
||||
}
|
||||
}
|
||||
return res;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateCellDraft = useCallback((tableId:string, lineId: string, value_id: string, newCell: CellValue) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[tableId]: {
|
||||
...prev[tableId] ?? EMPTY_DRAFT,
|
||||
meta: prev[tableId]?.meta ?? {},
|
||||
rows: {
|
||||
...(prev[tableId]?.rows ?? {}),
|
||||
[lineId]: {
|
||||
...(prev[tableId]?.rows[lineId] ?? {}),
|
||||
[value_id]: newCell
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
}, []);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditing(true);
|
||||
}, []);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setDraft({});
|
||||
setIsEditing(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
|
||||
setData((prev) => applyDraftToData(prev, draft));
|
||||
setPending((prev) => mergeChanges(prev, draft));
|
||||
setDraft({});
|
||||
setIsEditing(false);
|
||||
}, [data, draft]);
|
||||
|
||||
const handleConfirm = useCallback(async() => {
|
||||
const payload = buildPatchPayload(fileId, pending)
|
||||
const res = await fetch(`${Config.API_URL}/fsl/confirm`, {
|
||||
method: "PATCH",
|
||||
headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
if (res.status !== 200) {
|
||||
alert('Произошла ошибка при попытке сохранить изменения')
|
||||
} else {
|
||||
const json = await res.json()
|
||||
patchJson(json)
|
||||
setData(json)
|
||||
setDraft({})
|
||||
setPending({})
|
||||
}
|
||||
|
||||
}, [pending, fileId])
|
||||
if (error !== null) {
|
||||
return <div className={styles.result_container}>
|
||||
<div className={styles.result_header}>
|
||||
<span className={styles.result_title}>РЕЗУЛЬТАТ РАСПОЗНАВАНИЯ</span>
|
||||
|
||||
</div>
|
||||
<h4 style={{}}>{error}</h4>
|
||||
</div>
|
||||
}
|
||||
if (data === null) {
|
||||
return <div className={styles.result_container}>
|
||||
<div className={styles.result_header}>
|
||||
<span className={styles.result_title}>РЕЗУЛЬТАТ РАСПОЗНАВАНИЯ</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
return (
|
||||
<div className={styles.result_container}>
|
||||
<div className={styles.result_header}>
|
||||
<span className={styles.result_title}>РЕЗУЛЬТАТ РАСПОЗНАВАНИЯ</span>
|
||||
<div className={styles.result_actions}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button onClick={handleSave} className={styles.confirm_button}>
|
||||
<CheckOutlined /> Сохранить
|
||||
</button>
|
||||
<button onClick={handleCancel} className={styles.cancel_button}>
|
||||
<CloseOutlined /> Отменить
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button onClick={handleEdit} className={styles.edit_button}>
|
||||
<EditOutlined /> Редактировать
|
||||
</button>
|
||||
<button onClick={handleConfirm} className={styles.confirm_button}>
|
||||
<CheckOutlined /> Подтвердить данные
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.results_content}>
|
||||
<div className={styles.document_content}>
|
||||
{data.map((item) => (
|
||||
<Block key={item.table.id} item={item} draftBlock={draft[item.table.id] ?? EMPTY_DRAFT}
|
||||
isEditing={isEditing} onMetaChange={updateMetaDraft} onCellChange={updateCellDraft}/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
140
src/widgets/PDFResultViewer/styles.module.css
Normal file
140
src/widgets/PDFResultViewer/styles.module.css
Normal file
@ -0,0 +1,140 @@
|
||||
.result_container {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(226, 232, 240, .9);
|
||||
border-radius: 28px;
|
||||
background: radial-gradient(circle at top left,#f8fafcf5,#fffffff0 38%),#fff;
|
||||
}
|
||||
|
||||
.result_header {
|
||||
padding: 22px 24px 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.result_title {
|
||||
display: inline-block;
|
||||
margin-bottom: 0;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .08em;
|
||||
text-transform: uppercase;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.result_actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cancel_button,
|
||||
.edit_button {
|
||||
background-color: white;
|
||||
padding: 6px 10px;
|
||||
border: 1px solid #d9dee6;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cancel_button:hover,
|
||||
.edit_button:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.confirm_button {
|
||||
color: #fff;
|
||||
border: 1px solid #2f9648;
|
||||
background: #2f9648;
|
||||
box-shadow: 0 1px 2px #1f72372e;
|
||||
min-height: 30px;
|
||||
padding: 6px 10px;
|
||||
transition: background .12s ease,box-shadow .12s ease,color .1s ease;
|
||||
cursor: pointer;
|
||||
min-height: 30px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.confirm_button:hover {
|
||||
color: #fff;
|
||||
border-color: #1f7c3c;
|
||||
background: #1f7c3c;
|
||||
box-shadow: 0 2px 5px #1f72373d;
|
||||
}
|
||||
|
||||
.results_content {
|
||||
padding: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
/* overflow-x: auto; */
|
||||
}
|
||||
|
||||
.document_content {
|
||||
background: #fff;
|
||||
border: 1px solid rgba(203, 213, 225, .9);
|
||||
border-radius: 8px;
|
||||
padding: 22px;
|
||||
box-shadow: 0 16px 32px #0f172a14;
|
||||
color: #111827;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
}
|
||||
|
||||
|
||||
.table_wrapper {
|
||||
overflow-x: auto;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.table {
|
||||
border-collapse: collapse;
|
||||
width: fit-content;
|
||||
border-spacing: 0px;
|
||||
border-color: gray;
|
||||
}
|
||||
.cell {
|
||||
background: inherit;
|
||||
/* border-collapse: collapse; */
|
||||
padding: 8px 10px;
|
||||
min-height: 40px;
|
||||
/* min-width: 140px; */
|
||||
box-sizing: border-box;
|
||||
border: 1px solid black;
|
||||
}
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 5px 8px;
|
||||
border-radius: 5px;
|
||||
border: 0;
|
||||
box-shadow: 0 0 0 1px #0000001a,0 1px 2px #0000000f;
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
font-weight: inherit;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
/* border-color: #2f9648; */
|
||||
border: 0;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px #2ea64b59,0 1px 2px #0000000f;
|
||||
}
|
||||
|
||||
.balance_row {
|
||||
background-color: #f8fafc;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.unverified_cell {
|
||||
background-color: #fff7ed;
|
||||
color: #7c2d12;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.meta_label {
|
||||
font-weight: 700;
|
||||
}
|
||||
69
src/widgets/PDFResultViewer/types.ts
Normal file
69
src/widgets/PDFResultViewer/types.ts
Normal file
@ -0,0 +1,69 @@
|
||||
export type ItemSchema = {
|
||||
is_verified: boolean;
|
||||
value: string | number | null;
|
||||
}
|
||||
|
||||
export type MetaSchema = {
|
||||
inn: ItemSchema;
|
||||
report_date: ItemSchema;
|
||||
units: ItemSchema;
|
||||
}
|
||||
|
||||
export type CellValue = ItemSchema & {
|
||||
value_id: string | null;
|
||||
column_index: number;
|
||||
}
|
||||
|
||||
export type RowSchema = {
|
||||
line_id: string;
|
||||
cells: CellValue[];
|
||||
line_code: CellValue;
|
||||
line_name: CellValue;
|
||||
row_type: 'header' | 'row' | 'total' | 'balance';
|
||||
}
|
||||
|
||||
export type TableSchema = {
|
||||
id: string;
|
||||
table_type: 'balance' | 'income';
|
||||
meta: MetaSchema;
|
||||
rows: RowSchema;
|
||||
}
|
||||
|
||||
export type FileStatementResponse = {
|
||||
meta: MetaSchema;
|
||||
table: TableSchema;
|
||||
}
|
||||
|
||||
export type RowDraft = Record<string, CellValue>
|
||||
export type RowsDraft = Record<string, RowDraft>
|
||||
export type StatementDraft = {
|
||||
meta: Partial<MetaSchema>;
|
||||
rows: RowsDraft;
|
||||
}
|
||||
export type Draft = Record<string, StatementDraft>
|
||||
|
||||
export type MetaField = 'inn' | 'report_date' | 'units';
|
||||
export type FixedRowField = 'line_name' | 'line_code';
|
||||
|
||||
export type MetaUpdate = {
|
||||
key: MetaField;
|
||||
cell: ItemSchema;
|
||||
}
|
||||
|
||||
export type CellUpdate = {
|
||||
line_id: string;
|
||||
field?: FixedRowField;
|
||||
value_id?: string;
|
||||
cell: ItemSchema;
|
||||
}
|
||||
|
||||
export type TableUpdate = {
|
||||
table_id: string;
|
||||
meta: MetaUpdate[];
|
||||
cells: CellUpdate[];
|
||||
}
|
||||
|
||||
export type FileUpdateRequest = {
|
||||
file_id: string;
|
||||
tables: TableUpdate[]
|
||||
}
|
||||
@ -1,109 +1,254 @@
|
||||
import { HomeOutlined, LeftOutlined, RightOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { Divider, Layout, Tooltip } from 'antd';
|
||||
import { type ReactNode, memo, useMemo, useState } from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import { useCurrentUser } from '../../auth/MeContext';
|
||||
import { USERS_ADMIN } from '../../auth/permissions';
|
||||
import styles from './styles.module.css';
|
||||
import {
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import styles from "./styles.module.css";
|
||||
import { Divider, Layout, Tooltip, type MenuProps, Modal, Button } from "antd";
|
||||
import { NavLink, useLocation, useNavigate } from "react-router-dom";
|
||||
import {
|
||||
BankOutlined,
|
||||
ContainerOutlined,
|
||||
InboxOutlined,
|
||||
LeftOutlined,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
SolutionOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { USERS_ADMIN } from "../../auth/permissions";
|
||||
import { useCurrentUser } from "../../auth/MeContext";
|
||||
import { MyModal } from "../NewConsoleModal/NewConsoleModal";
|
||||
|
||||
type MenuItem = Required<MenuProps>["items"][number];
|
||||
|
||||
function CollapsedIcon({ icon, onClick, title, to, cn = "" }) {
|
||||
if (to === null) {
|
||||
return (
|
||||
<Tooltip placement="right" title={title}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn || `${styles.iconBtn} ${styles.iconBtnNav}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Tooltip placement="right" title={title}>
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
cn ||
|
||||
`${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
type NavItem = {
|
||||
link: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
/** Право из `/me`, без которого пункт не показывается; нет — пункт виден всем. */
|
||||
permission?: string;
|
||||
link: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
/** Право из `/me`, без которого пункт не показывается; нет — пункт виден всем. */
|
||||
permission?: string;
|
||||
};
|
||||
|
||||
type Group = {
|
||||
title: string;
|
||||
key: string;
|
||||
children: NavItem[];
|
||||
title: string;
|
||||
key: string;
|
||||
children: NavItem[];
|
||||
};
|
||||
|
||||
// Каркас: разделы меню — по карте экранов docs/context/10-user-journey.md, добавляются срезами.
|
||||
const config: Group[] = [
|
||||
{
|
||||
title: 'Главная',
|
||||
key: 'home',
|
||||
children: [{ link: '/', icon: <HomeOutlined />, label: 'Главная' }],
|
||||
},
|
||||
{
|
||||
title: 'Система',
|
||||
key: 'system',
|
||||
children: [{ link: '/admin', icon: <TeamOutlined />, label: 'Пользователи и роли', permission: USERS_ADMIN }],
|
||||
},
|
||||
{
|
||||
title: "Отчетность",
|
||||
key: "reporting",
|
||||
children: [
|
||||
{
|
||||
link: "/groups",
|
||||
icon: <BankOutlined />,
|
||||
label: "Группы компаний",
|
||||
},
|
||||
{
|
||||
link: "/groups/monozaemchiki",
|
||||
icon: <UserOutlined />,
|
||||
label: "Монозаемщики",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Справочники",
|
||||
key: "catalogues",
|
||||
children: [
|
||||
{
|
||||
link: "/catalogue/groups",
|
||||
icon: <TeamOutlined />,
|
||||
label: "ГК и организации",
|
||||
},
|
||||
{
|
||||
link: "/catalogue/jur_l",
|
||||
icon: <SolutionOutlined />,
|
||||
label: "ЮЛ вне ГК",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Документы",
|
||||
key: "docs",
|
||||
children: [
|
||||
{
|
||||
link: "/docstorage",
|
||||
icon: <InboxOutlined />,
|
||||
label: "Единое хранилище",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "Система",
|
||||
key: "system",
|
||||
children: [
|
||||
{
|
||||
link: "/logging",
|
||||
icon: <ContainerOutlined />,
|
||||
label: "Журнал действий",
|
||||
},
|
||||
{
|
||||
link: "/admin",
|
||||
icon: <TeamOutlined />,
|
||||
label: "Пользователи и роли",
|
||||
permission: USERS_ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/** Меню для пользователя: пункты без нужного права убираются, опустевшие группы — тоже. */
|
||||
function visibleGroups(permissions: string[]): Group[] {
|
||||
return config
|
||||
.map((group) => ({ ...group, children: group.children.filter((i) => !i.permission || permissions.includes(i.permission)) }))
|
||||
.filter((group) => group.children.length > 0);
|
||||
function NavLinkStyled({ to, children }) {
|
||||
return (
|
||||
<NavLink
|
||||
className={({ isActive }) => {
|
||||
return `${styles.linkBase} ${isActive ? styles.linkActive : ""}`;
|
||||
}}
|
||||
to={to}
|
||||
end
|
||||
>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) {
|
||||
return (
|
||||
<Tooltip placement='right' title={title}>
|
||||
<NavLink to={to} className={({ isActive }) => `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}>
|
||||
{icon}
|
||||
</NavLink>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) {
|
||||
return (
|
||||
<NavLink className={({ isActive }) => `${styles.linkBase} ${isActive ? styles.linkActive : ''}`} to={to}>
|
||||
{children}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) {
|
||||
return !isCollapsed ? (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{group.title}</div>
|
||||
{group.children.map((i) => (
|
||||
<NavLinkStyled key={i.link} to={i.link}>
|
||||
{i.icon}
|
||||
{i.label}
|
||||
</NavLinkStyled>
|
||||
))}
|
||||
<Divider size='small' />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{group.children.map((i) => (
|
||||
<CollapsedIcon key={i.link} icon={i.icon} to={i.link} title={i.label} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
const Section = memo(function Section({
|
||||
isCollapsed,
|
||||
group,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
group: Group;
|
||||
}) {
|
||||
return !isCollapsed ? (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{group.title}</div>
|
||||
{group.children.map((i) => (
|
||||
<NavLinkStyled key={i.link} to={i.link}>
|
||||
{i.icon}
|
||||
{i.label}
|
||||
</NavLinkStyled>
|
||||
))}
|
||||
<Divider size="small" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{group.children.map((i) => (
|
||||
<CollapsedIcon
|
||||
key={i.link}
|
||||
icon={i.icon}
|
||||
// onClick={() => navigate(i.link)}
|
||||
onClick={() => {}}
|
||||
// isActive={isGroupActive(i.link)}
|
||||
// isActive={false}
|
||||
to={i.link}
|
||||
title={i.label}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export function Sidebar() {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const me = useCurrentUser();
|
||||
// useMemo: группы пересчитываются только при смене прав, иначе memo(Section) получал бы новый объект на каждой отрисовке
|
||||
const groups = useMemo(() => visibleGroups(me.permissions), [me.permissions]);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
return (
|
||||
<Layout.Sider
|
||||
collapsed={isCollapsed}
|
||||
style={{ height: 'auto', margin: '10px', borderRadius: 10 }}
|
||||
width={280}
|
||||
collapsedWidth={64}
|
||||
collapsible
|
||||
trigger={null}>
|
||||
<div className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ''}`}>
|
||||
<div className={styles.topbar}>
|
||||
<button type='button' className={styles.collapseBtn} onClick={() => setIsCollapsed(!isCollapsed)}>
|
||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
{groups.map((g) => (
|
||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
);
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Layout.Sider
|
||||
collapsed={isCollapsed}
|
||||
style={{ height: "auto", margin: "10px", borderRadius: 10 }}
|
||||
width={210}
|
||||
collapsedWidth={64}
|
||||
collapsible
|
||||
trigger={null}
|
||||
>
|
||||
<div
|
||||
className={`${styles.wrapper} ${isCollapsed ? styles.wrapperCollapsed : ""}`}
|
||||
>
|
||||
<div className={styles.topbar}>
|
||||
<button
|
||||
className={styles.collapseBtn}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? <RightOutlined /> : <LeftOutlined />}
|
||||
</button>
|
||||
</div>
|
||||
{isCollapsed ? (
|
||||
<div>
|
||||
<CollapsedIcon
|
||||
icon={<PlusOutlined />}
|
||||
cn={`${styles.iconBtn} ${styles.iconBtnAction}`}
|
||||
to={null}
|
||||
// isActive={false}
|
||||
title="Новая консолидация"
|
||||
onClick={() => {
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={() => {
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<PlusOutlined />
|
||||
Новая консолидация
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{config.map((g) => (
|
||||
<Section key={g.key} group={g} isCollapsed={isCollapsed} />
|
||||
))}
|
||||
</div>
|
||||
<MyModal
|
||||
open={modalOpen}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
}}
|
||||
onOk={async (values) => {
|
||||
// await api.create(values)
|
||||
setModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Layout.Sider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,86 +1,88 @@
|
||||
.company_group_item {
|
||||
border: 2px solid grey;
|
||||
border-radius: 2px;
|
||||
height: 50px;
|
||||
width: 90%;
|
||||
cursor: pointer;
|
||||
background-color: lightgray;
|
||||
border: 2px solid grey;
|
||||
border-radius: 2px;
|
||||
height: 50px;
|
||||
width: 90%;
|
||||
cursor: pointer;
|
||||
background-color: lightgray;
|
||||
}
|
||||
.active_cg {
|
||||
background-color: white;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.collapsedList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
|
||||
.section {
|
||||
margin-bottom: 0px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 0px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #8c8c8c;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: rgb(16, 24, 40);
|
||||
padding: 8px 10px;
|
||||
text-transform: uppercase ;
|
||||
letter-spacing: 0.04em;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.search {
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.subgroupToggle {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 600;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 6px 10px;
|
||||
margin-bottom: 6px;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.subgroup {
|
||||
/* padding-left: 8px; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* gap: 10px; */
|
||||
margin-bottom: 12px;
|
||||
/* padding-left: 8px; */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
/* gap: 10px; */
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.linkBase {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
transition: background 0.2s, color 0.2s;
|
||||
}
|
||||
|
||||
/* .linkActive:hover {
|
||||
@ -88,101 +90,102 @@
|
||||
} */
|
||||
|
||||
.linkBase:hover {
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
background: rgb(249, 250, 251);
|
||||
color: rgba(55, 65, 81, 0.88);
|
||||
background: rgb(249, 250, 251);
|
||||
}
|
||||
|
||||
.linkBase.linkActive {
|
||||
color: rgb(31, 114, 55);
|
||||
background: #edf8ee;
|
||||
font-weight: 600;
|
||||
color: rgb(31, 114, 55);
|
||||
background: #edf8ee;
|
||||
}
|
||||
|
||||
.state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.emptyWrap {
|
||||
padding-top: 8;
|
||||
padding-top: 8;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
transition: width 0.2s ease;
|
||||
}
|
||||
|
||||
.wrapperCollapsed {
|
||||
padding: 12px 0;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.collapsedList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.iconBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 22px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
.iconBtnActive,
|
||||
.iconBtnActive:hover {
|
||||
background: rgba(46, 166, 75, 0.16);
|
||||
color: rgb(31, 114, 55);
|
||||
background: rgba(46, 166, 75, 0.16);
|
||||
color: rgb(31, 114, 55);
|
||||
}
|
||||
|
||||
.iconBtnNav:hover {
|
||||
color: #101828;
|
||||
background: #f2f4f7;
|
||||
color: #101828;
|
||||
background: #f2f4f7;
|
||||
}
|
||||
|
||||
.iconBtnAction {
|
||||
background: linear-gradient(160deg, #2ea64b, #1f7237);
|
||||
font-size: small;
|
||||
box-shadow: 0 1px 3px #1f723740;
|
||||
color: white;
|
||||
background: linear-gradient(160deg,#2ea64b,#1f7237);
|
||||
font-size: small;
|
||||
box-shadow: 0 1px 3px #1f723740;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.iconBtnAction:hover {
|
||||
background: linear-gradient(160deg, #268f40, #185c2b);
|
||||
background: linear-gradient(160deg,#268f40,#185c2b);
|
||||
}
|
||||
|
||||
/* .iconBtn. */
|
||||
|
||||
|
||||
.collapseBtn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25);
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: rgba(0, 0, 0, 0.75);
|
||||
transition: background 0.2s, color 0.2s;
|
||||
font-size: 12px;
|
||||
padding: 0;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user