"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, Skeleton, TextFieldRoot, TextFieldTextarea, TextFieldInput, SwitchRoot, SwitchControl, SwitchThumb, BottomSheetRoot, BottomSheetBackdrop, BottomSheetPositioner, BottomSheetContent, BottomSheetHeader, BottomSheetTitle, BottomSheetBody, BottomSheetFooter, } from "@seed-design/react"; import { useTransactions, useCategories, useSubscriptions } from "@/api/hooks/reads"; import { useCategorize, useRenameTxn, useSetNote, useSubscriptionMutations, useLendingMutations } 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, isLoading } = useTransactions(); const { data: categories = [] } = useCategories(); const { data: subscriptions } = useSubscriptions(); const categorize = useCategorize(); const renameTxn = useRenameTxn(); const setNoteMutation = useSetNote(); const subscriptionMutations = useSubscriptionMutations(); const lendingMutations = useLendingMutations(); 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 [subscriptionOverride, setSubscriptionOverride] = 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(""); const [convertOpen, setConvertOpen] = useState(false); useEffect(() => { setAssignedCategory(null); setDisplayTitle(null); setNoteOverride(null); setSubscriptionOverride(null); setRenaming(false); setEditingNote(false); setConvertOpen(false); }, [id]); if (isLoading && !txn) { return (
router.push("/accounting")} />
); } 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; // A settled row has a stable txnId; pending holds (nil/0) can't be linked // to a lending entry or the subscription-detection reconciliation below. const hasStableTxnId = txn.txnId != null && txn.txnId > 0; const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount); const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : "−"}${tugrikRaw(txn.amount)}`; // Whether this merchant is already marked as a subscription override, so the // toggle opens in the right state (mirrors iOS's `.task` reconciliation // against `api.recurring()`), keyed on the same match key categorize/rename // already use for this merchant. const subscriptionKey = (txn.matchKey ?? txn.title).toLowerCase().trim(); const detectedSubscription = (subscriptions?.subscriptions ?? []).some( (sub) => (sub.matchKey ?? sub.label.toLowerCase()) === subscriptionKey, ); const isSubscription = subscriptionOverride ?? detectedSubscription; 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`. } } } /** Toggle this merchant's subscription override (force-add / force-remove), * mirroring iOS's `setSubscription`. */ async function toggleSubscription(next: boolean) { setSubscriptionOverride(next); try { await subscriptionMutations.setSubscription.mutateAsync({ matchKey: txn!.matchKey ?? txn!.title, active: next, }); } catch { // Best-effort — leave the optimistic toggle in place. } } /** Create a lending entry from this expense, linked to the transaction when * it has a stable id, then navigate to the new entry's detail page. */ async function convertToLending(values: { person: string; amount: string; lentOn: string }) { const created = await lendingMutations.create.mutateAsync({ person: values.person, amount: values.amount, lentOn: values.lentOn, txnId: hasStableTxnId ? (txn!.txnId as number) : undefined, }); setConvertOpen(false); router.push(created ? `/assets/lending/${created.id}` : "/assets"); } 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); }} /> ))}
{s.detailActions.subscriptionLabel}
{isSubscription ? s.detailActions.subscriptionActive : s.detailActions.subscriptionInactive}
{!income && ( setConvertOpen(true)}> {s.detailActions.convertToLending} )} { 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} {convertOpen && ( setConvertOpen(false)} onSave={convertToLending} /> )}
); } /** * Small pre-filled form (Card style="Зээл болгох") that turns an expense into a * lending entry — ports the pre-fill in `TransactionDetailView.swift`'s * `showMarkAsLending` (person/amount/date seeded via `LendingAutofill`), * simplified to just those three editable fields per this task's scope. */ function ConvertToLendingSheet({ initialPerson, initialAmount, initialLentOn, saving, onClose, onSave, }: { initialPerson: string; initialAmount: string; initialLentOn: string; saving: boolean; onClose: () => void; onSave: (values: { person: string; amount: string; lentOn: string }) => void | Promise; }) { const [person, setPerson] = useState(initialPerson); const [amount, setAmount] = useState(initialAmount); const [lentOn, setLentOn] = useState(initialLentOn); const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving; return ( { if (!next) onClose(); }}> {s.detailActions.lendingSheetTitle} {s.detailActions.cancel} onSave({ person: person.trim(), amount, lentOn })} > {s.detailActions.save} ); } function BackButton({ onClick }: { onClick: () => void }) { return ( ); }