import { useEffect, useState } from "react"; import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from "@/components/ui/sheet"; import { Separator } from "@/components/ui/separator"; import { Textarea } from "@/components/ui/textarea"; import { Link } from "react-router-dom"; import { Building2 } from "lucide-react"; import { Transaction } from "@/types/finance"; import { useMoney } from "@/hooks/useMoney"; import { normalizeMerchant } from "@/lib/merchant"; import { CategoryBadge } from "@/components/elements/CategoryIcon"; import { CategoryPicker } from "@/components/elements/CategoryPicker"; import { DetailRow } from "@/components/elements/DetailRow"; import { TagPicker } from "@/components/elements/TagPicker"; import { SplitEditor } from "@/components/elements/SplitEditor"; import { useTags, useTagMutations, useTransactionTags } from "@/hooks/useTags"; import { useTransactionSplits } from "@/hooks/useSplits"; import { useTransactionAccountName } from "@/hooks/useAccountNames"; export const allCategories = [ "Food & Dining", "Shopping", "Transportation", "Entertainment", "Bills & Utilities", "Health", "Travel", "Income", "Transfer", "Groceries", ]; interface TransactionDetailProps { transaction: Transaction | null; open: boolean; onOpenChange: (open: boolean) => void; onCategoryChange?: (txnId: string, newCategory: string) => void; onNoteChange?: (txnId: string, note: string) => Promise | void; /** Called after a split is saved or removed, so the caller can refresh * whatever list it's showing this transaction in (its own isSplit/ * splitCount there won't otherwise update until the next fetch). */ onSplitsChange?: (txnId: string) => void; categories?: string[]; } export function TransactionDetail({ transaction, open, onOpenChange, onCategoryChange, onNoteChange, onSplitsChange, categories, }: TransactionDetailProps) { // Hooks must run unconditionally, so the null guard comes after them. const { moneyAbs, date: fmtDate } = useMoney({ accountId: transaction?.accountId }); const [note, setNote] = useState(""); const [savingNote, setSavingNote] = useState(false); const { data: allTags = [] } = useTags(); const { data: txnTags = [], setTags } = useTransactionTags(transaction?.id); const { create: createTag } = useTagMutations(); const txnTagIds = txnTags.map((t) => t.id); const { data: splits = [], setSplits } = useTransactionSplits(transaction?.id); const [splitEditorOpen, setSplitEditorOpen] = useState(false); const txnAccountName = useTransactionAccountName(); useEffect(() => { setNote(transaction?.note ?? ""); }, [transaction]); if (!transaction) return null; const noteDirty = note !== (transaction.note ?? ""); const saveNote = async () => { if (!noteDirty || !onNoteChange) return; setSavingNote(true); try { await onNoteChange(transaction.id, note); } finally { setSavingNote(false); } }; const accountName = txnAccountName(transaction); const formattedAmount = `${transaction.amount >= 0 ? "+" : "\u2212"}${moneyAbs(transaction.amount)}`; return ( <>
{/* Radix requires a title for the panel to be announced to screen readers. It doubles as the visible heading. */} {accountName} {formattedAmount} {/* L-5: View account link */} {transaction.accountId && ( onOpenChange(false)} > {accountName || "View account"} → )} {/* Merchant, then the facts as label/value rows */}

{normalizeMerchant(transaction.description) || transaction.description}

{fmtDate(transaction.date)} {transaction.type}
{/* Category Select, or a read-only breakdown once split. Driven by the live splits query rather than transaction.isSplit: that prop is a snapshot taken when the panel opened and doesn't update in place after a save within this same session. */}

Category

{splits.length > 0 ? (
{splits.map((s) => (
{moneyAbs(s.amount)}
))}
) : ( onCategoryChange?.(transaction.id, val)} categories={categories || allCategories} triggerClassName="bg-background border-border/50 h-9" /> )}
{/* Tags */}

Tags

setTags.mutate(ids)} onCreate={(name) => createTag.mutateAsync({ name }).then((t) => t.id)} disabled={setTags.isPending} />
{/* Notes, the field was readOnly because there was no column to store it in; it saves on blur now. */}

Notes