import { useState, useMemo, useRef, useEffect } from "react"; import { useQuery } from "@tanstack/react-query"; import { formatCurrency } from "@/lib/format"; import { ChevronDown, Check } from "lucide-react"; const MONTHS_RU = ["Янв", "Фев", "Мар", "Апр", "Май", "Июн", "Июл", "Авг", "Сен", "Окт", "Ноя", "Дек"]; function monthLabel(ym: string) { const [y, m] = ym.split("-"); return `${MONTHS_RU[parseInt(m, 10) - 1]} ${y}`; } function heatColor(value: number, max: number, type: "income" | "expense"): string { if (max === 0 || value === 0) return "hsl(0,0%,97%)"; const t = Math.pow(value / max, 0.5); if (type === "income") { const h = 217, s = 80; const l = Math.round(92 - t * 58); return `hsl(${h},${s}%,${l}%)`; } else { const h = 0, s = 75; const l = Math.round(92 - t * 58); return `hsl(${h},${s}%,${l}%)`; } } function textColor(value: number, max: number): string { if (max === 0 || value === 0) return "var(--muted-foreground)"; const t = Math.pow(value / max, 0.5); return t > 0.55 ? "#fff" : "#111"; } interface HeatCategory { id: string; name: string; treeId?: string; treeName?: string; monthly: Record; total: number; } interface HeatmapData { months: string[]; categories: HeatCategory[]; maxValue: number; totalByMonth: Record; } const currentYear = new Date().getFullYear(); function TreePicker({ trees, categories, selectedTreeIds, onChange, }: { trees: [string, string][]; categories: HeatCategory[]; selectedTreeIds: Set; onChange: (next: Set) => void; }) { const [open, setOpen] = useState(false); const ref = useRef(null); useEffect(() => { function handleClick(e: MouseEvent) { if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); } document.addEventListener("mousedown", handleClick); return () => document.removeEventListener("mousedown", handleClick); }, []); const allSelected = trees.every(([id]) => selectedTreeIds.has(id)); const noneSelected = trees.every(([id]) => !selectedTreeIds.has(id)); const selectedCount = trees.filter(([id]) => selectedTreeIds.has(id)).length; function toggle(id: string) { const next = new Set(selectedTreeIds); if (next.has(id)) next.delete(id); else next.add(id); onChange(next); } function selectAll() { onChange(new Set(trees.map(([id]) => id))); } function clearAll() { onChange(new Set()); } const catCountByTree = useMemo(() => { const m: Record = {}; for (const c of categories) { if (c.treeId) m[c.treeId] = (m[c.treeId] || 0) + 1; } return m; }, [categories]); const label = allSelected ? "Все деревья" : noneSelected ? "Деревья не выбраны" : `Деревья: ${selectedCount} из ${trees.length}`; return (
{open && (
{trees.map(([id, name]) => { const checked = selectedTreeIds.has(id); const count = catCountByTree[id] || 0; return ( ); })}
)}
); } export default function Heatmap() { const [type, setType] = useState<"income" | "expense">("income"); const [dateFrom, setDateFrom] = useState(`${currentYear}-01-01`); const [dateTo, setDateTo] = useState(`${currentYear}-12-31`); const [selectedTreeIds, setSelectedTreeIds] = useState | null>(null); const [tooltip, setTooltip] = useState<{ x: number; y: number; label: string; value: number } | null>(null); const [normMode, setNormMode] = useState<"global" | "row">("global"); const params = new URLSearchParams({ type, dateFrom, dateTo }); const { data, isLoading, isError } = useQuery({ queryKey: ["/api/heatmap", type, dateFrom, dateTo], queryFn: () => fetch(`/api/heatmap?${params}`).then(r => r.json()), }); const trees = useMemo(() => { if (!data) return []; const seen = new Map(); for (const c of data.categories) { if (c.treeId && !seen.has(c.treeId)) seen.set(c.treeId, c.treeName || c.treeId); } return Array.from(seen.entries()); }, [data]); const effectiveSelectedTrees = useMemo(() => { if (selectedTreeIds === null) return new Set(trees.map(([id]) => id)); return selectedTreeIds; }, [selectedTreeIds, trees]); useEffect(() => { setSelectedTreeIds(null); }, [type, data]); const filteredCategories = useMemo(() => { if (!data) return []; if (effectiveSelectedTrees.size === 0) return []; return data.categories.filter(c => c.treeId && effectiveSelectedTrees.has(c.treeId)); }, [data, effectiveSelectedTrees]); const maxValue = useMemo(() => { if (normMode === "global") return data?.maxValue || 0; return 0; }, [data, normMode]); function getMax(cat: HeatCategory) { if (normMode === "row") return Math.max(0, ...Object.values(cat.monthly)); return maxValue; } const months = data?.months || []; return (
setDateFrom(e.target.value)} className="text-xs px-2 py-1 border border-border rounded-md bg-background h-8" data-testid="input-heatmap-from" /> setDateTo(e.target.value)} className="text-xs px-2 py-1 border border-border rounded-md bg-background h-8" data-testid="input-heatmap-to" />
{trees.length > 0 && ( )}
{isLoading && (
Загрузка…
)} {isError && (
Ошибка загрузки данных
)} {data && !isLoading && filteredCategories.length === 0 && (
Нет данных для отображения
)} {data && !isLoading && filteredCategories.length > 0 && (
{months.map(m => ( ))} {filteredCategories.map(cat => { const rowMax = getMax(cat); return ( {months.map(m => { const v = cat.monthly[m] || 0; const bg = heatColor(v, rowMax, type); const fg = textColor(v, rowMax); return ( ); })} ); })} {months.map(m => { const v = filteredCategories.reduce((s, c) => s + (c.monthly[m] || 0), 0); return ( ); })}
Категория {monthLabel(m)} Итого
{cat.treeName && ( {cat.treeName} / )} {cat.name} setTooltip({ x: e.clientX, y: e.clientY, label: `${cat.name} · ${monthLabel(m)}`, value: v, })} onMouseLeave={() => setTooltip(null)} data-testid={`heatmap-cell-${cat.id}-${m}`} > {v > 0 ? formatCurrency(v) : "—"} {formatCurrency(cat.total)}
Итого {v > 0 ? formatCurrency(v) : "—"} {formatCurrency(filteredCategories.reduce((s, c) => s + c.total, 0))}
{tooltip && (
{tooltip.label}
{formatCurrency(tooltip.value)}
)}
Меньше
{[0, 0.15, 0.3, 0.5, 0.7, 1].map(t => (
))}
Больше
)}
); }