Files
iistwin/client-di2/src/pages/heatmap.tsx
Ильяс Султанов 968010c411 DI2: визуальный рестайл — графики и цвета по токенам скина, таблицы по UI Style Guide, мобильная нижняя панель, переключатель темы
- 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
2026-09-07 00:04:49 +03:00

385 lines
16 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, 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>
);
}