- client-di2/: копия фронтенда DI2 + shared/schema.ts, main.tsx обёрнут в wouter Router base=/di2, favicon относительный - client-di2/tailwind.config.ts (свои токены DI2) подключён через @config в index.css — иначе postcss взял бы корневой конфиг iistwin - vite.config.di2.ts: base /di2/, root client-di2, outDir dist/public-di2 - package.json: build/check через && включают сборку и typecheck di2 (build:di2, check:di2), новых зависимостей нет - Навигация финансов: ссылка «Data-Insight (полная версия)» → /di2 (target=_blank) в FinanceSidebar и FinanceBottomNav - Типовые фиксы предсуществующих ошибок DI2 (casts, ruleLogic в mutation-типах, Set<string>)
483 lines
17 KiB
TypeScript
483 lines
17 KiB
TypeScript
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<string> {
|
||
if (!groups) return new Set();
|
||
const result = new Set<string>();
|
||
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<string>; ancestors: Set<string>; catRootName: Map<string, string> } {
|
||
const matching = new Set<string>();
|
||
const ancestors = new Set<string>();
|
||
const catRootName = new Map<string, string>();
|
||
|
||
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<string>;
|
||
onToggle: (id: string) => void;
|
||
onSelect: (id: string) => void;
|
||
currentOverrideCategoryId?: string;
|
||
isPending: boolean;
|
||
searchActive: boolean;
|
||
matchingIds: Set<string>;
|
||
ancestorIds: Set<string>;
|
||
catRootName?: Map<string, string>;
|
||
}
|
||
|
||
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 = (
|
||
<>
|
||
<span className="truncate flex-1">{cat.name}</span>
|
||
{rootName && (
|
||
<span className="text-[10px] text-muted-foreground shrink-0 italic ml-1">{rootName}</span>
|
||
)}
|
||
{cat.autoDetected && !rootName && (
|
||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 shrink-0">авто</Badge>
|
||
)}
|
||
{isSelected && (
|
||
<Badge variant="default" className="text-[10px] px-1.5 py-0 h-4 shrink-0 ml-1">текущая</Badge>
|
||
)}
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<div
|
||
className={`w-full text-left px-3 py-1.5 text-sm flex items-center gap-1 transition-colors ${isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted/50"} ${isSearchAncestorOnly ? "opacity-60" : ""}`}
|
||
style={{ paddingLeft: `${12 + depth * 16}px` }}
|
||
>
|
||
{hasChildren ? (
|
||
<button
|
||
className="shrink-0 p-0.5 rounded hover:bg-muted"
|
||
onClick={() => onToggle(cat.id)}
|
||
data-testid={`toggle-${cat.id}`}
|
||
>
|
||
{isExpanded
|
||
? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||
: <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />}
|
||
</button>
|
||
) : (
|
||
<span className="w-4.5 shrink-0" />
|
||
)}
|
||
<button
|
||
className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
|
||
onClick={() => onSelect(cat.id)}
|
||
disabled={isPending}
|
||
data-testid={`cat-option-${cat.id}`}
|
||
>
|
||
{hasChildren
|
||
? (isExpanded ? <FolderOpen className="w-3.5 h-3.5 text-muted-foreground shrink-0" /> : <Folder className="w-3.5 h-3.5 text-muted-foreground shrink-0" />)
|
||
: <span className="w-3.5 shrink-0" />}
|
||
{label}
|
||
</button>
|
||
</div>
|
||
{hasChildren && isExpanded && (
|
||
<div>
|
||
{cat.children!.map(child => (
|
||
<TreeNode
|
||
key={child.id}
|
||
cat={child}
|
||
depth={depth + 1}
|
||
expanded={expanded}
|
||
onToggle={onToggle}
|
||
onSelect={onSelect}
|
||
currentOverrideCategoryId={currentOverrideCategoryId}
|
||
isPending={isPending}
|
||
searchActive={searchActive}
|
||
matchingIds={matchingIds}
|
||
ancestorIds={ancestorIds}
|
||
catRootName={catRootName}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface RootGroupRowProps {
|
||
group: RootGroup;
|
||
expanded: Set<string>;
|
||
onToggle: (id: string) => void;
|
||
onSelect: (id: string) => void;
|
||
currentOverrideCategoryId?: string;
|
||
isPending: boolean;
|
||
searchActive: boolean;
|
||
matchingIds: Set<string>;
|
||
ancestorIds: Set<string>;
|
||
catRootName: Map<string, string>;
|
||
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 (
|
||
<div>
|
||
<div
|
||
className={`flex items-center gap-1 px-3 py-1.5 cursor-pointer sticky top-0 z-10 border-b border-border/40 ${isContext ? "bg-primary/5 hover:bg-primary/10" : "bg-background hover:bg-muted/50"}`}
|
||
onClick={() => onToggle(group.id)}
|
||
data-testid={`root-group-${group.id}`}
|
||
>
|
||
<span className="shrink-0 p-0.5">
|
||
{isExpanded
|
||
? <ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||
: <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />}
|
||
</span>
|
||
{isExpanded
|
||
? <FolderOpen className={`w-3.5 h-3.5 shrink-0 ${isContext ? "text-primary" : "text-primary/70"}`} />
|
||
: <Folder className={`w-3.5 h-3.5 shrink-0 ${isContext ? "text-primary" : "text-primary/70"}`} />}
|
||
<span className={`text-sm font-medium ${isContext ? "text-primary" : "text-foreground"}`}>{group.name}</span>
|
||
{isContext && (
|
||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-4 ml-auto shrink-0 border-primary/50 text-primary">текущий</Badge>
|
||
)}
|
||
</div>
|
||
{(isExpanded || searchActive) && (
|
||
<div>
|
||
{group.categories.map(cat => (
|
||
<TreeNode
|
||
key={cat.id}
|
||
cat={cat}
|
||
depth={0}
|
||
expanded={expanded}
|
||
onToggle={onToggle}
|
||
onSelect={onSelect}
|
||
currentOverrideCategoryId={currentOverrideCategoryId}
|
||
isPending={isPending}
|
||
searchActive={searchActive}
|
||
matchingIds={matchingIds}
|
||
ancestorIds={ancestorIds}
|
||
catRootName={catRootName}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CategoryOverrideDialog({ open, onOpenChange, recordId, recordType, currentOverrideCategoryId, contextTreeId }: CategoryOverrideDialogProps) {
|
||
const [search, setSearch] = useState("");
|
||
const [expanded, setExpanded] = useState<Set<string>>(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<IncomeCategory[]>({
|
||
queryKey: [catEndpoint],
|
||
enabled: open,
|
||
});
|
||
|
||
const { data: autoDetectConfig, isLoading: configLoading } = useQuery<{ trees: AutoDetectTree[] }>({
|
||
queryKey: [configEndpoint],
|
||
enabled: open,
|
||
});
|
||
|
||
const isLoadingAny = catsLoading || configLoading;
|
||
|
||
const rootGroups = useMemo<RootGroup[]>(() => {
|
||
if (!categories) return [];
|
||
const trees = autoDetectConfig?.trees ?? [];
|
||
return buildRootGroups(categories, trees);
|
||
}, [categories, autoDetectConfig]);
|
||
|
||
const computeInitialExpanded = useCallback((groups: RootGroup[], overrideId?: string, ctxTreeId?: string): Set<string> => {
|
||
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<string>(), ancestors: new Set<string>(), catRootName: new Map<string, string>() };
|
||
}
|
||
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 (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col" data-testid="dialog-category-override">
|
||
<DialogHeader>
|
||
<DialogTitle className="text-base">Назначить категорию</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<div className="relative">
|
||
<Search className="absolute left-2.5 top-2.5 w-4 h-4 text-muted-foreground" />
|
||
<Input
|
||
placeholder="Поиск категории..."
|
||
value={search}
|
||
onChange={e => setSearch(e.target.value)}
|
||
className="pl-9 h-9 text-sm"
|
||
data-testid="input-category-search"
|
||
/>
|
||
{search && (
|
||
<button onClick={() => setSearch("")} className="absolute right-2.5 top-2.5 text-muted-foreground hover:text-foreground">
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{currentOverrideCategoryId && (
|
||
<div className="flex items-center gap-2">
|
||
<Badge variant="secondary" className="text-xs">
|
||
Назначена вручную
|
||
</Badge>
|
||
<Button
|
||
variant="ghost"
|
||
size="sm"
|
||
className="h-7 text-xs text-muted-foreground"
|
||
onClick={() => removeOverrideMutation.mutate()}
|
||
disabled={isPending}
|
||
data-testid="button-remove-override"
|
||
>
|
||
<RotateCcw className="w-3 h-3 mr-1" />
|
||
Сбросить
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex-1 overflow-y-auto min-h-0 max-h-[50vh] border border-border rounded-md">
|
||
{isLoadingAny || !hasAnyVisible ? (
|
||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||
{isLoadingAny ? "Загрузка..." : "Категории не найдены"}
|
||
</div>
|
||
) : (
|
||
<div className="py-1">
|
||
{rootGroups.map(group => (
|
||
<RootGroupRow
|
||
key={group.id}
|
||
group={group}
|
||
expanded={effectiveExpanded}
|
||
onToggle={onToggle}
|
||
onSelect={onSelect}
|
||
currentOverrideCategoryId={currentOverrideCategoryId}
|
||
isPending={isPending}
|
||
searchActive={searchActive}
|
||
matchingIds={matchingIds}
|
||
ancestorIds={searchAncestorIds}
|
||
catRootName={catRootName}
|
||
contextTreeId={contextTreeId}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|