import { useEffect, useState } from "react"; import { Link, useSearchParams } from "react-router-dom"; import { FileUp, Plus, Search, X } from "lucide-react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card"; import { Button } from "@/components/ui/Button"; import { Input, Select } from "@/components/ui/Input"; import { MonthSelector } from "@/components/layout/MonthSelector"; import { YearSelector } from "@/components/layout/YearSelector"; import { TransactionsTable } from "@/components/transactions/TransactionsTable"; import { TransactionFormModal } from "@/components/transactions/TransactionFormModal"; import { useTransactions, useDeleteTransaction, useTransactionYears } from "@/hooks/useTransactions"; import { useCategories } from "@/hooks/useCategories"; import { useTags } from "@/hooks/useTags"; import type { TransactionSort } from "@/api/transactions"; import { useTranslation } from "@/lib/i18n"; import { buildHierarchicalCategories, translateCategoryName } from "@/lib/categoryLabels"; import type { Transaction, TransactionType } from "@/types"; const PAGE_SIZE = 20; /** Parses a query-param month, falling back to `fallback` for anything * missing or out of range (e.g. a hand-edited URL). Must check `value` * for null before `Number()` — `Number(null)` is 0, not NaN, so a missing * param would otherwise silently pass the integer check and clamp to 1. */ function parseMonthParam(value: string | null, fallback: number): number { if (value === null) return fallback; const parsed = Number(value); return Number.isInteger(parsed) && parsed >= 1 && parsed <= 12 ? parsed : fallback; } function parseYearParam(value: string | null, fallback: number): number { if (value === null) return fallback; const parsed = Number(value); return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; } export function TransactionsPage() { 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 // page doesn't reset back to the current month. const [searchParams] = useSearchParams(); const [year, setYear] = useState(() => parseYearParam(searchParams.get("year"), now.getFullYear())); const [month, setMonth] = useState(() => parseMonthParam(searchParams.get("month"), now.getMonth() + 1)); const [type, setType] = useState(""); const [categoryId, setCategoryId] = useState(""); const [tagId, setTagId] = useState(""); const [sort, setSort] = useState("date_desc"); const [page, setPage] = useState(1); // Raw text follows every keystroke; the debounced value is what actually // drives the query, so we're not refetching on every character typed. const [searchInput, setSearchInput] = useState(""); const [search, setSearch] = useState(""); useEffect(() => { const timeout = setTimeout(() => { setSearch(searchInput.trim()); setPage(1); }, 300); return () => clearTimeout(timeout); }, [searchInput]); const isSearching = search.length > 0; const [modalOpen, setModalOpen] = useState(false); const [editingTransaction, setEditingTransaction] = useState(null); const { data: categories } = useCategories(); const { data: tags } = useTags(); const { data: years } = useTransactionYears(); const { data, isLoading, isError } = useTransactions({ // A search looks for a purchase from an unknown month, so it must span // every period instead of being boxed into the currently selected one. year: isSearching ? undefined : year, month: isSearching ? undefined : month, search: isSearching ? search : undefined, type: type || undefined, category_id: categoryId ? Number(categoryId) : undefined, tag_id: tagId ? Number(tagId) : undefined, sort, page, page_size: PAGE_SIZE, }); 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); setModalOpen(true); } function openEditModal(transaction: Transaction) { setEditingTransaction(transaction); setModalOpen(true); } function handleDelete(transaction: Transaction) { if (window.confirm(t("transactions.confirmDelete", { description: transaction.description }))) { deleteTransaction.mutate(transaction.id); } } /** Leaves search mode and switches the month/year selectors to whichever * month the picked transaction is in, so the user lands back in the normal * browsing view with it in context instead of a flat result list. */ function handleJumpToMonth(transaction: Transaction) { const date = new Date(`${transaction.date}T00:00:00`); setYear(date.getFullYear()); setMonth(date.getMonth() + 1); setSearchInput(""); setSearch(""); setPage(1); } return (
{ setMonth(value); setPage(1); }} />
{ setYear(value); setPage(1); }} />
setSearchInput(event.target.value)} placeholder={t("transactions.searchPlaceholder")} className="pl-9 pr-9" /> {searchInput && ( )}
{isSearching &&

{t("transactions.searchAcrossAllTime")}

}
{tags && tags.length > 0 && ( )}
{t("nav.transactions")} {data && {t("common.totalCount", { count: data.total })}} {isError &&

{t("transactions.failedToLoad")}

} {isLoading ? (

{t("common.loading")}

) : ( )} {totalPages > 1 && (
{t("common.pageOf", { page, total: totalPages })}
)}
setModalOpen(false)} transaction={editingTransaction} />
); }