Финансы: деревья категорий как в Data-Insight2 — группировка авто/ручные, свёрнуто по умолчанию, полосы сумм, нераспределённые
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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<string> {
|
||||
// По умолчанию (как в 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<string>;
|
||||
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);
|
||||
}}
|
||||
>
|
||||
<span className="w-4 h-4 flex items-center justify-center shrink-0">
|
||||
{hasChildren ? (
|
||||
expanded ? (
|
||||
isExpanded ? (
|
||||
<ChevronDown className="w-3 h-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
@@ -52,7 +82,7 @@ function TreeNodeItem({
|
||||
{node.excluded ? (
|
||||
<EyeOff className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
) : hasChildren ? (
|
||||
expanded ? (
|
||||
isExpanded ? (
|
||||
<FolderOpen className="w-3.5 h-3.5 text-amber-500 shrink-0" />
|
||||
) : (
|
||||
<Folder className="w-3.5 h-3.5 text-amber-500 shrink-0" />
|
||||
@@ -65,9 +95,14 @@ function TreeNodeItem({
|
||||
{node.name}
|
||||
</span>
|
||||
|
||||
{node.excluded && (
|
||||
<span className="text-[9px] px-1 rounded border border-destructive/40 text-destructive/70 shrink-0">
|
||||
Искл.
|
||||
</span>
|
||||
)}
|
||||
{node.autoDetected && <Wand2 className="w-3 h-3 text-blue-400 shrink-0" />}
|
||||
|
||||
<span className="text-[11px] text-muted-foreground shrink-0">
|
||||
<span className="text-[11px] text-muted-foreground shrink-0 tabular-nums">
|
||||
{formatCurrency(node.total)}
|
||||
</span>
|
||||
{node.count > 0 && (
|
||||
@@ -77,7 +112,7 @@ function TreeNodeItem({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChildren && expanded && (
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children!.map((child) => (
|
||||
<TreeNodeItem
|
||||
@@ -86,6 +121,8 @@ function TreeNodeItem({
|
||||
depth={depth + 1}
|
||||
onSelect={onSelect}
|
||||
selectedId={selectedId}
|
||||
expandedIds={expandedIds}
|
||||
toggle={toggle}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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<Set<string>>(() => loadExpanded(storageKey));
|
||||
|
||||
const persist = useCallback(
|
||||
(next: Set<string>) => {
|
||||
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 <div className="text-xs text-muted-foreground py-2">Нет категорий</div>;
|
||||
}
|
||||
|
||||
const maxTotal = Math.max(...trees.map((t) => t.total || 0), uncategorized?.total || 0, 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{trees.map((tree) => (
|
||||
{trees.map((tree, idx) => (
|
||||
<div key={tree.id} className="rounded border bg-card p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold">{tree.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatCurrency(tree.total)} ({tree.count})
|
||||
<div className="flex items-center justify-between mb-1 gap-2">
|
||||
<span className="text-xs font-semibold truncate flex items-center gap-1.5">
|
||||
{tree.name}
|
||||
{!tree.isPrimary && (
|
||||
<span className="text-[9px] px-1 rounded bg-muted text-muted-foreground font-normal shrink-0">
|
||||
Отчет
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAll(tree.nodes, true);
|
||||
}}
|
||||
title="Развернуть все"
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
>
|
||||
<ChevronsUpDown className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setAll(tree.nodes, false);
|
||||
}}
|
||||
title="Свернуть все"
|
||||
className="p-0.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/60"
|
||||
>
|
||||
<ChevronsDownUp className="w-3 h-3" />
|
||||
</button>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatCurrency(tree.total)} ({tree.count})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden mb-2">
|
||||
<div
|
||||
className={cn("h-full rounded-full", BAR_COLORS[idx % BAR_COLORS.length])}
|
||||
style={{ width: `${Math.min(100, ((tree.total || 0) / maxTotal) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-0.5">
|
||||
{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) && (
|
||||
<div className="text-[10px] text-muted-foreground/60 py-1">Нет данных за период</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{uncategorized && uncategorized.count > 0 && (
|
||||
<div className="rounded border bg-card p-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-xs font-semibold text-muted-foreground">Нераспределённые</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{formatCurrency(uncategorized.total)} ({uncategorized.count})
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-muted-foreground/30"
|
||||
style={{ width: `${Math.min(100, ((uncategorized.total || 0) / maxTotal) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -165,6 +165,8 @@ export default function FinanceDashboard() {
|
||||
) : (
|
||||
<CategoryTree
|
||||
trees={incomeTree?.trees ?? []}
|
||||
uncategorized={incomeTree?.uncategorized}
|
||||
storageKey="omegaprom_finance_income_expanded"
|
||||
onSelect={(id) => {
|
||||
setActiveTab("income");
|
||||
// could filter by category here in future
|
||||
@@ -179,6 +181,8 @@ export default function FinanceDashboard() {
|
||||
) : (
|
||||
<CategoryTree
|
||||
trees={expenseTree?.trees ?? []}
|
||||
uncategorized={expenseTree?.uncategorized}
|
||||
storageKey="omegaprom_finance_expense_expanded"
|
||||
onSelect={(id) => {
|
||||
setActiveTab("expenses");
|
||||
}}
|
||||
|
||||
@@ -34,3 +34,58 @@ export function applyCategoryRules(row: Record<string, any>, categories: any[]):
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Сумма статистики категории со всеми потомками (статистика хранится по id глубочайшей совпавшей категории)
|
||||
function subtreeStats(
|
||||
cat: any,
|
||||
treeTotals: Map<string, { total: number; count: number }>
|
||||
): { 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<string, { total: number; count: number }>,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user