mirror of
https://github.com/Zproger/Aurum.git
synced 2026-10-13 02:56:39 +07:00
fix: show full category path (parent · subcategory) everywhere, group filters by hierarchy
Транзакция на подкатегории показывала только её имя — непонятно было, что это подкатегория и к какой родительской категории она относится. Фильтры по категории в Транзакциях и Отчётах тоже перечисляли подкатегории наравне с родительскими, без иерархии. - categoryLabels.ts: categoryPath() и buildHierarchicalCategories() — общая логика, вынесенная из TransactionFormModal. - Список транзакций и «Последние транзакции» на Dashboard показывают счёт, затем родителя, затем подкатегорию. - Фильтр категорий в Транзакциях сгруппирован по типу (как в Отчётах); оба списка показывают подкатегорию с отступом под родителем.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user