From b4503a933f5efcbee00946f472aaeec57c41a1ac Mon Sep 17 00:00:00 2001 From: ZProger Date: Sun, 30 Aug 2026 19:13:13 +0300 Subject: [PATCH] fix: show subcategory breakdown in a modal instead of expanding inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Инлайн-раскрытие внутри строки при большом числе подкатегорий/сплитов раздувало высоту строки и ломало вёрстку (донат на Dashboard и список справа расходились по высоте). Теперь клик по шеврону открывает общее модальное окно CategoryBreakdownModal со списком категорий-источников — строка сама больше не меняет размер. --- .../categories/CategoryBreakdownModal.tsx | 74 ++++++++++++++ .../dashboard/SpendingByCategoryCard.tsx | 98 ++++++++----------- .../reports/CategoryRankingCard.tsx | 63 +++++------- frontend/src/lib/i18n.ts | 2 + 4 files changed, 138 insertions(+), 99 deletions(-) create mode 100644 frontend/src/components/categories/CategoryBreakdownModal.tsx diff --git a/frontend/src/components/categories/CategoryBreakdownModal.tsx b/frontend/src/components/categories/CategoryBreakdownModal.tsx new file mode 100644 index 0000000..4b24331 --- /dev/null +++ b/frontend/src/components/categories/CategoryBreakdownModal.tsx @@ -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 ( + +
+ {t("reports.categoryBreakdownTotalLabel")} + {formatCurrency(totalAmount)} +
+ +
+ ); +} diff --git a/frontend/src/components/dashboard/SpendingByCategoryCard.tsx b/frontend/src/components/dashboard/SpendingByCategoryCard.tsx index ceae726..d3370e9 100644 --- a/frontend/src/components/dashboard/SpendingByCategoryCard.tsx +++ b/frontend/src/components/dashboard/SpendingByCategoryCard.tsx @@ -1,12 +1,13 @@ import { useState } from "react"; -import { ChevronDown, ChevronRight } from "lucide-react"; +import { ChevronRight } from "lucide-react"; import { Cell, Pie, PieChart, ResponsiveContainer, Tooltip } from "recharts"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; +import { CategoryBreakdownModal } from "@/components/categories/CategoryBreakdownModal"; import { getCategoryIcon } from "@/lib/icons"; import { formatCurrency } from "@/lib/format"; -import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; -import type { CategoryBreakdownChildItem, CategoryBreakdownItem } from "@/types"; +import type { CategoryBreakdownItem } from "@/types"; interface SpendingByCategoryCardProps { 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) { const { t } = useTranslation(); - const [expanded, setExpanded] = useState>(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(null); const hasData = items.length > 0; // Recharts needs a numeric dataKey — API amounts arrive as strings (Decimal // is serialized as string to avoid float precision loss). 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 ( @@ -87,46 +75,29 @@ export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) { {items.map((item) => { const Icon = getCategoryIcon(item.icon); const hasChildren = item.children.length > 0; - const isExpanded = item.category_id !== null && expanded.has(item.category_id); return ( -
  • - - - {hasChildren && isExpanded && ( -
      - {item.children.map((child) => ( -
    • - - - {childLabel(child, item.category_id, t)} - - - {formatCurrency(child.amount)} - -
    • - ))} -
    + + )}
  • ); @@ -135,6 +106,17 @@ export function SpendingByCategoryCard({ items }: SpendingByCategoryCardProps) { )} + + {breakdownItem && breakdownItem.category_id !== null && ( + setBreakdownItem(null)} + categoryId={breakdownItem.category_id} + categoryName={breakdownItem.name} + totalAmount={breakdownItem.amount} + children={breakdownItem.children} + /> + )}
    ); } diff --git a/frontend/src/components/reports/CategoryRankingCard.tsx b/frontend/src/components/reports/CategoryRankingCard.tsx index 57eee15..b4c26ea 100644 --- a/frontend/src/components/reports/CategoryRankingCard.tsx +++ b/frontend/src/components/reports/CategoryRankingCard.tsx @@ -1,11 +1,12 @@ 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 { CategoryBreakdownModal } from "@/components/categories/CategoryBreakdownModal"; import { getCategoryIcon } from "@/lib/icons"; import { formatCurrency } from "@/lib/format"; -import { useTranslation, type TranslationKey } from "@/lib/i18n"; +import { useTranslation } from "@/lib/i18n"; import { translateCategoryName } from "@/lib/categoryLabels"; -import type { CategoryRankingChildItem, CategoryRankingItem } from "@/types"; +import type { CategoryRankingItem } from "@/types"; interface CategoryRankingCardProps { items: CategoryRankingItem[]; @@ -19,26 +20,12 @@ interface CategoryRankingCardProps { * detail chart below (locked to one category), this is "which category * costs the most" across the whole range at once. Clicking a row drills * 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) { const { t } = useTranslation(); - const [expanded, setExpanded] = useState>(new Set()); - - function toggle(categoryId: number) { - setExpanded((prev) => { - const next = new Set(prev); - if (next.has(categoryId)) next.delete(categoryId); - else next.add(categoryId); - return next; - }); - } + // The subcategory breakdown lives in a modal, not expanded inline — a + // category with many subcategories would otherwise push the whole ranking + // list taller and shift every row below it. + const [breakdownItem, setBreakdownItem] = useState(null); return ( @@ -56,7 +43,6 @@ export function CategoryRankingCard({ items, isLoading, selectedCategoryId, onSe const Icon = getCategoryIcon(item.icon); const isSelected = item.category_id === selectedCategoryId; const hasChildren = item.children.length > 0; - const isExpanded = expanded.has(item.category_id); return (
  • toggle(item.category_id)} + aria-label={t("common.expand")} + onClick={() => setBreakdownItem(item)} className="mr-1 shrink-0 rounded-md p-1.5 text-text-muted hover:text-text-primary" > - {isExpanded ? : } + )}
    - - {hasChildren && isExpanded && ( -
      - {item.children.map((child) => ( -
    • - - - {childLabel(child, item.category_id, t)} - - - {formatCurrency(child.amount)} - -
    • - ))} -
    - )}
  • ); })} )} + + {breakdownItem && ( + setBreakdownItem(null)} + categoryId={breakdownItem.category_id} + categoryName={breakdownItem.name} + totalAmount={breakdownItem.amount} + children={breakdownItem.children} + /> + )}
    ); } diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index c762d1a..e7f4118 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -291,6 +291,7 @@ const ru = { "transactions.form.splitDirectOption": "— без конкретной подкатегории —", "transactions.form.splitNoChildren": "У этой категории пока нет подкатегорий — добавьте их на странице «Категории», чтобы разбивать покупки по ним.", "reports.directSpendLabel": "Без подкатегории", + "reports.categoryBreakdownTotalLabel": "Итого", "reports.categoryLabel": "Категория", "reports.expenseGroup": "Расходы", @@ -772,6 +773,7 @@ const en: Record = { "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.", "reports.directSpendLabel": "No subcategory", + "reports.categoryBreakdownTotalLabel": "Total", "reports.categoryLabel": "Category", "reports.expenseGroup": "Expenses",