fix: show full category path (parent · subcategory) everywhere, group filters by hierarchy

Транзакция на подкатегории показывала только её имя — непонятно было,
что это подкатегория и к какой родительской категории она относится.
Фильтры по категории в Транзакциях и Отчётах тоже перечисляли
подкатегории наравне с родительскими, без иерархии.

- categoryLabels.ts: categoryPath() и buildHierarchicalCategories() —
  общая логика, вынесенная из TransactionFormModal.
- Список транзакций и «Последние транзакции» на Dashboard показывают
  счёт, затем родителя, затем подкатегорию.
- Фильтр категорий в Транзакциях сгруппирован по типу (как в Отчётах);
  оба списка показывают подкатегорию с отступом под родителем.
This commit is contained in:
ZProger
2026-08-30 19:30:49 +03:00
parent 87b37a10ff
commit dbae7938da
6 changed files with 111 additions and 28 deletions

View File

@@ -3,7 +3,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency, formatTransactionDate } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import { categoryPath, translateCategoryName } from "@/lib/categoryLabels";
import { useTransactions } from "@/hooks/useTransactions";
import { useCategories } from "@/hooks/useCategories";
interface RecentTransactionsCardProps {
year: number;
@@ -13,6 +15,7 @@ interface RecentTransactionsCardProps {
export function RecentTransactionsCard({ year, month }: RecentTransactionsCardProps) {
const { t } = useTranslation();
const { data, isLoading } = useTransactions({ year, month, page: 1, page_size: 6 });
const { data: categories } = useCategories();
return (
<Card>
@@ -33,10 +36,16 @@ export function RecentTransactionsCard({ year, month }: RecentTransactionsCardPr
) : (
<ul className="divide-y divide-gridline">
{data.items.map((tx) => {
const isSplit = tx.splits.length > 0;
const Icon = getCategoryIcon(tx.category?.icon);
const isTransfer = tx.type === "transfer";
const isExpense = tx.type === "expense";
const color = tx.category?.color ?? "var(--text-muted)";
const categoryLabel = isSplit
? tx.splits.map((split) => (split.category ? translateCategoryName(split.category.name) : "?")).join(" + ")
: tx.category
? categoryPath(tx.category, categories)
: null;
return (
<li key={tx.id} className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0">
<span
@@ -47,8 +56,9 @@ export function RecentTransactionsCard({ year, month }: RecentTransactionsCardPr
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-text-primary">{tx.description}</span>
<span className="block text-xs text-text-muted">
<span className="block truncate text-xs text-text-muted">
{formatTransactionDate(tx.date)} · {tx.account.name}
{categoryLabel ? ` · ${categoryLabel}` : ""}
</span>
</span>
<span

View File

@@ -8,9 +8,9 @@ import { useAccounts } from "@/hooks/useAccounts";
import { useCategories } from "@/hooks/useCategories";
import { useCreateTransaction, useUpdateTransaction } from "@/hooks/useTransactions";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
import { buildHierarchicalCategories, translateCategoryName } from "@/lib/categoryLabels";
import { formatCurrency } from "@/lib/format";
import type { Category, Tag, Transaction, TransactionInput, TransactionSplitInput, TransactionType } from "@/types";
import type { Tag, Transaction, TransactionInput, TransactionSplitInput, TransactionType } from "@/types";
interface TransactionFormModalProps {
open: boolean;
@@ -114,19 +114,13 @@ export function TransactionFormModal({ open, onClose, transaction }: Transaction
setError(null);
}, [open, transaction, accounts]);
const kindCategories = (categories ?? []).filter((category) =>
form.type === "income" ? category.kind === "income" : category.kind === "expense"
);
// Subcategories are listed right under their parent (not scattered by
// sort_order) so the hierarchy set up on the Categories page reads the
// same way here.
const kindCategories = (categories ?? [])
.filter((category) => (form.type === "income" ? category.kind === "income" : category.kind === "expense"))
.sort((a, b) => translateCategoryName(a.name).localeCompare(translateCategoryName(b.name), language));
const relevantCategories: (Category & { indented?: boolean })[] = [];
for (const parent of kindCategories.filter((category) => category.parent_id === null)) {
relevantCategories.push(parent);
for (const child of kindCategories.filter((category) => category.parent_id === parent.id)) {
relevantCategories.push({ ...child, indented: true });
}
}
// name) so the hierarchy set up on the Categories page reads the same way
// here.
const relevantCategories = buildHierarchicalCategories(kindCategories, language);
const isSaving = createTransaction.isPending || updateTransaction.isPending;

View File

@@ -4,7 +4,8 @@ import { Dialog } from "@/components/ui/Dialog";
import { getCategoryIcon } from "@/lib/icons";
import { formatCurrency, formatTransactionDate } from "@/lib/format";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
import { categoryPath, translateCategoryName } from "@/lib/categoryLabels";
import { useCategories } from "@/hooks/useCategories";
import type { Transaction } from "@/types";
interface TransactionsTableProps {
@@ -19,6 +20,7 @@ interface TransactionsTableProps {
export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: TransactionsTableProps) {
const { t } = useTranslation();
const { data: categories } = useCategories();
const [noteTransaction, setNoteTransaction] = useState<Transaction | null>(null);
if (items.length === 0) {
@@ -41,7 +43,7 @@ export function TransactionsTable({ items, onEdit, onDelete, onJumpToMonth }: Tr
const categoryLabel = isSplit
? tx.splits.map((split) => (split.category ? translateCategoryName(split.category.name) : "?")).join(" + ")
: tx.category
? translateCategoryName(tx.category.name)
? categoryPath(tx.category, categories)
: null;
return (

View File

@@ -29,3 +29,43 @@ export function translateCategoryName(name: string): string {
const key = DEFAULT_CATEGORY_KEYS[name];
return key ? t(key) : name;
}
/** A category's full path for display — "Groceries" alone reads as if it
* were a top-level category even when it's actually a subcategory; showing
* "Groceries · Sweets" makes the parent unambiguous everywhere a bare
* category name used to be shown (transaction rows, filters). `categories`
* is the full list (any kind, any order) to resolve the parent's own name
* from `category.parent_id` — subcategories are one level deep only, so a
* single lookup is enough. */
export function categoryPath(
category: { name: string; parent_id: number | null },
categories: { id: number; name: string }[] | undefined
): string {
if (category.parent_id == null) return translateCategoryName(category.name);
const parent = categories?.find((c) => c.id === category.parent_id);
if (!parent) return translateCategoryName(category.name);
return `${translateCategoryName(parent.name)} · ${translateCategoryName(category.name)}`;
}
/** Orders a category list for display in a dropdown: alphabetical by
* translated name, with each subcategory placed directly under its own
* parent (not scattered by name) and flagged `indented` so callers can
* prefix it visually (e.g. " ↳ "). Shared by every category picker
* (TransactionFormModal, the Transactions/Reports filters) so they all
* read the same way. */
export function buildHierarchicalCategories<C extends { id: number; parent_id: number | null; name: string }>(
categories: C[],
language: string
): (C & { indented: boolean })[] {
const sorted = [...categories].sort((a, b) =>
translateCategoryName(a.name).localeCompare(translateCategoryName(b.name), language)
);
const result: (C & { indented: boolean })[] = [];
for (const parent of sorted.filter((category) => category.parent_id === null)) {
result.push({ ...parent, indented: false });
for (const child of sorted.filter((category) => category.parent_id === parent.id)) {
result.push({ ...child, indented: true });
}
}
return result;
}

View File

@@ -14,13 +14,13 @@ import { useDeleteTransaction, useTransactions, useTransactionYears } from "@/ho
import type { TransactionSort } from "@/api/transactions";
import { computeRange, type CustomYearRange, type RangePreset } from "@/lib/dateRange";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
import { buildHierarchicalCategories, translateCategoryName } from "@/lib/categoryLabels";
import type { Transaction } from "@/types";
const PAGE_SIZE = 20;
export function ReportsPage() {
const { t } = useTranslation();
const { t, language } = useTranslation();
const now = new Date();
const RANGE_OPTIONS: Array<{ value: RangePreset; label: string }> = [
{ value: "all", label: t("reports.rangeAll") },
@@ -61,8 +61,17 @@ export function ReportsPage() {
});
const deleteTransaction = useDeleteTransaction();
const expenseCategories = categories?.filter((category) => category.kind === "expense") ?? [];
const incomeCategories = categories?.filter((category) => category.kind === "income") ?? [];
// Hierarchical within each group (a subcategory right under its own
// parent, indented) — a bare "Sweets" option next to top-level categories
// reads as if it were one itself.
const expenseCategories = buildHierarchicalCategories(
categories?.filter((category) => category.kind === "expense") ?? [],
language
);
const incomeCategories = buildHierarchicalCategories(
categories?.filter((category) => category.kind === "income") ?? [],
language
);
const totalPages = transactions ? Math.max(1, Math.ceil(transactions.total / PAGE_SIZE)) : 1;
function handleEdit(transaction: Transaction) {
@@ -93,6 +102,7 @@ export function ReportsPage() {
<optgroup label={t("reports.expenseGroup")}>
{expenseCategories.map((category) => (
<option key={category.id} value={category.id}>
{category.indented ? ` ↳ ` : ""}
{translateCategoryName(category.name)}
</option>
))}
@@ -102,6 +112,7 @@ export function ReportsPage() {
<optgroup label={t("reports.incomeGroup")}>
{incomeCategories.map((category) => (
<option key={category.id} value={category.id}>
{category.indented ? ` ↳ ` : ""}
{translateCategoryName(category.name)}
</option>
))}

View File

@@ -13,7 +13,7 @@ import { useCategories } from "@/hooks/useCategories";
import { useTags } from "@/hooks/useTags";
import type { TransactionSort } from "@/api/transactions";
import { useTranslation } from "@/lib/i18n";
import { translateCategoryName } from "@/lib/categoryLabels";
import { buildHierarchicalCategories, translateCategoryName } from "@/lib/categoryLabels";
import type { Transaction, TransactionType } from "@/types";
const PAGE_SIZE = 20;
@@ -35,7 +35,7 @@ function parseYearParam(value: string | null, fallback: number): number {
}
export function TransactionsPage() {
const { t } = useTranslation();
const { t, language } = useTranslation();
const now = new Date();
// Deep-linked from the Dashboard's "All transactions" link, which carries
// the month/year the user was already looking at (?year=&month=) so this
@@ -84,6 +84,17 @@ export function TransactionsPage() {
const deleteTransaction = useDeleteTransaction();
const totalPages = data ? Math.max(1, Math.ceil(data.total / PAGE_SIZE)) : 1;
// Grouped by kind and hierarchical within each group (a subcategory right
// under its own parent, indented) — a bare "Sweets" option next to
// top-level categories reads as if it were one itself.
const expenseCategoryOptions = buildHierarchicalCategories(
(categories ?? []).filter((category) => category.kind === "expense"),
language
);
const incomeCategoryOptions = buildHierarchicalCategories(
(categories ?? []).filter((category) => category.kind === "income"),
language
);
function openCreateModal() {
setEditingTransaction(null);
@@ -199,11 +210,26 @@ export function TransactionsPage() {
className="sm:w-56"
>
<option value="">{t("transactions.allCategories")}</option>
{categories?.map((category) => (
<option key={category.id} value={category.id}>
{translateCategoryName(category.name)}
</option>
))}
{expenseCategoryOptions.length > 0 && (
<optgroup label={t("reports.expenseGroup")}>
{expenseCategoryOptions.map((category) => (
<option key={category.id} value={category.id}>
{category.indented ? ` ↳ ` : ""}
{translateCategoryName(category.name)}
</option>
))}
</optgroup>
)}
{incomeCategoryOptions.length > 0 && (
<optgroup label={t("reports.incomeGroup")}>
{incomeCategoryOptions.map((category) => (
<option key={category.id} value={category.id}>
{category.indented ? ` ↳ ` : ""}
{translateCategoryName(category.name)}
</option>
))}
</optgroup>
)}
</Select>
{tags && tags.length > 0 && (
<Select