fix: show subcategory breakdown in a modal instead of expanding inline

Инлайн-раскрытие внутри строки при большом числе подкатегорий/сплитов
раздувало высоту строки и ломало вёрстку (донат на Dashboard и список
справа расходились по высоте). Теперь клик по шеврону открывает общее
модальное окно CategoryBreakdownModal со списком категорий-источников —
строка сама больше не меняет размер.
This commit is contained in:
ZProger
2026-08-30 19:13:13 +03:00
parent 140f58d33f
commit b4503a933f
4 changed files with 138 additions and 99 deletions

View File

@@ -0,0 +1,74 @@
import { Dialog } from "@/components/ui/Dialog";
import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
interface CategoryBreakdownChild {
category_id: number;
name: string;
color: string;
icon: string | null;
amount: string;
}
interface CategoryBreakdownModalProps {
open: boolean;
onClose: () => void;
categoryId: number;
categoryName: string;
totalAmount: string;
children: CategoryBreakdownChild[];
}
/** Shows how a parent category's total splits across its subcategories — a
* modal rather than an inline expansion, since a category with many
* subcategories (or many split-across purchases) would otherwise push the
* Dashboard's donut/list layout out of alignment as rows grow taller. Shared
* between the Dashboard breakdown and the Reports category ranking, which
* expose the same shape (see services/category_rollup.py's
* CategoryRollupChildItem). */
export function CategoryBreakdownModal({
open,
onClose,
categoryId,
categoryName,
totalAmount,
children,
}: CategoryBreakdownModalProps) {
const { t } = useTranslation();
const total = Number(totalAmount);
return (
<Dialog open={open} onClose={onClose} title={translateCategoryName(categoryName)}>
<div className="mb-3 flex items-center justify-between border-b border-gridline pb-3 text-sm">
<span className="text-text-muted">{t("reports.categoryBreakdownTotalLabel")}</span>
<span className="font-semibold tabular-nums text-text-primary">{formatCurrency(totalAmount)}</span>
</div>
<ul className="divide-y divide-gridline">
{children.map((child) => {
const Icon = getCategoryIcon(child.icon);
const percent = total ? (Number(child.amount) / total) * 100 : 0;
const label = child.category_id === categoryId ? t("reports.directSpendLabel") : translateCategoryName(child.name);
return (
<li key={child.category_id} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<span
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${child.color}26` }}
>
<Icon size={15} style={{ color: child.color }} />
</span>
<span className="min-w-0 flex-1 truncate text-sm text-text-primary">{label}</span>
<span className="w-9 shrink-0 text-right text-xs text-text-muted tabular-nums">
{percent.toFixed(0)}%
</span>
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
{formatCurrency(child.amount)}
</span>
</li>
);
})}
</ul>
</Dialog>
);
}

View File

@@ -1,12 +1,13 @@
import { useState } from "react"; import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CategoryBreakdownModal } from "@/components/categories/CategoryBreakdownModal";
import { getCategoryIcon } from "@/lib/icons"; import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency } from "@/lib/format"; import { formatCurrency } from "@/lib/format";
import { useTranslation, type TranslationKey } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels"; import { translateCategoryName } from "@/lib/categoryLabels";
import type { CategoryBreakdownChildItem, CategoryBreakdownItem } from "@/types"; import type { CategoryBreakdownItem } from "@/types";
interface SpendingByCategoryCardProps { interface SpendingByCategoryCardProps {
items: CategoryBreakdownItem[]; items: CategoryBreakdownItem[];
@@ -25,31 +26,18 @@ function DonutTooltip({ active, payload }: { active?: boolean; payload?: Array<{
); );
} }
// A child's category_id equals its parent's own — the enclosing
// CategoryBreakdownItem's — when that slice of spend was filed directly on
// the parent with no more specific subcategory chosen (see
// services/category_rollup.py's CategoryRollupChildItem).
function childLabel(child: CategoryBreakdownChildItem, parentId: number | null, t: (key: TranslationKey) => string) {
return child.category_id === parentId ? t("reports.directSpendLabel") : translateCategoryName(child.name);
}
export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) { export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<number>>(new Set()); // The breakdown lives in a modal, not expanded inline — a category with
// many subcategories (or many split purchases) would otherwise push this
// row taller than the fixed-height donut next to it, throwing the
// side-by-side layout out of alignment.
const [breakdownItem, setBreakdownItem] = useState<CategoryBreakdownItem | null>(null);
const hasData = items.length > 0; const hasData = items.length > 0;
// Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal // Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal
// is serialized as string to avoid float precision loss). // is serialized as string to avoid float precision loss).
const chartData = items.map((item) => ({ ...item, amount: Number(item.amount) })); const chartData = items.map((item) => ({ ...item, amount: Number(item.amount) }));
function toggle(categoryId: number) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(categoryId)) next.delete(categoryId);
else next.add(categoryId);
return next;
});
}
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -87,18 +75,8 @@ export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
{items.map((item) => { {items.map((item) => {
const Icon = getCategoryIcon(item.icon); const Icon = getCategoryIcon(item.icon);
const hasChildren = item.children.length > 0; const hasChildren = item.children.length > 0;
const isExpanded = item.category_id !== null && expanded.has(item.category_id);
return ( return (
<li key={item.category_id ?? "other"} className="py-2 first:pt-0 last:pb-0"> <li key={item.category_id ?? "other"} className="flex items-center gap-3 py-2 first:pt-0 last:pb-0">
<button
type="button"
disabled={!hasChildren}
onClick={() => item.category_id !== null && toggle(item.category_id)}
className="flex w-full items-center gap-3 text-left disabled:cursor-default"
>
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-text-muted">
{hasChildren ? isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} /> : null}
</span>
<span <span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full"
style={{ backgroundColor: `${item.color}26` }} style={{ backgroundColor: `${item.color}26` }}
@@ -111,22 +89,15 @@ export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
<span className="shrink-0 text-sm font-medium tabular-nums text-text-primary"> <span className="shrink-0 text-sm font-medium tabular-nums text-text-primary">
{formatCurrency(item.amount)} {formatCurrency(item.amount)}
</span> </span>
{hasChildren && (
<button
type="button"
aria-label={t("common.expand")}
onClick={() => setBreakdownItem(item)}
className="shrink-0 rounded-md p-1 text-text-muted hover:bg-surface-2 hover:text-text-primary"
>
<ChevronRight size={15} />
</button> </button>
{hasChildren && isExpanded && (
<ul className="ml-11 mt-1 space-y-1 border-l border-gridline pl-3">
{item.children.map((child) => (
<li key={child.category_id} className="flex items-center gap-2 text-xs">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} />
<span className="min-w-0 flex-1 truncate text-text-secondary">
{childLabel(child, item.category_id, t)}
</span>
<span className="shrink-0 tabular-nums text-text-secondary">
{formatCurrency(child.amount)}
</span>
</li>
))}
</ul>
)} )}
</li> </li>
); );
@@ -135,6 +106,17 @@ export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) {
</div> </div>
)} )}
</CardContent> </CardContent>
{breakdownItem && breakdownItem.category_id !== null && (
<CategoryBreakdownModal
open
onClose={() => setBreakdownItem(null)}
categoryId={breakdownItem.category_id}
categoryName={breakdownItem.name}
totalAmount={breakdownItem.amount}
children={breakdownItem.children}
/>
)}
</Card> </Card>
); );
} }

View File

@@ -1,11 +1,12 @@
import { useState } from "react"; import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { CategoryBreakdownModal } from "@/components/categories/CategoryBreakdownModal";
import { getCategoryIcon } from "@/lib/icons"; import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency } from "@/lib/format"; import { formatCurrency } from "@/lib/format";
import { useTranslation, type TranslationKey } from "@/lib/i18n"; import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels"; import { translateCategoryName } from "@/lib/categoryLabels";
import type { CategoryRankingChildItem, CategoryRankingItem } from "@/types"; import type { CategoryRankingItem } from "@/types";
interface CategoryRankingCardProps { interface CategoryRankingCardProps {
items: CategoryRankingItem[]; items: CategoryRankingItem[];
@@ -19,26 +20,12 @@ interface CategoryRankingCardProps {
* detail chart below (locked to one category), this is "which category * detail chart below (locked to one category), this is "which category
* costs the most" across the whole range at once. Clicking a row drills * costs the most" across the whole range at once. Clicking a row drills
* into that category in the detail chart/transaction list below. */ * into that category in the detail chart/transaction list below. */
// A child's category_id equals its parent's own — the enclosing
// CategoryRankingItem's — when that slice of spend was filed directly on
// the parent with no more specific subcategory chosen (see
// services/category_rollup.py's CategoryRollupChildItem).
function childLabel(child: CategoryRankingChildItem, parentId: number, t: (key: TranslationKey) => string) {
return child.category_id === parentId ? t("reports.directSpendLabel") : translateCategoryName(child.name);
}
export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSelectCategory }: CategoryRankingCardProps) { export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSelectCategory }: CategoryRankingCardProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [expanded, setExpanded] = useState<Set<number>>(new Set()); // The subcategory breakdown lives in a modal, not expanded inline — a
// category with many subcategories would otherwise push the whole ranking
function toggle(categoryId: number) { // list taller and shift every row below it.
setExpanded((prev) => { const [breakdownItem, setBreakdownItem] = useState<CategoryRankingItem | null>(null);
const next = new Set(prev);
if (next.has(categoryId)) next.delete(categoryId);
else next.add(categoryId);
return next;
});
}
return ( return (
<Card> <Card>
@@ -56,7 +43,6 @@ export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSe
const Icon = getCategoryIcon(item.icon); const Icon = getCategoryIcon(item.icon);
const isSelected = item.category_id === selectedCategoryId; const isSelected = item.category_id === selectedCategoryId;
const hasChildren = item.children.length > 0; const hasChildren = item.children.length > 0;
const isExpanded = expanded.has(item.category_id);
return ( return (
<li key={item.category_id}> <li key={item.category_id}>
<div <div
@@ -97,36 +83,31 @@ export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSe
{hasChildren && ( {hasChildren && (
<button <button
type="button" type="button"
aria-label={isExpanded ? t("common.collapse") : t("common.expand")} aria-label={t("common.expand")}
onClick={() => toggle(item.category_id)} onClick={() => setBreakdownItem(item)}
className="mr-1 shrink-0 rounded-md p-1.5 text-text-muted hover:text-text-primary" className="mr-1 shrink-0 rounded-md p-1.5 text-text-muted hover:text-text-primary"
> >
{isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />} <ChevronRight size={14} />
</button> </button>
)} )}
</div> </div>
{hasChildren && isExpanded && (
<ul className="ml-11 mb-2 mt-1 space-y-1 border-l border-gridline pl-3">
{item.children.map((child) => (
<li key={child.category_id} className="flex items-center gap-2 text-xs">
<span className="h-2 w-2 shrink-0 rounded-full" style={{ backgroundColor: child.color }} />
<span className="min-w-0 flex-1 truncate text-text-secondary">
{childLabel(child, item.category_id, t)}
</span>
<span className="shrink-0 tabular-nums text-text-secondary">
{formatCurrency(child.amount)}
</span>
</li>
))}
</ul>
)}
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
</CardContent> </CardContent>
{breakdownItem && (
<CategoryBreakdownModal
open
onClose={() => setBreakdownItem(null)}
categoryId={breakdownItem.category_id}
categoryName={breakdownItem.name}
totalAmount={breakdownItem.amount}
children={breakdownItem.children}
/>
)}
</Card> </Card>
); );
} }

View File

@@ -291,6 +291,7 @@ const ru = {
"transactions.form.splitDirectOption": "— без конкретной подкатегории —", "transactions.form.splitDirectOption": "— без конкретной подкатегории —",
"transactions.form.splitNoChildren": "У этой категории пока нет подкатегорий — добавьте их на странице «Категории», чтобы разбивать покупки по ним.", "transactions.form.splitNoChildren": "У этой категории пока нет подкатегорий — добавьте их на странице «Категории», чтобы разбивать покупки по ним.",
"reports.directSpendLabel": "Без подкатегории", "reports.directSpendLabel": "Без подкатегории",
"reports.categoryBreakdownTotalLabel": "Итого",
"reports.categoryLabel": "Категория", "reports.categoryLabel": "Категория",
"reports.expenseGroup": "Расходы", "reports.expenseGroup": "Расходы",
@@ -772,6 +773,7 @@ const en: Record<keyof typeof ru, string> = {
"transactions.form.splitDirectOption": "— no specific subcategory —", "transactions.form.splitDirectOption": "— no specific subcategory —",
"transactions.form.splitNoChildren": "This category has no subcategories yet — add some on the Categories page to split purchases across them.", "transactions.form.splitNoChildren": "This category has no subcategories yet — add some on the Categories page to split purchases across them.",
"reports.directSpendLabel": "No subcategory", "reports.directSpendLabel": "No subcategory",
"reports.categoryBreakdownTotalLabel": "Total",
"reports.categoryLabel": "Category", "reports.categoryLabel": "Category",
"reports.expenseGroup": "Expenses", "reports.expenseGroup": "Expenses",