Финансы: деревья категорий как в Data-Insight2 — группировка авто/ручные, свёрнуто по умолчанию, полосы сумм, нераспределённые

This commit is contained in:
2026-09-06 09:53:45 +03:00
parent 2d137332da
commit a7a14d947a
6 changed files with 228 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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