import { Fragment, useState, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { ChevronUp, ChevronDown, MessageSquare, Tag, Link2 } from "lucide-react"; import type { TransactionRow, TransactionSort, Category, SplitChild, } from "../../shared/types"; import type { LinkedTransferTooltipRow } from "../../services/balance.service"; import { SPLIT_FEATURE, type SplitAccess } from "../../shared/splitGate"; import { onboardingTargetAttributes } from "../../utils/onboardingPath"; import CategoryCombobox from "../shared/CategoryCombobox"; import UpsellDialog from "../shared/UpsellDialog"; import SplitAdjustmentModal from "./SplitAdjustmentModal"; import SplitButton from "./SplitButton"; interface TransactionTableProps { rows: TransactionRow[]; sort: TransactionSort; categories: Category[]; onSort: (column: TransactionSort["column"]) => void; onCategoryChange: (txId: number, categoryId: number | null) => void; onNotesChange: (txId: number, notes: string) => void; onAddKeyword: (categoryId: number, keyword: string) => Promise; onLoadSplitChildren: (parentId: number) => Promise; onSaveSplit: (parentId: number, entries: Array<{ category_id: number; amount: number; description: string }>) => Promise; onDeleteSplit: (parentId: number) => Promise; /** * What the RĂ©partition button does (#369): `splitAccessFor` over * `useEntitlement(SPLIT_FEATURE)`. Required on purpose: a default of "open" * would let a new caller leave the split ungated without a word. */ splitAccess: SplitAccess; onRowContextMenu?: (event: React.MouseEvent, row: TransactionRow) => void; /** * Issue #142 — when supplied, a small Link2 icon appears next to the * description for every transaction whose id is a key in the map. The * icon's tooltip lists the linked accounts. The lookup is intentionally * done by the parent (one batch SELECT, in-memory `.has()` thereafter) * to avoid an N+1 hit on the table render. */ linkedTransfersByTxId?: Map; /** * Highlight the category of the first row for the getting-started card * (#399). The ring sits on a wrapper: `CategoryCombobox` stays as it is. */ onboardingTarget?: boolean; } /** `data-onboarding-target` of the highlighted category. */ export const TRANSACTION_CATEGORY_TARGET = "transaction-category"; function SortIcon({ column, sort, }: { column: TransactionSort["column"]; sort: TransactionSort; }) { if (sort.column !== column) return ↕; return sort.direction === "asc" ? ( ) : ( ); } export default function TransactionTable({ rows, sort, categories, onSort, onCategoryChange, onNotesChange, onAddKeyword, onLoadSplitChildren, onSaveSplit, onDeleteSplit, splitAccess, onRowContextMenu, linkedTransfersByTxId, onboardingTarget, }: TransactionTableProps) { const { t } = useTranslation(); const [expandedId, setExpandedId] = useState(null); const [editingNotes, setEditingNotes] = useState(""); const [keywordRowId, setKeywordRowId] = useState(null); const [keywordText, setKeywordText] = useState(""); const [keywordSaved, setKeywordSaved] = useState(null); const [splitRow, setSplitRow] = useState(null); const [showSplitUpsell, setShowSplitUpsell] = useState(false); const noCategoryExtra = useMemo( () => [{ value: "", label: t("transactions.table.noCategory") }], [t] ); if (rows.length === 0) { return (

{t("transactions.noTransactions")}

); } const columns: Array<{ key: TransactionSort["column"]; label: string; align: string; }> = [ { key: "date", label: t("transactions.date"), align: "text-left" }, { key: "description", label: t("transactions.description"), align: "text-left" }, { key: "amount", label: t("transactions.amount"), align: "text-right" }, { key: "category_name", label: t("transactions.category"), align: "text-left" }, ]; const toggleNotes = (row: TransactionRow) => { if (expandedId === row.id) { setExpandedId(null); } else { setExpandedId(row.id); setEditingNotes(row.notes ?? ""); } }; const handleNotesSave = (txId: number) => { onNotesChange(txId, editingNotes); setExpandedId(null); }; const toggleKeyword = (row: TransactionRow) => { if (keywordRowId === row.id) { setKeywordRowId(null); } else { setKeywordRowId(row.id); setKeywordText(row.description); } }; const handleKeywordSave = async (row: TransactionRow) => { if (!row.category_id || !keywordText.trim()) return; await onAddKeyword(row.category_id, keywordText); setKeywordRowId(null); setKeywordSaved(row.id); setTimeout(() => setKeywordSaved(null), 2000); }; return (
{columns.map((col) => ( ))} {rows.map((row, index) => ( onRowContextMenu(e, row) : undefined} className="hover:bg-[var(--muted)] transition-colors" > {expandedId === row.id && (
onSort(col.key)} className={`px-3 py-2 ${col.align} text-xs font-medium text-[var(--muted-foreground)] cursor-pointer select-none hover:text-[var(--foreground)] transition-colors`} > {col.label}
{row.date}
{row.description} {linkedTransfersByTxId?.has(row.id) && ( { const links = linkedTransfersByTxId.get(row.id) ?? []; const parts = links.map( (l) => `${l.account_name} (${t(`balance.transfers.direction.${l.direction}`)})` ); return `${t("transactions.transferIcon.tooltip")}: ${parts.join(", ")}`; })() } aria-label={t("transactions.transferIcon.ariaLabel")} > )}
= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" }`} > {row.amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2, })}
{/* Every row has the wrapper and only the first one's carries the ring (#399): the ring coming or going never remounts the combobox, so it never drops the focus. */}
onCategoryChange(row.id, id)} placeholder={t("transactions.table.noCategory")} compact extraOptions={noCategoryExtra} activeExtra={row.category_id === null ? "" : null} onExtraSelect={() => onCategoryChange(row.id, null)} />
{row.category_id !== null && ( <> setSplitRow(row)} onOpenUpsell={() => setShowSplitUpsell(true)} /> )}