import { useState, useMemo, useEffect, useCallback, useRef } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; import { ChevronRight, ChevronDown, Folder, FolderOpen, Search, X, RotateCcw } from "lucide-react"; import { queryClient } from "@/lib/queryClient"; import type { IncomeCategory } from "@shared/schema"; interface CategoryOverrideDialogProps { open: boolean; onOpenChange: (open: boolean) => void; recordId: string; recordType: "income" | "expense"; currentOverrideCategoryId?: string; contextTreeId?: string; } interface AutoDetectTree { id: string; name: string; isPrimary?: boolean; } interface RootGroup { id: string; name: string; categories: IncomeCategory[]; } function collectAncestorIds(cats: IncomeCategory[], targetId: string, path: string[] = []): string[] | null { for (const cat of cats) { if (cat.excluded) continue; if (cat.id === targetId) return path; if (cat.children && cat.children.length > 0) { const found = collectAncestorIds(cat.children, targetId, [...path, cat.id]); if (found) return found; } } return null; } function buildRootGroups( categories: IncomeCategory[], trees: AutoDetectTree[] ): RootGroup[] { const groups: RootGroup[] = []; const manual = categories.filter(c => !c.treeId); if (manual.length > 0) { groups.push({ id: "__manual__", name: "Ручные категории", categories: manual }); } for (const tree of trees) { if (tree.isPrimary === false) continue; const treeCats = categories.filter(c => c.treeId === tree.id); if (treeCats.length > 0) { groups.push({ id: tree.id, name: tree.name, categories: treeCats }); } } return groups; } function computeInitialExpandedForGroups(groups: RootGroup[], overrideId?: string, contextTreeId?: string): Set { if (!groups) return new Set(); const result = new Set(); if (overrideId) { for (const group of groups) { const path = collectAncestorIds(group.categories, overrideId); if (path !== null) { result.add(group.id); for (const id of path) result.add(id); break; } } } if (contextTreeId && !result.has(contextTreeId)) { const exists = groups.some(g => g.id === contextTreeId); if (exists) result.add(contextTreeId); } return result; } function collectMatchIdsForGroups( groups: RootGroup[], query: string ): { matching: Set; ancestors: Set; catRootName: Map } { const matching = new Set(); const ancestors = new Set(); const catRootName = new Map(); for (const group of groups) { function walk(cats: IncomeCategory[], parentIds: string[]): boolean { let anyMatch = false; for (const cat of cats) { if (cat.excluded) continue; const nameMatches = cat.name.toLowerCase().includes(query); let childMatch = false; if (cat.children && cat.children.length > 0) { childMatch = walk(cat.children, [...parentIds, cat.id]); } if (nameMatches || childMatch) { matching.add(cat.id); for (const pid of parentIds) ancestors.add(pid); if (nameMatches && !catRootName.has(cat.id)) catRootName.set(cat.id, group.name); anyMatch = true; } } return anyMatch; } walk(group.categories, []); } return { matching, ancestors, catRootName }; } function hasNonExcludedChildren(cat: IncomeCategory): boolean { return !!(cat.children && cat.children.some(c => !c.excluded)); } interface TreeNodeProps { cat: IncomeCategory; depth: number; expanded: Set; onToggle: (id: string) => void; onSelect: (id: string) => void; currentOverrideCategoryId?: string; isPending: boolean; searchActive: boolean; matchingIds: Set; ancestorIds: Set; catRootName?: Map; } function TreeNode({ cat, depth, expanded, onToggle, onSelect, currentOverrideCategoryId, isPending, searchActive, matchingIds, ancestorIds, catRootName }: TreeNodeProps) { if (cat.excluded) return null; if (searchActive && !matchingIds.has(cat.id) && !ancestorIds.has(cat.id)) return null; const hasChildren = hasNonExcludedChildren(cat); const isExpanded = expanded.has(cat.id); const isSelected = cat.id === currentOverrideCategoryId; const isSearchAncestorOnly = searchActive && ancestorIds.has(cat.id) && !matchingIds.has(cat.id); const rootName = searchActive && catRootName ? catRootName.get(cat.id) : undefined; const label = ( <> {cat.name} {rootName && ( {rootName} )} {cat.autoDetected && !rootName && ( авто )} {isSelected && ( текущая )} ); return (
{hasChildren ? ( ) : ( )}
{hasChildren && isExpanded && (
{cat.children!.map(child => ( ))}
)}
); } interface RootGroupRowProps { group: RootGroup; expanded: Set; onToggle: (id: string) => void; onSelect: (id: string) => void; currentOverrideCategoryId?: string; isPending: boolean; searchActive: boolean; matchingIds: Set; ancestorIds: Set; catRootName: Map; contextTreeId?: string; } function RootGroupRow({ group, expanded, onToggle, onSelect, currentOverrideCategoryId, isPending, searchActive, matchingIds, ancestorIds, catRootName, contextTreeId }: RootGroupRowProps) { const isExpanded = expanded.has(group.id); const isContext = group.id === contextTreeId; const hasVisibleChildren = searchActive ? group.categories.some(c => matchingIds.has(c.id) || ancestorIds.has(c.id)) : group.categories.some(c => !c.excluded); if (searchActive && !hasVisibleChildren) return null; return (
onToggle(group.id)} data-testid={`root-group-${group.id}`} > {isExpanded ? : } {isExpanded ? : } {group.name} {isContext && ( текущий )}
{(isExpanded || searchActive) && (
{group.categories.map(cat => ( ))}
)}
); } export function CategoryOverrideDialog({ open, onOpenChange, recordId, recordType, currentOverrideCategoryId, contextTreeId }: CategoryOverrideDialogProps) { const [search, setSearch] = useState(""); const [expanded, setExpanded] = useState>(new Set()); const catEndpoint = recordType === "income" ? "/api/income-categories" : "/api/expense-categories"; const configEndpoint = recordType === "income" ? "/api/income-categories/auto-detect/config" : "/api/expense-categories/auto-detect/config"; const { data: categories, isLoading: catsLoading } = useQuery({ queryKey: [catEndpoint], enabled: open, }); const { data: autoDetectConfig, isLoading: configLoading } = useQuery<{ trees: AutoDetectTree[] }>({ queryKey: [configEndpoint], enabled: open, }); const isLoadingAny = catsLoading || configLoading; const rootGroups = useMemo(() => { if (!categories) return []; const trees = autoDetectConfig?.trees ?? []; return buildRootGroups(categories, trees); }, [categories, autoDetectConfig]); const computeInitialExpanded = useCallback((groups: RootGroup[], overrideId?: string, ctxTreeId?: string): Set => { return computeInitialExpandedForGroups(groups, overrideId, ctxTreeId); }, []); useEffect(() => { if (open && rootGroups.length > 0) { setExpanded(computeInitialExpanded(rootGroups, currentOverrideCategoryId, contextTreeId)); } if (!open) { setSearch(""); setExpanded(new Set()); } }, [open, rootGroups, currentOverrideCategoryId, contextTreeId, computeInitialExpanded]); const prevSearchActiveRef = useRef(false); useEffect(() => { const wasActive = prevSearchActiveRef.current; const isActive = search.trim().length > 0; prevSearchActiveRef.current = isActive; if (wasActive && !isActive) { setExpanded(computeInitialExpanded(rootGroups, currentOverrideCategoryId, contextTreeId)); } }, [search, rootGroups, currentOverrideCategoryId, contextTreeId, computeInitialExpanded]); const { matching: matchingIds, ancestors: searchAncestorIds, catRootName } = useMemo(() => { const q = search.trim().toLowerCase(); if (!q || rootGroups.length === 0) { return { matching: new Set(), ancestors: new Set(), catRootName: new Map() }; } return collectMatchIdsForGroups(rootGroups, q); }, [rootGroups, search]); const searchActive = search.trim().length > 0; const effectiveExpanded = useMemo(() => { if (!searchActive) return expanded; const allGroupIds = new Set(rootGroups.map(g => g.id)); return new Set([...allGroupIds, ...searchAncestorIds, ...matchingIds]); }, [searchActive, expanded, searchAncestorIds, matchingIds, rootGroups]); const hasAnyVisible = useMemo(() => { if (rootGroups.length === 0) return false; if (!searchActive) return true; return matchingIds.size > 0 || searchAncestorIds.size > 0; }, [rootGroups, searchActive, matchingIds, searchAncestorIds]); const onToggle = useCallback((id: string) => { setExpanded(prev => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); }, []); const setOverrideMutation = useMutation({ mutationFn: async (categoryId: string) => { const res = await fetch("/api/overrides", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ type: recordType, recordId, categoryId }), }); if (!res.ok) throw new Error("Failed"); return res.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/overrides"] }); queryClient.invalidateQueries({ predicate: (q) => { const key = q.queryKey[0]; if (typeof key !== "string") return false; return key.includes("tree-summary") || key.includes("/api/income") || key.includes("/api/expenses"); }}); onOpenChange(false); }, }); const removeOverrideMutation = useMutation({ mutationFn: async () => { const res = await fetch(`/api/overrides/${recordType}/${encodeURIComponent(recordId)}`, { method: "DELETE" }); if (!res.ok) throw new Error("Failed"); return res.json(); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["/api/overrides"] }); queryClient.invalidateQueries({ predicate: (q) => { const key = q.queryKey[0]; if (typeof key !== "string") return false; return key.includes("tree-summary") || key.includes("/api/income") || key.includes("/api/expenses"); }}); onOpenChange(false); }, }); const isPending = setOverrideMutation.isPending || removeOverrideMutation.isPending; const onSelect = useCallback((catId: string) => { setOverrideMutation.mutate(catId); }, [setOverrideMutation]); return ( Назначить категорию
setSearch(e.target.value)} className="pl-9 h-9 text-sm" data-testid="input-category-search" /> {search && ( )}
{currentOverrideCategoryId && (
Назначена вручную
)}
{isLoadingAny || !hasAnyVisible ? (
{isLoadingAny ? "Загрузка..." : "Категории не найдены"}
) : (
{rootGroups.map(group => ( ))}
)}
); }