- recharts: все hex/hsl палитры → var(--chart-1..5), семантика → var(--info/success/warning/destructive) - палитровые Tailwind-классы (red/blue/green/amber и т.д.) → семантические классы через color-mix (Tailwind v3 не генерирует alpha для var-цветов) - ui/table.tsx: шапка по гайду (bg-muted/50, text-[12px]), строки h-[var(--table-row-height,40px)], ячейки px-3, hover muted 40% - lib/field-styles.ts (порт из основного клиента) + fieldControlClass в column-filter - app-sidebar: брендинг «ОмегаПром / Финансы», компактные отступы - bottom-nav.tsx: мобильная нижняя панель (Обзор/Разделы/Настройки) по референсу FinanceBottomNav, safe-area-bottom в index.css - переключатель темы Sun/Moon в хедере (useDi2Theme), SidebarTrigger скрыт на мобильном - заголовки страниц text-lg font-semibold
385 lines
16 KiB
TypeScript
385 lines
16 KiB
TypeScript
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<string, number>;
|
||
total: number;
|
||
}
|
||
|
||
interface HeatmapData {
|
||
months: string[];
|
||
categories: HeatCategory[];
|
||
maxValue: number;
|
||
totalByMonth: Record<string, number>;
|
||
}
|
||
|
||
const currentYear = new Date().getFullYear();
|
||
|
||
function TreePicker({
|
||
trees,
|
||
categories,
|
||
selectedTreeIds,
|
||
onChange,
|
||
}: {
|
||
trees: [string, string][];
|
||
categories: HeatCategory[];
|
||
selectedTreeIds: Set<string>;
|
||
onChange: (next: Set<string>) => void;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const ref = useRef<HTMLDivElement>(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<string, number> = {};
|
||
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 (
|
||
<div className="relative" ref={ref}>
|
||
<button
|
||
onClick={() => setOpen(v => !v)}
|
||
className="flex items-center gap-1.5 px-2 py-1 h-8 text-xs border border-border rounded-md bg-background hover:bg-muted transition-colors min-w-[160px] max-w-[240px]"
|
||
data-testid="btn-tree-picker"
|
||
>
|
||
<span className="flex-1 text-left truncate">{label}</span>
|
||
<ChevronDown className={`w-3.5 h-3.5 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
|
||
</button>
|
||
|
||
{open && (
|
||
<div className="absolute left-0 top-full mt-1 z-50 bg-popover border border-border rounded-lg shadow-xl w-64 flex flex-col overflow-hidden">
|
||
<div className="flex gap-1 px-2 py-1.5 border-b border-border">
|
||
<button
|
||
onClick={selectAll}
|
||
className="flex-1 text-xs py-0.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||
data-testid="btn-select-all-trees"
|
||
>
|
||
Все
|
||
</button>
|
||
<button
|
||
onClick={clearAll}
|
||
className="flex-1 text-xs py-0.5 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||
data-testid="btn-clear-all-trees"
|
||
>
|
||
Сбросить
|
||
</button>
|
||
</div>
|
||
<div className="overflow-y-auto max-h-72">
|
||
{trees.map(([id, name]) => {
|
||
const checked = selectedTreeIds.has(id);
|
||
const count = catCountByTree[id] || 0;
|
||
return (
|
||
<button
|
||
key={id}
|
||
onClick={() => toggle(id)}
|
||
className="flex items-center gap-2 w-full px-3 py-2 text-xs hover:bg-muted transition-colors text-left"
|
||
data-testid={`tree-checkbox-${id}`}
|
||
>
|
||
<span className={`w-4 h-4 shrink-0 rounded border flex items-center justify-center transition-colors ${checked ? "bg-primary border-primary" : "border-border bg-background"}`}>
|
||
{checked && <Check className="w-2.5 h-2.5 text-primary-foreground" />}
|
||
</span>
|
||
<span className="truncate flex-1 font-medium" title={name}>{name}</span>
|
||
<span className="text-muted-foreground shrink-0">{count}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<Set<string> | 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<HeatmapData>({
|
||
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<string, string>();
|
||
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 (
|
||
<div className="flex flex-col h-full overflow-hidden">
|
||
<div className="flex items-center justify-between px-4 py-3 border-b border-border shrink-0 gap-3 flex-wrap">
|
||
<div className="flex items-center gap-1.5">
|
||
<button
|
||
onClick={() => setType("income")}
|
||
className={`px-3 py-1.5 text-sm rounded-md font-medium transition-colors ${type === "income" ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
|
||
data-testid="btn-heatmap-income"
|
||
>
|
||
Доходы
|
||
</button>
|
||
<button
|
||
onClick={() => setType("expense")}
|
||
className={`px-3 py-1.5 text-sm rounded-md font-medium transition-colors ${type === "expense" ? "bg-destructive text-destructive-foreground" : "bg-muted text-muted-foreground hover:text-foreground"}`}
|
||
data-testid="btn-heatmap-expense"
|
||
>
|
||
Расходы
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
<label className="text-xs text-muted-foreground">С</label>
|
||
<input type="date" value={dateFrom} onChange={e => 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" />
|
||
<label className="text-xs text-muted-foreground">По</label>
|
||
<input type="date" value={dateTo} onChange={e => 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" />
|
||
</div>
|
||
|
||
<div className="flex items-center gap-2 flex-wrap">
|
||
{trees.length > 0 && (
|
||
<TreePicker
|
||
trees={trees}
|
||
categories={data?.categories || []}
|
||
selectedTreeIds={effectiveSelectedTrees}
|
||
onChange={setSelectedTreeIds}
|
||
/>
|
||
)}
|
||
|
||
<div className="flex items-center gap-1 text-xs border border-border rounded-md overflow-hidden">
|
||
<button
|
||
onClick={() => setNormMode("global")}
|
||
className={`px-2 py-1 h-8 transition-colors ${normMode === "global" ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"}`}
|
||
data-testid="btn-norm-global"
|
||
title="Единая шкала для всех строк"
|
||
>Глобально</button>
|
||
<button
|
||
onClick={() => setNormMode("row")}
|
||
className={`px-2 py-1 h-8 transition-colors ${normMode === "row" ? "bg-muted font-medium" : "text-muted-foreground hover:text-foreground"}`}
|
||
data-testid="btn-norm-row"
|
||
title="Шкала отдельная для каждой строки"
|
||
>По строке</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-auto p-2">
|
||
{isLoading && (
|
||
<div className="flex items-center justify-center h-40 text-muted-foreground text-sm">Загрузка…</div>
|
||
)}
|
||
{isError && (
|
||
<div className="flex items-center justify-center h-40 text-destructive text-sm">Ошибка загрузки данных</div>
|
||
)}
|
||
{data && !isLoading && filteredCategories.length === 0 && (
|
||
<div className="flex items-center justify-center h-40 text-muted-foreground text-sm">Нет данных для отображения</div>
|
||
)}
|
||
|
||
{data && !isLoading && filteredCategories.length > 0 && (
|
||
<div className="relative">
|
||
<table className="text-xs border-separate border-spacing-0.5 min-w-max">
|
||
<thead>
|
||
<tr>
|
||
<th className="text-left px-2 py-1.5 bg-[color-mix(in_oklab,var(--muted)_50%,transparent)] rounded text-muted-foreground font-medium sticky left-0 z-10 min-w-[160px] max-w-[220px]">
|
||
Категория
|
||
</th>
|
||
{months.map(m => (
|
||
<th key={m} className="px-1 py-1.5 bg-[color-mix(in_oklab,var(--muted)_50%,transparent)] rounded text-muted-foreground font-medium text-center min-w-[80px] whitespace-nowrap">
|
||
{monthLabel(m)}
|
||
</th>
|
||
))}
|
||
<th className="px-2 py-1.5 bg-[color-mix(in_oklab,var(--muted)_60%,transparent)] rounded text-muted-foreground font-medium text-right min-w-[90px] whitespace-nowrap sticky right-0 z-10">
|
||
Итого
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{filteredCategories.map(cat => {
|
||
const rowMax = getMax(cat);
|
||
return (
|
||
<tr key={cat.id}>
|
||
<td
|
||
className="px-2 py-1 bg-[color-mix(in_oklab,var(--muted)_30%,transparent)] rounded font-medium sticky left-0 z-10 truncate max-w-[220px]"
|
||
title={`${cat.treeName ? cat.treeName + " / " : ""}${cat.name}`}
|
||
data-testid={`heatmap-row-${cat.id}`}
|
||
>
|
||
{cat.treeName && (
|
||
<span className="text-muted-foreground mr-1">{cat.treeName} /</span>
|
||
)}
|
||
{cat.name}
|
||
</td>
|
||
{months.map(m => {
|
||
const v = cat.monthly[m] || 0;
|
||
const bg = heatColor(v, rowMax, type);
|
||
const fg = textColor(v, rowMax);
|
||
return (
|
||
<td
|
||
key={m}
|
||
className="px-1 py-1 text-center rounded cursor-default transition-opacity hover:opacity-80 font-mono"
|
||
style={{ backgroundColor: bg, color: fg }}
|
||
onMouseEnter={e => 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) : "—"}
|
||
</td>
|
||
);
|
||
})}
|
||
<td
|
||
className="px-2 py-1 text-right rounded font-semibold bg-[color-mix(in_oklab,var(--muted)_30%,transparent)] sticky right-0 z-10 font-mono"
|
||
style={{ color: type === "income" ? "hsl(217,80%,35%)" : "hsl(0,75%,35%)" }}
|
||
>
|
||
{formatCurrency(cat.total)}
|
||
</td>
|
||
</tr>
|
||
);
|
||
})}
|
||
|
||
<tr>
|
||
<td className="px-2 py-1.5 bg-[color-mix(in_oklab,var(--muted)_50%,transparent)] rounded font-semibold sticky left-0 z-10 text-muted-foreground">
|
||
Итого
|
||
</td>
|
||
{months.map(m => {
|
||
const v = filteredCategories.reduce((s, c) => s + (c.monthly[m] || 0), 0);
|
||
return (
|
||
<td key={m} className="px-1 py-1.5 text-center rounded bg-[color-mix(in_oklab,var(--muted)_40%,transparent)] font-semibold font-mono">
|
||
{v > 0 ? formatCurrency(v) : "—"}
|
||
</td>
|
||
);
|
||
})}
|
||
<td className="px-2 py-1.5 text-right rounded font-bold bg-[color-mix(in_oklab,var(--muted)_50%,transparent)] sticky right-0 z-10 font-mono">
|
||
{formatCurrency(filteredCategories.reduce((s, c) => s + c.total, 0))}
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
|
||
{tooltip && (
|
||
<div
|
||
className="fixed z-50 pointer-events-none bg-popover text-popover-foreground border border-border rounded-lg shadow-lg px-3 py-2 text-xs max-w-[220px]"
|
||
style={{ left: tooltip.x + 12, top: tooltip.y - 36 }}
|
||
>
|
||
<div className="font-medium mb-0.5 truncate">{tooltip.label}</div>
|
||
<div className="font-semibold text-sm">{formatCurrency(tooltip.value)}</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
|
||
<span>Меньше</span>
|
||
<div className="flex gap-0.5">
|
||
{[0, 0.15, 0.3, 0.5, 0.7, 1].map(t => (
|
||
<div
|
||
key={t}
|
||
className="w-5 h-4 rounded-sm"
|
||
style={{ backgroundColor: heatColor(t * (data.maxValue || 1), data.maxValue || 1, type) }}
|
||
/>
|
||
))}
|
||
</div>
|
||
<span>Больше</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|