second update

This commit is contained in:
maxim 2026-10-07 12:48:56 +03:00
parent 383e4e3e5d
commit 81548eb4ae
70 changed files with 4551 additions and 286 deletions

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
src/assets/fonts/Geist-Thin.woff2 Executable file

Binary file not shown.

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
),
)}
</>
);
}

View 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]));
}

View 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";

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

View 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,
};
}

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

View 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,
};
}

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

View 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>
);
}

View File

@ -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
View File

@ -1 +0,0 @@
/// <reference types="@rsbuild/core/types" />

View File

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

View File

@ -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>,
);

View File

@ -0,0 +1,3 @@
export function AuditLogging() {
return <h3>Журнал действий</h3>;
}

View File

@ -0,0 +1,7 @@
export function Catalogue() {
return (
<>
<h3>Справочники</h3>
</>
);
}

View File

@ -0,0 +1,3 @@
export function CatalogueGroup() {
return <h3>ГК и организации</h3>;
}

View File

@ -0,0 +1,3 @@
export function CatalogueJur() {
return <h3>ЮЛ вне ГК</h3>;
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
export function DocStorage() {
return <h3>Единое хранилище</h3>;
}

109
src/layouts/FileViewer.tsx Normal file
View 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
View 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>
);
}

View File

@ -0,0 +1,7 @@
export function Mono() {
return (
<>
<h3>Монозаёмщики</h3>
</>
);
}

10
src/layouts/index.ts Normal file
View 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";

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

View 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
View 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,
},
],
},
];

View 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;

View 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 : "Ошибка",
);
}
},
);

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

View 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;

View 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 : "Ошибка",
);
}
},
);

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

View 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;

View File

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

View File

@ -0,0 +1,7 @@
export interface ICompanyGroup {
id: string;
name: string;
crm_id: string;
base_regional_office_id: string;
legal_entities_count: number;
}

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

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

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

View 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>
);
}

View 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 },
},
],
};

View 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>
);
}

View 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 } },
],
};

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

View 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>
);
}

View 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: {} },
],
};

View 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",
};

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

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

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

View 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>
);
};

View 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>
);
}

View 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>
);
}

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

View 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[]
}

View File

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

View File

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