import { useEffect, useMemo, useState } from "react"; import { format } from "date-fns"; import { ArrowDown, ArrowUp, ArrowUpDown, Plus, SearchX, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card, CardContent } from "@/components/ui/card"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Pagination, PaginationContent, PaginationItem, PaginationLink, PaginationNext, PaginationPrevious } from "@/components/ui/pagination"; import { CategoryBadge, MerchantAvatar } from "@/components/elements/CategoryIcon"; import { CategoryPicker } from "@/components/elements/CategoryPicker"; import { TagFilterPicker } from "@/components/elements/TagFilterPicker"; import { SearchInput } from "@/components/elements/SearchInput"; import { EmptyState } from "@/components/elements/EmptyState"; import { TransactionDetail } from "@/components/TransactionDetail"; import { TransferBadge } from "@/components/transactions/TransferBadge"; import { DuplicateBadge } from "@/components/transactions/DuplicateBadge"; import { SplitBadge } from "@/components/transactions/SplitBadge"; import { AddTransactionDialog } from "@/components/transactions/AddTransactionDialog"; import { StatCard } from "@/components/dashboard/StatCard"; import { useFinanceData } from "@/context/FinanceDataContext"; import { useMoney } from "@/hooks/useMoney"; import { useElementHeight } from "@/hooks/useElementHeight"; import { useTransactionSearch } from "@/hooks/useTransactionSearch"; import { useMerchantRefinements } from "@/hooks/useMerchantRefinements"; import { useSuggestedTransfers, useTransferAction } from "@/hooks/useTransfers"; import { useSuggestedDuplicates, useDuplicateAction } from "@/hooks/useDuplicates"; import { useTags } from "@/hooks/useTags"; import { dayLabel, withDayHeaders, type DayHeaderRow } from "@/lib/dayGroups"; import { refinedMerchantLabel } from "@/lib/merchant"; import { isAssetAccount, canHoldTransactions } from "@/lib/accounts"; import { CATEGORIES } from "@/lib/categories"; import { cn, parseLocalDate } from "@/lib/utils"; import { toast } from "sonner"; import type { Account, Transaction } from "@/types/finance"; /** Same choices, default and remembered value as the main Transactions table. */ const PAGE_SIZES = [10, 20, 50, 100] as const; const DEFAULT_PAGE_SIZE = 20; const PAGE_SIZE_KEY = "txn-page-size"; /** Height of the app's sticky top bar (Layout's h-12); the filter bar sticks below it. */ const APP_HEADER = 48; type SortKey = "date" | "description" | "amount"; const isDayHeader = (r: Transaction | DayHeaderRow): r is DayHeaderRow => "isDayHeader" in r; function loadPageSize(): number { try { const n = Number(localStorage.getItem(PAGE_SIZE_KEY)); return (PAGE_SIZES as readonly number[]).includes(n) ? n : DEFAULT_PAGE_SIZE; } catch { return DEFAULT_PAGE_SIZE; } } /** Which tags an asset last showed, so opening the Honda page again is still the Honda view. */ const tagMemoryKey = (accountId: string) => `account-tags:${accountId}`; function loadTags(accountId: string): string[] { try { const raw = localStorage.getItem(tagMemoryKey(accountId)); const parsed = raw ? JSON.parse(raw) : []; return Array.isArray(parsed) ? parsed.filter((v) => typeof v === "string") : []; } catch { return []; } } const fmtDate = (d: string) => { try { return format(parseLocalDate(d), "MMM d, yyyy"); } catch { return d; } }; /** * The transaction table for one account, built like the Transactions page: * a filter bar, a column header and per-day headers that all stick as you * scroll, server-side paging with a rows-per-page choice, and a detail panel * that opens right here instead of navigating away. * * Cars, homes, loans and cash rarely have transactions of their own; what * belongs to them (insurance, repairs, repayments) sits in your bank accounts. * So on those accounts, picking a tag shows everything carrying that tag from * every account, with a switch back to only this account's own rows. */ export function AccountTransactions({ account }: { account: Account }) { const { updateTransactions, addTransaction } = useFinanceData(); const { moneyAbs: fmtMoney } = useMoney(account.currency); const { data: refinements } = useMerchantRefinements(); const { data: tags = [] } = useTags(); const { byTxnId: suggestedTransfers } = useSuggestedTransfers(); const { byTxnId: suggestedDuplicates } = useSuggestedDuplicates(); const assetLike = isAssetAccount(account.type); const [search, setSearch] = useState(""); const [category, setCategory] = useState("all"); const [type, setType] = useState("all"); const [tagIds, setTagIdsState] = useState(() => loadTags(account.id)); const [tagScope, setTagScope] = useState<"all" | "account">("all"); const [sortKey, setSortKey] = useState("date"); const [sortDir, setSortDir] = useState<"asc" | "desc">("desc"); const [page, setPage] = useState(1); const [pageSize, setPageSizeState] = useState(loadPageSize); const [detailTxn, setDetailTxn] = useState(null); const [addOpen, setAddOpen] = useState(false); const [filterBarRef, filterBarHeight] = useElementHeight(); const [columnHeaderRef, columnHeaderHeight] = useElementHeight(); // A tag that has since been deleted must not leave the table empty behind an invisible filter. useEffect(() => { if (tagIds.length > 0 && tags.length > 0) { const stillExists = tagIds.filter((id) => tags.some((t) => t.id === id)); if (stillExists.length !== tagIds.length) setTagIdsState(stillExists); } }, [tags, tagIds]); const setTags = (ids: string[]) => { setTagIdsState(ids); setPage(1); try { localStorage.setItem(tagMemoryKey(account.id), JSON.stringify(ids)); } catch { /* not remembered in private mode */ } }; const setPageSize = (n: number) => { setPageSizeState(n); setPage(1); try { localStorage.setItem(PAGE_SIZE_KEY, String(n)); } catch { /* ditto */ } }; // Asset-like accounts follow a tag across every account; bank accounts keep the account. const acrossAccounts = assetLike && tagIds.length > 0 && tagScope === "all"; const filters = { search, category, type, tagIds, accountId: acrossAccounts ? "all" : account.id, sort: sortKey, direction: sortDir, }; const { rows, filteredCount, income, expenses, loading, error, refresh } = useTransactionSearch(filters, { pageSize, page }); const { confirm: confirmTransfer, reject: rejectTransfer } = useTransferAction(refresh); const { merge: mergeDuplicate, dismiss: dismissDuplicate } = useDuplicateAction(refresh); const activeTags = tags.filter((t) => tagIds.includes(t.id)); const filtersActive = search !== "" || category !== "all" || type !== "all" || tagIds.length > 0; const clearFilters = () => { setSearch(""); setCategory("all"); setType("all"); setTags([]); setPage(1); }; const totalPages = Math.max(1, Math.ceil(filteredCount / pageSize)); useEffect(() => { if (page > totalPages) setPage(totalPages); }, [page, totalPages]); const rangeStart = filteredCount === 0 ? 0 : (page - 1) * pageSize + 1; const rangeEnd = Math.min(page * pageSize, filteredCount); const display = useMemo(() => (sortKey === "date" ? withDayHeaders(rows) : rows), [rows, sortKey]); const toggleSort = (key: SortKey) => { if (sortKey === key) setSortDir(sortDir === "asc" ? "desc" : "asc"); else { setSortKey(key); setSortDir(key === "description" ? "asc" : "desc"); } setPage(1); }; const SortIcon = ({ k }: { k: SortKey }) => sortKey !== k ? : sortDir === "asc" ? : ; const handleCategoryChange = (txnId: string, newCategory: string) => { updateTransactions([{ id: txnId, category: newCategory }], { applyToMerchant: true }) .then((n) => { void refresh(); const others = typeof n === "number" ? n : 0; toast.success(others > 0 ? `Moved to ${newCategory}, and ${others} more from this merchant` : `Moved to ${newCategory}`); }) .catch(() => toast.error("Couldn't update the category")); setDetailTxn((d) => (d && d.id === txnId ? { ...d, category: newCategory } : d)); }; const columns = "grid-cols-[1fr_auto] sm:grid-cols-[minmax(0,1fr)_120px_130px_120px]"; const activeTagNames = activeTags.map((t) => t.name); const activeTagLabel = activeTagNames.length <= 2 ? activeTagNames.join(" and ") : `${activeTagNames.slice(0, -1).join(", ")} and ${activeTagNames[activeTagNames.length - 1]}`; return (

Transactions

{acrossAccounts && activeTags.length > 0 ? <>Everything tagged {activeTagLabel}, from all your accounts : "Activity recorded on this account"}

{canHoldTransactions(account.type) && ( )}
{/* Totals for whatever's currently filtered (tags included) - not just this account's lifetime totals, so narrowing by tag shows exactly what that slice adds up to. */} {filteredCount > 0 && (
)} {/* Sticks under the app header; the column header and day rows stack below it. */}
{ setSearch(v); setPage(1); }} placeholder="Search this table…" shortcut={false} /> { setCategory(v); setPage(1); }} categories={CATEGORIES} allLabel="All Categories" aria-label="Filter by category" />
{(filtersActive || (assetLike && tagIds.length > 0)) && (
{assetLike && tagIds.length > 0 && (
{([["all", "All accounts"], ["account", "This account only"]] as const).map(([v, label]) => ( ))}
)} {filtersActive && ( )}
)}
Category
{error &&

Couldn't load transactions. Try again in a moment.

} {!error && !loading && display.length === 0 && ( )} {display.map((row) => { if (isDayHeader(row)) { return (
{dayLabel(row.date)} 0 && "text-chart-green")}> {row.net > 0 ? "+" : row.net < 0 ? "-" : ""}{fmtMoney(row.net)}
); } const txn = row; return (
setDetailTxn(txn)} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetailTxn(txn); } }} className={cn( "grid gap-2 px-5 py-3 border-b border-border/20 last:border-0 items-center cursor-pointer transition-colors", "hover:bg-muted/30 focus-visible:outline-none focus-visible:bg-muted/40 focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary/40", detailTxn?.id === txn.id && "bg-muted/40", columns )} >

{refinedMerchantLabel(txn.description, refinements, txn.date, txn.category)}

{fmtDate(txn.date)}

{fmtDate(txn.date)} e.stopPropagation()}> {suggestedDuplicates.has(txn.id) ? ( mergeDuplicate.mutate(suggestedDuplicates.get(txn.id)!.id)} onDismiss={() => dismissDuplicate.mutate(suggestedDuplicates.get(txn.id)!.id)} busy={mergeDuplicate.isPending || dismissDuplicate.isPending} /> ) : txn.isTransfer ? ( confirmTransfer.mutate(suggestedTransfers.get(txn.id)!.id)} onReject={() => rejectTransfer.mutate(suggestedTransfers.get(txn.id)!.id)} busy={confirmTransfer.isPending || rejectTransfer.isPending} /> ) : txn.isSplit ? ( ) : ( )} = 0 ? "text-chart-green" : "text-foreground")}> {txn.amount >= 0 ? "+" : "-"}{fmtMoney(txn.amount)}
); })} {filteredCount > 0 && (
Rows per page {rangeStart}–{rangeEnd} of {filteredCount}
{totalPages > 1 && ( setPage(Math.max(1, page - 1))} className={cn("cursor-pointer", page === 1 && "pointer-events-none opacity-50")} /> {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { const start = Math.max(1, Math.min(page - 2, totalPages - 4)); const p = start + i; return ( setPage(p)}>{p} ); })} setPage(Math.min(totalPages, page + 1))} className={cn("cursor-pointer", page === totalPages && "pointer-events-none opacity-50")} /> )}
)}
{/* Opens on this page: clicking a row never leaves the account. */} { if (!o) setDetailTxn(null); }} onCategoryChange={handleCategoryChange} onNoteChange={async (txnId, note) => { try { await updateTransactions([{ id: txnId, note }]); void refresh(); setDetailTxn((d) => (d && d.id === txnId ? { ...d, note } : d)); } catch { toast.error("Couldn't save the note"); } }} onSplitsChange={() => void refresh()} categories={[...CATEGORIES]} /> { await addTransaction(t); void refresh(); }} />
); }