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