Files
iistwin/client-di2/src/components/category-override-dialog.tsx
Ильяс Султанов 23ed1ecf3d DI2 внедрение, таск 2: клиентский субапп Data-Insight2 (client-di2)
- 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>)
2026-09-06 19:46:28 +03:00

483 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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