@@ -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 ? (
e xpanded ? (
isE xpanded ? (
< 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 ? (
e xpanded ? (
isE xpanded ? (
< 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 && e xpanded && (
{ hasChildren && isE xpanded && (
< 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 >
) ;
}