"use client"; import type { CSSProperties } from "react"; import { useEffect, useMemo, useState } from "react"; import { useRouter } from "next/navigation"; import { DialogRoot, DialogBackdrop, DialogPositioner, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogAction, TextFieldRoot, TextFieldTextarea, } from "@seed-design/react"; import { useTransactions, useCategories } from "@/api/hooks/reads"; import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations"; import { Card, MercuryButton, NameEdit } from "@/ds"; import { MASKED, tugrikRaw } from "@/ds/money"; import { accountingStrings as s } from "./strings"; import { CategorizeSheet } from "./CategorizeSheet"; import { findTxnByRouteId } from "./txnRoute"; import { useHiddenAmounts } from "./useHiddenAmounts"; export interface TransactionDetailProps { id: string; } const ROW_BORDER: CSSProperties = { borderBottom: "1px solid var(--seed-color-border-neutral, #e5e5e5)" }; function formatDateTime(iso: string): string { const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; const pad = (n: number) => String(n).padStart(2, "0"); return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; } function DetailRow({ label, value, chevron, bold, last, onClick, }: { label: string; value: string; chevron?: boolean; bold?: boolean; last?: boolean; onClick?: () => void; }) { const wrapperStyle: CSSProperties = last ? {} : ROW_BORDER; const body = (
{label} {value} {chevron && ( )}
); if (!onClick) return
{body}
; return ( ); } /** * Transaction detail (ports `TransactionDetailView.swift`, narrowed to this * task's scope): a key-value table plus categorize / rename / note edits. * Categorize and rename both "learn a rule" server-side keyed on the * merchant's match key (applies to all of that merchant's past + future * transactions) — renaming shows a confirm dialog because of that broad * effect; categorizing (like iOS) applies immediately. */ export function TransactionDetail({ id }: TransactionDetailProps) { const router = useRouter(); const { data: transactions } = useTransactions(); const { data: categories = [] } = useCategories(); const categorize = useCategorize(); const renameTxn = useRenameTxn(); const setNoteMutation = useSetNote(); const hiddenAmounts = useHiddenAmounts(); const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]); // Local overrides so an edit reflects immediately, matching iOS's // `assigned` / `displayTitle` @State — the server call runs in the // background and the list refetch (via mutation `onSuccess` invalidation) // reconciles afterwards. const [assignedCategory, setAssignedCategory] = useState(null); const [displayTitle, setDisplayTitle] = useState(null); const [noteOverride, setNoteOverride] = useState(null); const [pickerOpen, setPickerOpen] = useState(false); const [renaming, setRenaming] = useState(false); const [pendingName, setPendingName] = useState(null); const [confirmOpen, setConfirmOpen] = useState(false); const [editingNote, setEditingNote] = useState(false); const [noteDraft, setNoteDraft] = useState(""); useEffect(() => { setAssignedCategory(null); setDisplayTitle(null); setNoteOverride(null); setRenaming(false); setEditingNote(false); }, [id]); if (!txn) { return (
router.push("/accounting")} />

{s.detail.notFound}

); } const income = txn.direction === "income"; const category = assignedCategory ?? txn.category; const title = displayTitle ?? txn.title; const note = noteOverride ?? txn.note ?? ""; const canNote = txn.txnId != null && txn.txnId > 0; const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount); const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : "−"}${tugrikRaw(txn.amount)}`; async function confirmRename() { if (!pendingName) return; const name = pendingName; setDisplayTitle(name); setPendingName(null); try { await renameTxn.mutateAsync({ matchKey: txn!.matchKey ?? txn!.title, name }); } catch { // Best-effort, matches iOS's `renameMerchant` — leave the optimistic // title in place; the next successful list load reconciles it. } } async function saveNote() { const trimmed = noteDraft.trim(); setNoteOverride(trimmed); setEditingNote(false); if (txn!.txnId != null) { try { await setNoteMutation.mutateAsync({ id: txn!.txnId, note: trimmed }); } catch { // Best-effort, matches iOS's `saveNote`. } } } if (renaming) { return (
setRenaming(false)} onSave={(name) => { setRenaming(false); setPendingName(name); setConfirmOpen(true); }} />
); } return (
router.push("/accounting")} />
{title || category} {category}
{signedAmount}
setPickerOpen(true)} /> setRenaming(true)} /> {txn.balanceAfter != null && ( )} {canNote && (editingNote ? (
setEditingNote(false)}> {s.detail.noteCancel} {s.detail.noteSave}
) : ( { setNoteDraft(note); setEditingNote(true); }} /> ))}
{ setAssignedCategory(cat.name); setPickerOpen(false); categorize.mutate({ matchKey: txn.matchKey ?? txn.title, category: cat.name, kind: txn.direction === "income" ? "income" : "expense", }); }} /> {s.rename.confirmTitle} {s.rename.confirmDescription} {s.rename.confirmCancel} {s.rename.confirmSave}
); } function BackButton({ onClick }: { onClick: () => void }) { return ( ); }