"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 (
);
}