From a7a14d947a8577be1a0bec856ede4ee9b47993c0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Sun, 6 Sep 2026 09:53:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=B8=D0=BD=D0=B0=D0=BD=D1=81=D1=8B:=20?= =?UTF-8?q?=D0=B4=D0=B5=D1=80=D0=B5=D0=B2=D1=8C=D1=8F=20=D0=BA=D0=B0=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D0=BE=D1=80=D0=B8=D0=B9=20=D0=BA=D0=B0=D0=BA=20?= =?UTF-8?q?=D0=B2=20Data-Insight2=20=E2=80=94=20=D0=B3=D1=80=D1=83=D0=BF?= =?UTF-8?q?=D0=BF=D0=B8=D1=80=D0=BE=D0=B2=D0=BA=D0=B0=20=D0=B0=D0=B2=D1=82?= =?UTF-8?q?=D0=BE/=D1=80=D1=83=D1=87=D0=BD=D1=8B=D0=B5,=20=D1=81=D0=B2?= =?UTF-8?q?=D1=91=D1=80=D0=BD=D1=83=D1=82=D0=BE=20=D0=BF=D0=BE=20=D1=83?= =?UTF-8?q?=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E,=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BB=D0=BE=D1=81=D1=8B=20=D1=81=D1=83=D0=BC=D0=BC,=20?= =?UTF-8?q?=D0=BD=D0=B5=D1=80=D0=B0=D1=81=D0=BF=D1=80=D0=B5=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D1=91=D0=BD=D0=BD=D1=8B=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/finance/CategoryManager.tsx | 2 +- .../src/components/finance/CategoryTree.tsx | 168 ++++++++++++++++-- client/src/components/finance/TreeNodeRow.tsx | 2 +- client/src/pages/finance/dashboard.tsx | 4 + server/finance/category-matching.ts | 55 ++++++ server/finance/routes.ts | 28 ++- 6 files changed, 228 insertions(+), 31 deletions(-) diff --git a/client/src/components/finance/CategoryManager.tsx b/client/src/components/finance/CategoryManager.tsx index 47646f3..f259bf9 100644 --- a/client/src/components/finance/CategoryManager.tsx +++ b/client/src/components/finance/CategoryManager.tsx @@ -33,7 +33,7 @@ function CategoryNode({ onEdit: (n: Category) => void; onDelete: (id: string) => void; }) { - const [expanded, setExpanded] = useState(true); + const [expanded, setExpanded] = useState(false); const hasChildren = node.children && node.children.length > 0; return ( diff --git a/client/src/components/finance/CategoryTree.tsx b/client/src/components/finance/CategoryTree.tsx index 3c6e73d..bdc2dd5 100644 --- a/client/src/components/finance/CategoryTree.tsx +++ b/client/src/components/finance/CategoryTree.tsx @@ -1,5 +1,5 @@ -import { useState } from "react"; -import { ChevronRight, ChevronDown, Folder, FolderOpen, EyeOff, Wand2 } from "lucide-react"; +import { useState, useCallback } from "react"; +import { ChevronRight, ChevronDown, Folder, FolderOpen, EyeOff, Wand2, ChevronsUpDown, ChevronsDownUp } from "lucide-react"; import { cn, formatCurrency } from "@/lib/utils"; import type { TreeNode } from "@/hooks/useFinance"; @@ -7,6 +7,32 @@ interface CategoryTreeProps { trees: TreeNode[]; onSelect?: (id: string, name: string) => void; selectedId?: string | null; + /** Нераспределённые суммы (серая строка внизу, как в Data-Insight2) */ + uncategorized?: { total: number; count: number } | null; + /** Ключ sessionStorage для персистентности развёрнутых узлов (по умолчанию не сохраняется) */ + storageKey?: string; +} + +const BAR_COLORS = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"]; + +function loadExpanded(storageKey?: string): Set { + // По умолчанию (как в Data-Insight2) всё свёрнуто + if (!storageKey) return new Set(); + try { + const raw = sessionStorage.getItem(storageKey); + if (!raw) return new Set(); + return new Set(JSON.parse(raw) as string[]); + } catch { + return new Set(); + } +} + +function collectIds(nodes: TreeNode[] | undefined, acc: string[] = []): string[] { + for (const n of nodes || []) { + acc.push(n.id); + collectIds(n.children, acc); + } + return acc; } function TreeNodeItem({ @@ -14,14 +40,18 @@ function TreeNodeItem({ depth, onSelect, selectedId, + expandedIds, + toggle, }: { node: TreeNode; depth: number; onSelect?: (id: string, name: string) => void; selectedId?: string | null; + expandedIds: Set; + toggle: (id: string) => void; }) { - const [expanded, setExpanded] = useState(true); const hasChildren = node.children && node.children.length > 0; + const isExpanded = expandedIds.has(node.id); const isSelected = selectedId === node.id; return ( @@ -33,13 +63,13 @@ function TreeNodeItem({ depth > 0 && "ml-4" )} onClick={() => { - if (hasChildren) setExpanded((e) => !e); + if (hasChildren) toggle(node.id); onSelect?.(node.id, node.name); }} > {hasChildren ? ( - expanded ? ( + isExpanded ? ( ) : ( @@ -52,7 +82,7 @@ function TreeNodeItem({ {node.excluded ? ( ) : hasChildren ? ( - expanded ? ( + isExpanded ? ( ) : ( @@ -65,9 +95,14 @@ function TreeNodeItem({ {node.name} + {node.excluded && ( + + Искл. + + )} {node.autoDetected && } - + {formatCurrency(node.total)} {node.count > 0 && ( @@ -77,7 +112,7 @@ function TreeNodeItem({ )} - {hasChildren && expanded && ( + {hasChildren && isExpanded && (
{node.children!.map((child) => ( ))}
@@ -94,20 +131,101 @@ function TreeNodeItem({ ); } -export function CategoryTree({ trees, onSelect, selectedId }: CategoryTreeProps) { +export function CategoryTree({ trees, onSelect, selectedId, uncategorized, storageKey }: CategoryTreeProps) { + const [expandedIds, setExpandedIds] = useState>(() => loadExpanded(storageKey)); + + const persist = useCallback( + (next: Set) => { + setExpandedIds(next); + if (storageKey) { + try { + sessionStorage.setItem(storageKey, JSON.stringify([...next])); + } catch { + // sessionStorage недоступен — игнорируем + } + } + }, + [storageKey] + ); + + const toggle = useCallback( + (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + persist(next); + return next; + }); + }, + [persist] + ); + + const setAll = useCallback( + (nodes: TreeNode[] | undefined, expand: boolean) => { + setExpandedIds((prev) => { + const next = new Set(prev); + for (const id of collectIds(nodes)) { + if (expand) next.add(id); + else next.delete(id); + } + persist(next); + return next; + }); + }, + [persist] + ); + if (!trees || trees.length === 0) { return
Нет категорий
; } + const maxTotal = Math.max(...trees.map((t) => t.total || 0), uncategorized?.total || 0, 1); + return (
- {trees.map((tree) => ( + {trees.map((tree, idx) => (
-
- {tree.name} - - {formatCurrency(tree.total)} ({tree.count}) +
+ + {tree.name} + {!tree.isPrimary && ( + + Отчет + + )} +
+ + + + {formatCurrency(tree.total)} ({tree.count}) + +
+
+
+
{tree.nodes?.map((node) => ( @@ -117,11 +235,33 @@ export function CategoryTree({ trees, onSelect, selectedId }: CategoryTreeProps) depth={0} onSelect={onSelect} selectedId={selectedId} + expandedIds={expandedIds} + toggle={toggle} /> ))} + {(!tree.nodes || tree.nodes.length === 0) && ( +
Нет данных за период
+ )}
))} + + {uncategorized && uncategorized.count > 0 && ( +
+
+ Нераспределённые + + {formatCurrency(uncategorized.total)} ({uncategorized.count}) + +
+
+
+
+
+ )}
); } diff --git a/client/src/components/finance/TreeNodeRow.tsx b/client/src/components/finance/TreeNodeRow.tsx index 1e3d289..1b2eb89 100644 --- a/client/src/components/finance/TreeNodeRow.tsx +++ b/client/src/components/finance/TreeNodeRow.tsx @@ -12,7 +12,7 @@ interface TreeNodeRowProps { } export function TreeNodeRow({ node, depth = 0, onSelect, selectedId, searchTerm }: TreeNodeRowProps) { - const [expanded, setExpanded] = useState(true); + const [expanded, setExpanded] = useState(false); const hasChildren = node.children && node.children.length > 0; const isSelected = selectedId === node.id; diff --git a/client/src/pages/finance/dashboard.tsx b/client/src/pages/finance/dashboard.tsx index 55cefb4..0bac0ae 100644 --- a/client/src/pages/finance/dashboard.tsx +++ b/client/src/pages/finance/dashboard.tsx @@ -165,6 +165,8 @@ export default function FinanceDashboard() { ) : ( { setActiveTab("income"); // could filter by category here in future @@ -179,6 +181,8 @@ export default function FinanceDashboard() { ) : ( { setActiveTab("expenses"); }} diff --git a/server/finance/category-matching.ts b/server/finance/category-matching.ts index 15d5418..1e00249 100644 --- a/server/finance/category-matching.ts +++ b/server/finance/category-matching.ts @@ -34,3 +34,58 @@ export function applyCategoryRules(row: Record, categories: any[]): } return null; } + +// Сумма статистики категории со всеми потомками (статистика хранится по id глубочайшей совпавшей категории) +function subtreeStats( + cat: any, + treeTotals: Map +): { total: number; count: number } { + const own = treeTotals.get(cat.id) || { total: 0, count: 0 }; + let total = own.total; + let count = own.count; + for (const child of cat.children || []) { + const s = subtreeStats(child, treeTotals); + total += s.total; + count += s.count; + } + return { total, count }; +} + +// Группировка корневых категорий по деревьям автоопределения (как в Data-Insight2): +// корни с treeId из конфигурации дерева — в это дерево, остальные — в «Ручные категории». +export function buildGroupedTrees( + categories: any[], + autoConfig: { trees: Array<{ id: string; name: string; isPrimary?: boolean }> }, + treeTotals: Map, + buildTreeNodes: (roots: any[]) => any[] +): Array<{ id: string; name: string; isPrimary: boolean; total: number; count: number; nodes: any[] }> { + const configIds = new Set(autoConfig.trees.map((t) => t.id)); + const manualRoots = categories.filter((c) => !c.treeId || !configIds.has(c.treeId)); + const trees: Array<{ id: string; name: string; isPrimary: boolean; total: number; count: number; nodes: any[] }> = []; + + if (manualRoots.length > 0) { + trees.push({ + id: "__manual__", + name: "Ручные категории", + isPrimary: true, + total: manualRoots.reduce((s, c) => s + subtreeStats(c, treeTotals).total, 0), + count: manualRoots.reduce((s, c) => s + subtreeStats(c, treeTotals).count, 0), + nodes: buildTreeNodes(manualRoots), + }); + } + + for (const treeCfg of autoConfig.trees) { + const roots = categories.filter((c) => c.treeId === treeCfg.id); + trees.push({ + id: treeCfg.id, + name: treeCfg.name, + isPrimary: !!treeCfg.isPrimary, + total: roots.reduce((s, c) => s + subtreeStats(c, treeTotals).total, 0), + count: roots.reduce((s, c) => s + subtreeStats(c, treeTotals).count, 0), + nodes: buildTreeNodes(roots), + }); + } + + return trees; +} + diff --git a/server/finance/routes.ts b/server/finance/routes.ts index 07866a3..03fb12d 100644 --- a/server/finance/routes.ts +++ b/server/finance/routes.ts @@ -23,7 +23,7 @@ import { tenantIsolation } from "../middleware/tenant.middleware"; import { previewCustomerSync, applyCustomerSync, type SyncApplyCustomer } from "./customer-sync"; import { previewContractSync, applyContractSync, type SyncApplyContract } from "./contract-sync"; import { getFinanceSettings, setFinanceSettings } from "./settings"; -import { applyCategoryRules } from "./category-matching"; +import { applyCategoryRules, buildGroupedTrees } from "./category-matching"; import { registerDebtRoutes } from "./debt"; import { registerCalculationRoutes } from "./calculations"; import { registerAuditRoutes, startAuditScheduler } from "./audit"; @@ -239,14 +239,12 @@ function registerIncomeRoutes(app: Express) { }).filter((n) => n.total > 0 || (n.children && n.children.length > 0)); } - const trees = categories.map((tree) => ({ - id: tree.id, - name: tree.name, - isPrimary: tree.treeId === "primary", - total: (treeTotals.get(tree.id) || { total: 0 }).total, - count: (treeTotals.get(tree.id) || { count: 0 }).count, - nodes: buildTreeNodes(tree.children || []), - })); + const trees = buildGroupedTrees( + categories, + getIncomeAutoDetectConfig(), + treeTotals, + (roots) => buildTreeNodes(roots) + ); const response = { success: true, @@ -436,12 +434,12 @@ function registerExpenseRoutes(app: Express) { }).filter((n) => n.total > 0 || (n.children && n.children.length > 0)); } - const trees = categories.map((tree) => ({ - id: tree.id, name: tree.name, isPrimary: tree.treeId === "primary", - total: (treeTotals.get(tree.id) || { total: 0 }).total, - count: (treeTotals.get(tree.id) || { count: 0 }).count, - nodes: buildTreeNodes(tree.children || []), - })); + const trees = buildGroupedTrees( + categories, + getExpenseAutoDetectConfig(), + treeTotals, + (roots) => buildTreeNodes(roots) + ); const response = { success: true, trees, uncategorized, grandTotal: { total: grandTotal, count: rows.length } }; cache.set(cacheKey, response);