diff --git a/src/App.tsx b/src/App.tsx index 26cb0a0..4244953 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,33 +1,53 @@ -import './App.css'; -import { Layout } from 'antd'; -import { Provider } from 'react-redux'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; -import { AuthGate } from './auth/AuthGate'; -import { Config } from './conf/config'; -import { Admin } from './layouts/Admin'; -import { Main } from './layouts/Main'; -import { store } from './store/store'; -import { Sidebar } from './widgets/Sidebar/Sidebar'; +import "./App.css"; +import { Layout } from "antd"; +import { Provider } from "react-redux"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { AuthGate } from "./auth/AuthGate"; +import { Config } from "./conf/config"; +import { + Admin, + AuditLogging, + CatalogueGroup, + CatalogueJur, + ConsolidationPage, + DocStorage, + GroupsLayout, + Main, + Mono, +} from "./layouts/"; +import { store } from "./store/store"; +import { Sidebar } from "./widgets/Sidebar/Sidebar"; +import { FileViewer } from "./layouts/FileViewer"; // Каркас: экраны по docs/context/10-user-journey.md добавляются срезами. function App() { - return ( - - - - - - - - } /> - } /> - - - - - - - ); + return ( + + + + + + + + } /> + } /> + + } /> + } /> + }> + + } /> + } /> + } /> + } /> + } /> + + + + + + + ); } export default App; diff --git a/src/assets/fonts/Geist-Bold.woff2 b/src/assets/fonts/Geist-Bold.woff2 new file mode 100755 index 0000000..870db68 Binary files /dev/null and b/src/assets/fonts/Geist-Bold.woff2 differ diff --git a/src/assets/fonts/Geist-Light.woff2 b/src/assets/fonts/Geist-Light.woff2 new file mode 100755 index 0000000..c049fcd Binary files /dev/null and b/src/assets/fonts/Geist-Light.woff2 differ diff --git a/src/assets/fonts/Geist-Medium.woff2 b/src/assets/fonts/Geist-Medium.woff2 new file mode 100755 index 0000000..3a62ba4 Binary files /dev/null and b/src/assets/fonts/Geist-Medium.woff2 differ diff --git a/src/assets/fonts/Geist-Regular.woff2 b/src/assets/fonts/Geist-Regular.woff2 new file mode 100755 index 0000000..d98d41c Binary files /dev/null and b/src/assets/fonts/Geist-Regular.woff2 differ diff --git a/src/assets/fonts/Geist-SemiBold.woff2 b/src/assets/fonts/Geist-SemiBold.woff2 new file mode 100755 index 0000000..ea20a79 Binary files /dev/null and b/src/assets/fonts/Geist-SemiBold.woff2 differ diff --git a/src/assets/fonts/Geist-Thin.woff2 b/src/assets/fonts/Geist-Thin.woff2 new file mode 100755 index 0000000..7d69385 Binary files /dev/null and b/src/assets/fonts/Geist-Thin.woff2 differ diff --git a/src/components/FileUploaderModel.tsx b/src/components/FileUploaderModel.tsx new file mode 100644 index 0000000..d9b41ec --- /dev/null +++ b/src/components/FileUploaderModel.tsx @@ -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([]); + const [isOpen, setIsOpen] = useState(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 ( + <> + + { + setCurrentFiles([]); + setIsOpen(false); + }} + onOk={async () => { + trigger_sse_cb(); + setIsOpen(false); + setCurrentFiles([]); + }} + cancelText="Отмена" + okText="Готово" + open={isOpen} + > + {currentFiles.length === 0 ? ( + <> + + + + + + + ) : ( + <> + ( + +
+
+ + {item.file.name} + {item.status === "success" && ( + + )} + {item.status === "error" && ( + + )} +
+ {item.status === "error" && ( + + {item.errorMessage} + + )} +
+
+ )} + /> + + + )} +
+ + ); +} diff --git a/src/components/FilesList.tsx b/src/components/FilesList.tsx new file mode 100644 index 0000000..64ea416 --- /dev/null +++ b/src/components/FilesList.tsx @@ -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 = { + JPEG: , + XLSX: ( + + ), + JPG: , + XLS: , + PDF: ( + + ), +}; + +export function FileItem({ file }: { file: FileDisplayItem }) { + const icon = typeToIconMapping[file.file_type]; + + return ( +
+ {/*
*/} + {typeToIconMapping[file.file_type]} + {/*
*/} + + {/* */} +
+ +

{file.original_name}

+ +

{file.status ?? "В очереди"}

+

{file.document_type}

+
+
+ ); +} diff --git a/src/components/GenericTable/ColumnSettings.tsx b/src/components/GenericTable/ColumnSettings.tsx new file mode 100644 index 0000000..05c8b9d --- /dev/null +++ b/src/components/GenericTable/ColumnSettings.tsx @@ -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 { + defs: ColumnDef[]; + visibleKeys: Array>; + onChange: (keys: Array>) => void; +} +export function ColumnSettings({ defs, visibleKeys, onChange }: Props) { + const toggle = (key: Extract, 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: ( + toggle(child.key, e.target.checked)} + onClick={(e) => e.stopPropagation()} + > + {def.title} - {child.title} + + ), + })); + return [ + { + key: def.key, + label: ( + toggle(def.key, e.target.checked)} + onClick={(e) => e.stopPropagation()} + > + {def.title} + + ), + }, + ]; + }); + return ( + + + + ); +} diff --git a/src/components/GenericTable/CustomFilterDropdown.tsx b/src/components/GenericTable/CustomFilterDropdown.tsx new file mode 100644 index 0000000..3cd2e5d --- /dev/null +++ b/src/components/GenericTable/CustomFilterDropdown.tsx @@ -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( + selectedKeys.length ? selectedKeys : defaultKeys, + ); + const [draftSort, setDraftSort] = useState(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([ + ...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 ( +
e.stopPropagation()} + > + {sort && ( + <> +
+ Сортировка +
+ { + const v = e.target.value; + setDraftSort(v === NO_SORT ? null : (v as "asc" | "desc")); + }} + style={{ display: "flex", flexDirection: "column", gap: 4 }} + > + + Нет + + + По возрастанию + + + По убыванию + + + {(search || multiselect) && } + + )} + + {search && ( + } + value={query} + onChange={(e) => setQuery(e.target.value)} + style={{ marginBottom: 8 }} + /> + )} + + {multiselect && filteredOptions.length > 0 && ( + <> + + + + + + +
+ setDraftKeys(vals as React.Key[])} + style={{ display: "flex", flexDirection: "column", gap: 4 }} + > + {filteredOptions.map((o) => ( + + {o.label} + + ))} + +
+ + )} + + + + + + +
+ ); +} diff --git a/src/components/GenericTable/DataTable.tsx b/src/components/GenericTable/DataTable.tsx new file mode 100644 index 0000000..90b05a0 --- /dev/null +++ b/src/components/GenericTable/DataTable.tsx @@ -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 = ColumnType & { + position?: number | "start" | "end"; + afterKey?: Extract; +}; +interface DataTableProps> { + config: TableConfig; + rows: T[]; + allRows: T[]; + loading?: boolean; + visibleKeys: Array>; + globalQuery?: string; + columnFilters: ColumnFilters; + onColumnFiltersChange: (next: ColumnFilters) => void; + sortState: SortState; + onSortChange: (next: SortState) => void; + extraColumns?: ColumnsType; + rowSelection?: TableProps["rowSelection"]; + scrollY?: number | string; + onRow?: TableProps["onRow"]; +} + +export function DataTable>({ + config, + rows, + allRows, + loading, + visibleKeys, + globalQuery = "", + columnFilters, + onColumnFiltersChange, + sortState, + onSortChange, + extraColumns = [], + rowSelection, + scrollY = 480, + onRow, +}: DataTableProps) { + const { columns: defs, searchableKeys = [], rowKey } = config; + const normalizedQuery = globalQuery.trim().toLowerCase(); + const hasGlobalSearch = + searchableKeys.length > 0 && normalizedQuery.length > 0; + + const getOptions = (key: Extract): FilterOption[] => { + const seen = new Set(); + 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): ColumnType => { + 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 ? ( + + ) : ( + text + ); + }, + ...(hasDropdown + ? { + filterIcon: () => { + const active = + (columnFilters[key]?.length ?? 0) > 0 || sortState?.key === key; + return ( + + + + ); + }, + filterDropdown: ({ confirm, clearFilters }) => ( + + 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 = useMemo(() => { + const built: ColumnsType = []; + 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); + } else { + if (!visibleKeys.includes(def.key)) continue; + built.push(buildLeaf(def)); + } + } + for (const col of extraColumns) { + const { position, afterKey, ...rest } = col as ExtraColumn & { + 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[number]); + } + return built; + }, [ + defs, + visibleKeys, + sortState, + allRows, + searchableKeys, + globalQuery, + hasGlobalSearch, + columnFilters, + onColumnFiltersChange, + onSortChange, + extraColumns, + ]); + + const wrapperRef = useRef(null); + const [appliedScrollY, setAppliedScrollY] = useState(scrollY); + + useLayoutEffect(() => { + const wrapper = wrapperRef.current; + if (!wrapper) return; + + const update = () => { + const total = wrapper.clientHeight; + + const header = wrapper.querySelector(".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 ( +
+ + rowKey={rowKey} + loading={loading} + columns={columns} + dataSource={rows} + rowSelection={rowSelection} + pagination={false} + scroll={{ x: "max-content", y: appliedScrollY }} + onRow={onRow} + /> +
+ ); +} diff --git a/src/components/GenericTable/Highlight.tsx b/src/components/GenericTable/Highlight.tsx new file mode 100644 index 0000000..46bea59 --- /dev/null +++ b/src/components/GenericTable/Highlight.tsx @@ -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() ? ( + + {part} + + ) : ( + {part} + ), + )} + + ); +} diff --git a/src/components/GenericTable/columnUtils.ts b/src/components/GenericTable/columnUtils.ts new file mode 100644 index 0000000..25dc10d --- /dev/null +++ b/src/components/GenericTable/columnUtils.ts @@ -0,0 +1,4 @@ +import { isGroup, type ColumnDef, type LeafColumnDef } from "./tableTypes"; +export function collectLeaves(cols: ColumnDef[]): LeafColumnDef[] { + return cols.flatMap((c) => (isGroup(c) ? c.children : [c])); +} diff --git a/src/components/GenericTable/index.ts b/src/components/GenericTable/index.ts new file mode 100644 index 0000000..22005a6 --- /dev/null +++ b/src/components/GenericTable/index.ts @@ -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"; diff --git a/src/components/GenericTable/tableTypes.ts b/src/components/GenericTable/tableTypes.ts new file mode 100644 index 0000000..3ee6eb1 --- /dev/null +++ b/src/components/GenericTable/tableTypes.ts @@ -0,0 +1,45 @@ +import type { ReactNode } from "react"; + +export type SortDirection = "asc" | "desc" | null; +export type SortState = { + key: Extract; + dir: "asc" | "desc"; +} | null; +export interface FilterFeatures { + multiselect?: boolean; + search?: boolean; + sort?: boolean; +} +export interface FilterOption { + label: ReactNode; + value: string | number; +} + +export interface LeafColumnDef { + key: Extract; + 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 { + title: string; + groupKey: string; + children: LeafColumnDef[]; + fixed?: "left" | "right"; +} +export type ColumnDef = LeafColumnDef | GroupColumnDef; + +export interface TableConfig { + rowKey: Extract; + columns: ColumnDef[]; + searchableKeys?: Array>; +} +export type ColumnFilters = Partial< + Record, Array> +>; +export function isGroup(c: ColumnDef): c is GroupColumnDef { + return (c as GroupColumnDef).children !== undefined; +} diff --git a/src/components/GenericTable/useFilteredRows.ts b/src/components/GenericTable/useFilteredRows.ts new file mode 100644 index 0000000..17da740 --- /dev/null +++ b/src/components/GenericTable/useFilteredRows.ts @@ -0,0 +1,62 @@ +import { useMemo } from "react"; +import { ColumnFilters, SortState, TableConfig } from "./tableTypes"; + +export function useFilteredRows>( + rawRows: T[], + config: TableConfig, + globalQuery: string, + columnFilters: ColumnFilters, + sort: SortState, +) { + 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]; + 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, + }; +} diff --git a/src/components/GenericTable/useTableData.ts b/src/components/GenericTable/useTableData.ts new file mode 100644 index 0000000..2f72e5c --- /dev/null +++ b/src/components/GenericTable/useTableData.ts @@ -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(fetcher: () => Promise) { + const [rows, setRows] = useState([]); + 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 }; +} diff --git a/src/components/GenericTable/useTableFilters.ts b/src/components/GenericTable/useTableFilters.ts new file mode 100644 index 0000000..bc24876 --- /dev/null +++ b/src/components/GenericTable/useTableFilters.ts @@ -0,0 +1,31 @@ +// components/DataTable/useTableFilters.ts +import { useCallback, useState } from "react"; +import type { ColumnFilters, SortState } from "./tableTypes"; + +export function useTableFilters() { + const [rawQuery, setRawQuery] = useState(""); + const [columnFilters, setColumnFilters] = useState>({}); + const [sort, setSort] = useState>(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, + }; +} diff --git a/src/components/GenericTable/useVisibleColumns.ts b/src/components/GenericTable/useVisibleColumns.ts new file mode 100644 index 0000000..dcd3806 --- /dev/null +++ b/src/components/GenericTable/useVisibleColumns.ts @@ -0,0 +1,10 @@ +import { useState } from "react"; +import type { TableConfig } from "./tableTypes"; + +// All columns visible by default. +export function useVisibleColumns(config: TableConfig) { + const [visibleKeys, setVisibleKeys] = useState< + Array> + >(() => config.columns.map((c) => c.key)); + return { visibleKeys, setVisibleKeys }; +} diff --git a/src/components/PageTitle.tsx b/src/components/PageTitle.tsx new file mode 100644 index 0000000..942a767 --- /dev/null +++ b/src/components/PageTitle.tsx @@ -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 ( +
+
+
+ {backable && ( + + )} + {location} +
+

+ {header} +

+ {description &&
{description}
} +
+
+
+ ); +} diff --git a/src/conf/config.ts b/src/conf/config.ts index acd3f47..315a4d1 100644 --- a/src/conf/config.ts +++ b/src/conf/config.ts @@ -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 ?? ''; +} \ No newline at end of file diff --git a/src/env.d.ts b/src/env.d.ts deleted file mode 100644 index b0ac762..0000000 --- a/src/env.d.ts +++ /dev/null @@ -1 +0,0 @@ -/// diff --git a/src/index.css b/src/index.css index 9200763..d791289 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } diff --git a/src/index.tsx b/src/index.tsx index ef98385..09e0174 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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( - // - - {/* AntApp даёт message/notification через App.useApp() с темой ConfigProvider; component={false} — без лишнего div */} - - - - , - // , +createRoot(document.getElementById("root")!).render( + // + + + , + // , ); diff --git a/src/layouts/AuditLogging.tsx b/src/layouts/AuditLogging.tsx new file mode 100644 index 0000000..d3fc49e --- /dev/null +++ b/src/layouts/AuditLogging.tsx @@ -0,0 +1,3 @@ +export function AuditLogging() { + return

Журнал действий

; +} diff --git a/src/layouts/Catalogue.tsx b/src/layouts/Catalogue.tsx new file mode 100644 index 0000000..08a8e66 --- /dev/null +++ b/src/layouts/Catalogue.tsx @@ -0,0 +1,7 @@ +export function Catalogue() { + return ( + <> +

Справочники

+ + ); +} diff --git a/src/layouts/CatalogueGroups.tsx b/src/layouts/CatalogueGroups.tsx new file mode 100644 index 0000000..84aa336 --- /dev/null +++ b/src/layouts/CatalogueGroups.tsx @@ -0,0 +1,3 @@ +export function CatalogueGroup() { + return

ГК и организации

; +} diff --git a/src/layouts/CatalogueJur.tsx b/src/layouts/CatalogueJur.tsx new file mode 100644 index 0000000..0ef3a42 --- /dev/null +++ b/src/layouts/CatalogueJur.tsx @@ -0,0 +1,3 @@ +export function CatalogueJur() { + return

ЮЛ вне ГК

; +} diff --git a/src/layouts/ConsolidationsList.tsx b/src/layouts/ConsolidationsList.tsx new file mode 100644 index 0000000..2419176 --- /dev/null +++ b/src/layouts/ConsolidationsList.tsx @@ -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 ( + <> +
+ Version_id: {current_version_id}, legal_entity_id:{" "} + {current_legal_entity_id} +
+ {}} + /> + {drawerContent.map((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 ( +
+ +
+
+ +
+
+ { + setDrawerOpen(true); + }} + /> +
+
+ { + setDrawerOpen(false); + }} + > + + +
+ ); +} diff --git a/src/layouts/DocStorage.tsx b/src/layouts/DocStorage.tsx new file mode 100644 index 0000000..64a45cb --- /dev/null +++ b/src/layouts/DocStorage.tsx @@ -0,0 +1,3 @@ +export function DocStorage() { + return

Единое хранилище

; +} diff --git a/src/layouts/FileViewer.tsx b/src/layouts/FileViewer.tsx new file mode 100644 index 0000000..ac41650 --- /dev/null +++ b/src/layouts/FileViewer.tsx @@ -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 ( + <> +

PDF

+
+ + +
+ + ); +} + +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 ( + <> +

XLSX

{JSON.stringify(data)}
+ + ); +} + +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 ( +
+ + {normFileType === "image" ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/layouts/Groups.tsx b/src/layouts/Groups.tsx new file mode 100644 index 0000000..3933c13 --- /dev/null +++ b/src/layouts/Groups.tsx @@ -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 ( +
+ +
+ +
+
+ ); +} diff --git a/src/layouts/Monozaemchiki.tsx b/src/layouts/Monozaemchiki.tsx new file mode 100644 index 0000000..79f01f6 --- /dev/null +++ b/src/layouts/Monozaemchiki.tsx @@ -0,0 +1,7 @@ +export function Mono() { + return ( + <> +

Монозаёмщики

+ + ); +} diff --git a/src/layouts/index.ts b/src/layouts/index.ts new file mode 100644 index 0000000..25eb9e0 --- /dev/null +++ b/src/layouts/index.ts @@ -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"; diff --git a/src/services/api/consolidations.ts b/src/services/api/consolidations.ts new file mode 100644 index 0000000..c2390a8 --- /dev/null +++ b/src/services/api/consolidations.ts @@ -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; +} diff --git a/src/services/api/directories/company_groups.ts b/src/services/api/directories/company_groups.ts new file mode 100644 index 0000000..5eecc95 --- /dev/null +++ b/src/services/api/directories/company_groups.ts @@ -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; +} diff --git a/src/services/api/mocks.ts b/src/services/api/mocks.ts new file mode 100644 index 0000000..5f23e5c --- /dev/null +++ b/src/services/api/mocks.ts @@ -0,0 +1,441 @@ +import { IConsolidation } from "../../types/consolidations/consolidations"; +import { IVersionCard } from "../../types/consolidations/version_members"; + +export const versionCardsMock: Record = { + "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, + }, + ], + }, +]; diff --git a/src/store/slices/.gitkeep b/src/store/slices/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/store/slices/companyGroupsSlice/companyGroupsSlice.ts b/src/store/slices/companyGroupsSlice/companyGroupsSlice.ts new file mode 100644 index 0000000..19e4dd1 --- /dev/null +++ b/src/store/slices/companyGroupsSlice/companyGroupsSlice.ts @@ -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; diff --git a/src/store/slices/companyGroupsSlice/thunks.ts b/src/store/slices/companyGroupsSlice/thunks.ts new file mode 100644 index 0000000..dc936c6 --- /dev/null +++ b/src/store/slices/companyGroupsSlice/thunks.ts @@ -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 : "Ошибка", + ); + } + }, +); diff --git a/src/store/slices/companyGroupsSlice/types.ts b/src/store/slices/companyGroupsSlice/types.ts new file mode 100644 index 0000000..7c2b147 --- /dev/null +++ b/src/store/slices/companyGroupsSlice/types.ts @@ -0,0 +1,8 @@ +import { ICompanyGroup } from "../../../types/companyGroups/companyGroups"; + +export interface CompanyGroupSliceType { + total_groups: number; + raw_rows: ICompanyGroup[]; + loading: boolean; + error: string | null; +} diff --git a/src/store/slices/consolidations/consolidationsSlice.ts b/src/store/slices/consolidations/consolidationsSlice.ts new file mode 100644 index 0000000..ce1a883 --- /dev/null +++ b/src/store/slices/consolidations/consolidationsSlice.ts @@ -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; diff --git a/src/store/slices/consolidations/thunks.ts b/src/store/slices/consolidations/thunks.ts new file mode 100644 index 0000000..447925a --- /dev/null +++ b/src/store/slices/consolidations/thunks.ts @@ -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 : "Ошибка", + ); + } + }, +); diff --git a/src/store/slices/consolidations/types.ts b/src/store/slices/consolidations/types.ts new file mode 100644 index 0000000..4b8f6b7 --- /dev/null +++ b/src/store/slices/consolidations/types.ts @@ -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; +}; diff --git a/src/store/slices/fileSlice.ts b/src/store/slices/fileSlice.ts new file mode 100644 index 0000000..bcf8c7e --- /dev/null +++ b/src/store/slices/fileSlice.ts @@ -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; diff --git a/src/store/store.ts b/src/store/store.ts index e3364ed..08c374e 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -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; diff --git a/src/types/companyGroups/companyGroups.ts b/src/types/companyGroups/companyGroups.ts new file mode 100644 index 0000000..ca77888 --- /dev/null +++ b/src/types/companyGroups/companyGroups.ts @@ -0,0 +1,7 @@ +export interface ICompanyGroup { + id: string; + name: string; + crm_id: string; + base_regional_office_id: string; + legal_entities_count: number; +} diff --git a/src/types/consolidations/common_types.ts b/src/types/consolidations/common_types.ts new file mode 100644 index 0000000..77596f3 --- /dev/null +++ b/src/types/consolidations/common_types.ts @@ -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; +} diff --git a/src/types/consolidations/consolidations.ts b/src/types/consolidations/consolidations.ts new file mode 100644 index 0000000..fe31dc2 --- /dev/null +++ b/src/types/consolidations/consolidations.ts @@ -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; +} diff --git a/src/types/consolidations/version_members.ts b/src/types/consolidations/version_members.ts new file mode 100644 index 0000000..b454516 --- /dev/null +++ b/src/types/consolidations/version_members.ts @@ -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; +}; diff --git a/src/components/.gitkeep b/src/types/legal_entities.ts similarity index 100% rename from src/components/.gitkeep rename to src/types/legal_entities.ts diff --git a/src/widgets/CompanyGroupsTable/CompanyGroupsTable.tsx b/src/widgets/CompanyGroupsTable/CompanyGroupsTable.tsx new file mode 100644 index 0000000..5e94ba9 --- /dev/null +++ b/src/widgets/CompanyGroupsTable/CompanyGroupsTable.tsx @@ -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(); + // 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 = useMemo( + () => [ + { + key: "actions", + title: "", + width: 120, + render: (_: unknown, row: ICompanyGroup) => ( + + + + ), + }, + ], + [navigate], + ); + + return ( +
+ + } + placeholder="Поиск по ГК, CRM ID или РФ" + value={rawQuery} + onChange={(e) => setRawQuery(e.target.value)} + style={{ maxWidth: 320 }} + /> + {hasActiveFilters && ( + + )} + + + + +
+ ); +} diff --git a/src/widgets/CompanyGroupsTable/config.tsx b/src/widgets/CompanyGroupsTable/config.tsx new file mode 100644 index 0000000..f8c983f --- /dev/null +++ b/src/widgets/CompanyGroupsTable/config.tsx @@ -0,0 +1,30 @@ +import { Tag } from "antd"; +import type { TableConfig } from "../../components/GenericTable"; +import { ICompanyGroup } from "../../types/companyGroups/companyGroups"; + +export const groupsConfig: TableConfig = { + 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 }, + }, + ], +}; diff --git a/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx new file mode 100644 index 0000000..ba34f9b --- /dev/null +++ b/src/widgets/ConsoleLegalEntities/ConsoleLegalEntities.tsx @@ -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(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(); + // 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 ( +
+ +
+

+ Организации +

+ + {rawRows.length} шт. + +
+
+ {hasActiveFilters && ( + + )} + +
+
+ +
+ ); +} diff --git a/src/widgets/ConsoleLegalEntities/config.tsx b/src/widgets/ConsoleLegalEntities/config.tsx new file mode 100644 index 0000000..ef85f45 --- /dev/null +++ b/src/widgets/ConsoleLegalEntities/config.tsx @@ -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) => ( + + ), + }, + { key: "inn", title: "ИНН", features: { search: true, multiselect: true } }, + ], +}; diff --git a/src/widgets/ConsoleLegalEntities/normalize.ts b/src/widgets/ConsoleLegalEntities/normalize.ts new file mode 100644 index 0000000..9688cde --- /dev/null +++ b/src/widgets/ConsoleLegalEntities/normalize.ts @@ -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; +} diff --git a/src/widgets/ConsoleTable/ConsoleTable.tsx b/src/widgets/ConsoleTable/ConsoleTable.tsx new file mode 100644 index 0000000..f4f8e43 --- /dev/null +++ b/src/widgets/ConsoleTable/ConsoleTable.tsx @@ -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 ( + { + domEvent.stopPropagation(); + onAction(key as ActionKey, row); + }, + }} + > + + + + + ), + }, + ], + [navigate], + ); + + const onRow: TableProps["onRow"] = (row) => ({ + onClick: () => dispatch(setSelectedVersion(row.id)), + }); + return ( +
+ +
+

+ Консолидации +

+ + {rawRows.length} шт. + +
+
+ {hasActiveFilters && ( + + )} + +
+
+ +
+ ); +} diff --git a/src/widgets/ConsoleTable/config.tsx b/src/widgets/ConsoleTable/config.tsx new file mode 100644 index 0000000..ddfa5d8 --- /dev/null +++ b/src/widgets/ConsoleTable/config.tsx @@ -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 = { + INITIATED: "gold", + DOCUMENTS_PROCESSING: "blue", + VERIFIED: "green", + CANCELLED: "default", + REJECTED: "red", + ARCHIVED: "default", +}; + +export const consConfigBase: TableConfig = { + 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) => ( + + {v} + + ), + }, + { + 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: {} }, + ], +}; diff --git a/src/widgets/ConsoleTable/consts.ts b/src/widgets/ConsoleTable/consts.ts new file mode 100644 index 0000000..f1db3dc --- /dev/null +++ b/src/widgets/ConsoleTable/consts.ts @@ -0,0 +1,34 @@ +import { + ConsRussianStatus, + ConsStatus, +} from "../../types/consolidations/common_types"; + +export const statusMapping: Record = { + VERIFIED: "Верифицирован", + VERIFICATION_GO: "Верификация ГО", + VERIFICATION_RF: "Верификация РФ", + INITIATED: "Инициация", + DOCUMENTS_PROCESSING: "Обработка документов", + DATA_CORRECTION: "Коррекция данных", + CONSOLIDATION_GO: "Консолидация ГО", + CONSOLIDATION_RF: "Консолидация РФ", + CANCELLED: "Отменён", + ADJUSTMENT: "Корректировка", + ARCHIVED: "В архиве", + REJECTED: "Отклонён", +}; + +export const statusColorMapping: Record = { + "В архиве": "default", + "Верификация ГО": "geekblue", + "Верификация РФ": "geekblue", + Верифицирован: "green", + Инициация: "default", + "Консолидация ГО": "purple", + "Консолидация РФ": "purple", + Корректировка: "gold", + "Коррекция данных": "gold", + "Обработка документов": "gold", + Отклонён: "red", + Отменён: "red", +}; diff --git a/src/widgets/ConsoleTable/flatten.ts b/src/widgets/ConsoleTable/flatten.ts new file mode 100644 index 0000000..e04abbf --- /dev/null +++ b/src/widgets/ConsoleTable/flatten.ts @@ -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; +} diff --git a/src/widgets/ConsoleTable/groupAdjacent.ts b/src/widgets/ConsoleTable/groupAdjacent.ts new file mode 100644 index 0000000..9f341be --- /dev/null +++ b/src/widgets/ConsoleTable/groupAdjacent.ts @@ -0,0 +1,19 @@ +import { ConsRow } from "../../types/consolidations/consolidations"; +export function groupAdjacent(sorted: ConsRow[]): ConsRow[] { + const order: string[] = []; + const byGroup = new Map(); + 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; +} diff --git a/src/widgets/ConsoleTable/useGrouping.ts b/src/widgets/ConsoleTable/useGrouping.ts new file mode 100644 index 0000000..bb07e81 --- /dev/null +++ b/src/widgets/ConsoleTable/useGrouping.ts @@ -0,0 +1,17 @@ +import { useMemo, useState } from "react"; +import { ConsRow } from "../../types/consolidations/consolidations"; + +export function useGrouping(rows: ConsRow[]) { + const [expanded, setExpanded] = useState>(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 }; +} diff --git a/src/widgets/NewConsoleModal/NewConsoleModal.tsx b/src/widgets/NewConsoleModal/NewConsoleModal.tsx new file mode 100644 index 0000000..656dc6b --- /dev/null +++ b/src/widgets/NewConsoleModal/NewConsoleModal.tsx @@ -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; +}; + +export const MyModal: React.FC = ({ open, onCancel, onOk }) => { + const [form] = Form.useForm(); + 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 ( + +
+ + + + + (option?.label as string) + ?.toLowerCase() + .includes(input.toLowerCase()) + } + options={subgroups.map((s) => ({ value: s.id, label: s.name }))} + notFoundContent={ + loadingSubgroups ? "Загрузка" : "Нет вариантов" + } + /> + + + + + + + + + +
+
+ ); +}; diff --git a/src/widgets/PDFResultViewer/IFrameBlock.tsx b/src/widgets/PDFResultViewer/IFrameBlock.tsx new file mode 100644 index 0000000..fef4fd1 --- /dev/null +++ b/src/widgets/PDFResultViewer/IFrameBlock.tsx @@ -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(null); + const canvasRefs = useRef<(HTMLCanvasElement | null)[]>([]); + const [pdfDoc, setPdfDoc] = useState(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
Loading PDF...
; + + return ( +
+
+ + Source FILE — {numPages} pages + + + + + {Math.round(zoom * 100)}% + + + + +
+ + {/* outer vertical scroll of pages */} +
+
+ {Array.from({ length: numPages }, (_, i) => ( + // page wrapper: fixed width via margins, does NOT grow. + // canvas larger than this scrolls horizontally inside here. +
+ {/* sticky so header stays visible while scrolling the page horizontally */} +
+ Page {i + 1} of {numPages} +
+ { + canvasRefs.current[i] = el; + }} + style={{ display: "block" }} + /> +
+ ))} +
+
+
+ ); +} diff --git a/src/widgets/PDFResultViewer/PDFDataViewer.tsx b/src/widgets/PDFResultViewer/PDFDataViewer.tsx new file mode 100644 index 0000000..e1cd1bd --- /dev/null +++ b/src/widgets/PDFResultViewer/PDFDataViewer.tsx @@ -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 ( + + {isEditing ? ( + onChange(e.target.value)} + /> + ) : ( + {defValue ?? ""} + )} + + ); +}); + +type DataRowProps = { + tableId: string; + row: RowSchema; + draftRow: Record | 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 ( + + {allCells.map(({ key, cell }) => { + const currentCell = Object.prototype.hasOwnProperty.call( + draftRow || {}, + key, + ) + ? draftRow[key] + : cell; + + return ( + + onCellChange(tableId, row.line_id, key, { + ...currentCell, + ...(key === "line_code" || key === "line_name" + ? {} + : { value_id: key }), + value: nextValue, + }) + } + /> + ); + })} + + ); +}); + +type MetaTableProps = { + meta: MetaSchema; + draftMeta: Partial; + 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 ( + + + {Object.entries(meta).map(([metaKey, originalCell]) => { + const currentCell = Object.prototype.hasOwnProperty.call( + draftMeta, + metaKey, + ) + ? draftMeta[metaKey] + : originalCell; + + return ( + + + + + onMetaChange(metaKey, { + ...currentCell, + value: nextValue, + }) + } + /> + + ); + })} + +
{metaKeyMapping[metaKey]}
+ ); +}); + + +const Block = memo(function Block({item, draftBlock, isEditing, onMetaChange, onCellChange}) { + const tableId = item.table.id; + + return ( + <> +
+ onMetaChange(tableId, key, newCell)} + /> +
+
{item.table.table_type === 'income' ? "Отчет о финансовых результатах" : 'Бухгалтерский баланс'}
+ +
+ + + {item.table.rows.map((row) => { + + if (row.row_type !== 'section' && row.row_type !== 'subsection') { + return } else if (row.row_type === 'section') { + return + } else { + return + } + + + })} + +
{row.line_name}
{row.line_name}
+
+ + ) +}) + +export default function PdfDataViewer({ fileId }) { + const [isEditing, setIsEditing] = useState(false); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [draft, setDraft] = useState({}); + const [pending, setPending] = useState({}); + 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
+
+ РЕЗУЛЬТАТ РАСПОЗНАВАНИЯ + +
+

{error}

+
+ } + if (data === null) { + return
+
+ РЕЗУЛЬТАТ РАСПОЗНАВАНИЯ +
+
+ } + return ( +
+
+ РЕЗУЛЬТАТ РАСПОЗНАВАНИЯ +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+
+ +
+
+ {data.map((item) => ( + + ))} +
+
+
+ ); +} diff --git a/src/widgets/PDFResultViewer/styles.module.css b/src/widgets/PDFResultViewer/styles.module.css new file mode 100644 index 0000000..4da0126 --- /dev/null +++ b/src/widgets/PDFResultViewer/styles.module.css @@ -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; +} \ No newline at end of file diff --git a/src/widgets/PDFResultViewer/types.ts b/src/widgets/PDFResultViewer/types.ts new file mode 100644 index 0000000..e5f2693 --- /dev/null +++ b/src/widgets/PDFResultViewer/types.ts @@ -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 +export type RowsDraft = Record +export type StatementDraft = { + meta: Partial; + rows: RowsDraft; +} +export type Draft = Record + +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[] +} \ No newline at end of file diff --git a/src/widgets/Sidebar/Sidebar.tsx b/src/widgets/Sidebar/Sidebar.tsx index 95e6afa..fadf8d6 100644 --- a/src/widgets/Sidebar/Sidebar.tsx +++ b/src/widgets/Sidebar/Sidebar.tsx @@ -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["items"][number]; + +function CollapsedIcon({ icon, onClick, title, to, cn = "" }) { + if (to === null) { + return ( + + + + ); + } + return ( + + + cn || + `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}` + } + > + {icon} + + + ); +} 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: , label: 'Главная' }], - }, - { - title: 'Система', - key: 'system', - children: [{ link: '/admin', icon: , label: 'Пользователи и роли', permission: USERS_ADMIN }], - }, + { + title: "Отчетность", + key: "reporting", + children: [ + { + link: "/groups", + icon: , + label: "Группы компаний", + }, + { + link: "/groups/monozaemchiki", + icon: , + label: "Монозаемщики", + }, + ], + }, + { + title: "Справочники", + key: "catalogues", + children: [ + { + link: "/catalogue/groups", + icon: , + label: "ГК и организации", + }, + { + link: "/catalogue/jur_l", + icon: , + label: "ЮЛ вне ГК", + }, + ], + }, + { + title: "Документы", + key: "docs", + children: [ + { + link: "/docstorage", + icon: , + label: "Единое хранилище", + }, + ], + }, + { + title: "Система", + key: "system", + children: [ + { + link: "/logging", + icon: , + label: "Журнал действий", + }, + { + link: "/admin", + icon: , + 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 ( + { + return `${styles.linkBase} ${isActive ? styles.linkActive : ""}`; + }} + to={to} + end + > + {children} + + ); } -function CollapsedIcon({ icon, title, to }: { icon: ReactNode; title: string; to: string }) { - return ( - - `${styles.iconBtn} ${isActive ? styles.iconBtnActive : styles.iconBtnNav}`}> - {icon} - - - ); -} - -function NavLinkStyled({ to, children }: { to: string; children: ReactNode }) { - return ( - `${styles.linkBase} ${isActive ? styles.linkActive : ''}`} to={to}> - {children} - - ); -} - -const Section = memo(function Section({ isCollapsed, group }: { isCollapsed: boolean; group: Group }) { - return !isCollapsed ? ( -
-
{group.title}
- {group.children.map((i) => ( - - {i.icon} - {i.label} - - ))} - -
- ) : ( - <> - {group.children.map((i) => ( - - ))} - - ); +const Section = memo(function Section({ + isCollapsed, + group, +}: { + isCollapsed: boolean; + group: Group; +}) { + return !isCollapsed ? ( +
+
{group.title}
+ {group.children.map((i) => ( + + {i.icon} + {i.label} + + ))} + +
+ ) : ( + <> + {group.children.map((i) => ( + 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 ( - -
-
- -
- {groups.map((g) => ( -
- ))} -
-
- ); + const navigate = useNavigate(); + + return ( + +
+
+ +
+ {isCollapsed ? ( +
+ } + cn={`${styles.iconBtn} ${styles.iconBtnAction}`} + to={null} + // isActive={false} + title="Новая консолидация" + onClick={() => { + setModalOpen(true); + }} + /> +
+ ) : ( + <> + + + )} + {config.map((g) => ( +
+ ))} +
+ { + setModalOpen(false); + }} + onOk={async (values) => { + // await api.create(values) + setModalOpen(false); + }} + /> +
+ ); } diff --git a/src/widgets/Sidebar/styles.module.css b/src/widgets/Sidebar/styles.module.css index 7f90c29..85fee50 100644 --- a/src/widgets/Sidebar/styles.module.css +++ b/src/widgets/Sidebar/styles.module.css @@ -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; +} \ No newline at end of file