diff --git a/src/app/(app)/accounting/[id]/page.tsx b/src/app/(app)/accounting/[id]/page.tsx
new file mode 100644
index 0000000..742c302
--- /dev/null
+++ b/src/app/(app)/accounting/[id]/page.tsx
@@ -0,0 +1,11 @@
+import { TransactionDetail } from "@/features/accounting/TransactionDetail";
+
+// Ports ios/Mercury/Features/Transactions/TransactionDetailView.swift.
+export default async function TransactionDetailPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ return ;
+}
diff --git a/src/app/(app)/accounting/page.tsx b/src/app/(app)/accounting/page.tsx
new file mode 100644
index 0000000..0d36688
--- /dev/null
+++ b/src/app/(app)/accounting/page.tsx
@@ -0,0 +1,7 @@
+import { TransactionList } from "@/features/accounting/TransactionList";
+
+// Ports ios/Mercury/Features/Transactions/TransactionsView.swift's ledger
+// tab (narrowed to this task's scope — see TransactionList).
+export default function AccountingPage() {
+ return ;
+}
diff --git a/src/features/accounting/CategorizeSheet.tsx b/src/features/accounting/CategorizeSheet.tsx
new file mode 100644
index 0000000..760b32a
--- /dev/null
+++ b/src/features/accounting/CategorizeSheet.tsx
@@ -0,0 +1,97 @@
+"use client";
+
+import {
+ BottomSheetRoot,
+ BottomSheetBackdrop,
+ BottomSheetPositioner,
+ BottomSheetContent,
+ BottomSheetHeader,
+ BottomSheetTitle,
+ BottomSheetCloseButton,
+ BottomSheetBody,
+ Icon,
+ ListRoot,
+ ListItem,
+ ListContent,
+ ListTitle,
+} from "@seed-design/react";
+import type { Category } from "@/api/schemas";
+import { accountingStrings as s } from "./strings";
+
+export interface CategorizeSheetProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ categories: Category[];
+ /** The transaction's current category (main or sub) — highlighted in the list. */
+ selected?: string;
+ onSelect: (category: Category) => void;
+}
+
+const closeSvg = (
+
+);
+
+const checkSvg = (
+
+);
+
+/**
+ * Category picker bottom sheet (ports `CategoryPickerSheet` from
+ * `ios/Mercury/Features/Planner/PlannerEditViews.swift`, opened from the
+ * transaction detail's Ангилал row): a titled list of all categories,
+ * tap-to-select, checkmark on the current pick.
+ */
+export function CategorizeSheet({ open, onOpenChange, categories, selected, onSelect }: CategorizeSheetProps) {
+ return (
+
+
+
+
+
+ {s.categoryPicker.title}
+
+
+
+
+
+
+ {categories.map((cat) => {
+ const isSelected = cat.name === selected;
+ return (
+
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
diff --git a/src/features/accounting/TransactionDetail.tsx b/src/features/accounting/TransactionDetail.tsx
new file mode 100644
index 0000000..48ee9ee
--- /dev/null
+++ b/src/features/accounting/TransactionDetail.tsx
@@ -0,0 +1,321 @@
+"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 (
+
+ );
+}
diff --git a/src/features/accounting/TransactionList.test.tsx b/src/features/accounting/TransactionList.test.tsx
new file mode 100644
index 0000000..b30049d
--- /dev/null
+++ b/src/features/accounting/TransactionList.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import { it, expect, vi, beforeEach } from "vitest";
+import type { Txn } from "@/api/schemas";
+import { setHidden } from "@/ds/money";
+
+const txns: Txn[] = [
+ {
+ date: "2026-08-20T09:00:00Z",
+ amount: "15000",
+ direction: "expense",
+ category: "Хоол",
+ title: "Кофе шоп",
+ accountId: 1,
+ txnId: 101,
+ },
+ {
+ date: "2026-08-20T08:00:00Z",
+ amount: "2500000",
+ direction: "income",
+ category: "Цалин",
+ title: "ХХК цалин",
+ accountId: 1,
+ txnId: 102,
+ salary: true,
+ },
+];
+
+vi.mock("@/api/hooks/reads", () => ({
+ useTransactions: () => ({ data: txns, isLoading: false }),
+}));
+
+import { TransactionList } from "./TransactionList";
+
+beforeEach(() => {
+ setHidden(false);
+});
+
+it("renders transaction titles and formatted amounts, hiding salary rows by default", () => {
+ render();
+
+ // Non-salary row: title + a signed, grouped amount.
+ expect(screen.getByText("Кофе шоп")).toBeInTheDocument();
+ expect(screen.getByText("−15,000₮")).toBeInTheDocument();
+
+ // Salary row is filtered out of the row list by default...
+ expect(screen.queryByText("ХХК цалин")).not.toBeInTheDocument();
+
+ // ...but its amount still counts toward the income total (salary is
+ // included in totals per TransactionsModel.recompute, only excluded from
+ // the row list).
+ expect(screen.getByText("2,500,000₮")).toBeInTheDocument();
+});
diff --git a/src/features/accounting/TransactionList.tsx b/src/features/accounting/TransactionList.tsx
new file mode 100644
index 0000000..ec149ab
--- /dev/null
+++ b/src/features/accounting/TransactionList.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import Link from "next/link";
+import { useMemo } from "react";
+import { Skeleton } from "@seed-design/react";
+import { useTransactions } from "@/api/hooks/reads";
+import type { Txn } from "@/api/schemas";
+import { Card, HideAmountsToggle } from "@/ds";
+import { MASKED, tugrikRaw } from "@/ds/money";
+import { accountingStrings as s } from "./strings";
+import { txnRouteId } from "./txnRoute";
+import { useHiddenAmounts } from "./useHiddenAmounts";
+
+/** Local calendar day (browser-local time), for grouping + the day header —
+ * a dependency-free stand-in for iOS's Asia/Ulaanbaatar `Calendar`. */
+function dayKeyAndLabel(iso: string): { key: string; label: string } {
+ const d = new Date(iso);
+ if (Number.isNaN(d.getTime())) return { key: iso.slice(0, 10), label: iso.slice(0, 10) };
+ const y = d.getFullYear();
+ const m = d.getMonth() + 1;
+ const day = d.getDate();
+ return { key: `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`, label: `${m}-р сарын ${day}` };
+}
+
+interface DayGroup {
+ key: string;
+ label: string;
+ items: Txn[];
+}
+
+/** Groups already-sorted (newest-first) rows into consecutive same-day buckets. */
+function groupByDay(items: Txn[]): DayGroup[] {
+ const groups: DayGroup[] = [];
+ for (const txn of items) {
+ const { key, label } = dayKeyAndLabel(txn.date);
+ const last = groups[groups.length - 1];
+ if (last && last.key === key) {
+ last.items.push(txn);
+ } else {
+ groups.push({ key, label, items: [txn] });
+ }
+ }
+ return groups;
+}
+
+function amountOf(txn: Txn): number {
+ return parseFloat(txn.amount) || 0;
+}
+
+function SummaryRow({ label, value, hidden, tone }: { label: string; value: number; hidden: boolean; tone: "income" | "expense" }) {
+ return (
+
+ {label}
+
+ {hidden ? MASKED : tugrikRaw(value)}
+
+
+ );
+}
+
+function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
+ const income = txn.direction === "income";
+ const isTransfer = txn.transfer === true;
+ const sign = income ? "+" : "−";
+ const amountText = hidden ? MASKED : `${sign}${tugrikRaw(txn.amount)}`;
+ const amountColor = isTransfer
+ ? "var(--seed-color-fg-neutral-muted, #8b8b8b)"
+ : income
+ ? "var(--mercury-success, #1e9e6b)"
+ : "var(--mercury-critical, #e5484d)";
+
+ return (
+
+
+
+ {txn.title || txn.category}
+
+
+ {isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category}
+
+
+ {amountText}
+
+ );
+}
+
+/**
+ * The Тооцоо list: income/expense totals for the loaded month, then the
+ * transaction rows grouped by day. Mirrors `TransactionsView.ledgerTab` /
+ * `TransactionsModel.recompute` (narrowed to this task's scope — no month
+ * nav or category chips): salary deposits (`salary === true`) are hidden by
+ * default, and transfers are excluded from the totals and tagged in the row
+ * meta line instead of colored green/red.
+ */
+export function TransactionList() {
+ const { data, isLoading } = useTransactions();
+ const all = useMemo(() => data ?? [], [data]);
+ const hidden = useHiddenAmounts();
+
+ const totals = useMemo(() => {
+ let income = 0;
+ let expense = 0;
+ for (const txn of all) {
+ if (txn.transfer === true) continue;
+ if (txn.direction === "income") income += amountOf(txn);
+ else expense += amountOf(txn);
+ }
+ return { income, expense };
+ }, [all]);
+
+ const visible = useMemo(() => all.filter((txn) => txn.salary !== true), [all]);
+ const groups = useMemo(() => groupByDay(visible), [visible]);
+
+ return (
+
+
+
{s.list.title}
+
+
+
+
+
+
+
+
+ {isLoading ? (
+
+ ) : groups.length === 0 ? (
+
+ {s.list.empty}
+
+ ) : (
+
+ {groups.map((group) => (
+
+
+ {group.label}
+
+
+ {group.items.map((txn, i) => (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/features/accounting/strings.ts b/src/features/accounting/strings.ts
new file mode 100644
index 0000000..d0f5c22
--- /dev/null
+++ b/src/features/accounting/strings.ts
@@ -0,0 +1,44 @@
+// Accounting (Тооцоо) feature copy, ported verbatim from
+// ios/Mercury/Features/Transactions/TransactionsView.swift,
+// TransactionDetailView.swift, and
+// ios/Mercury/Features/Categories/CategorizeReviewView.swift,
+// ios/Mercury/Features/Planner/PlannerEditViews.swift (CategoryPickerSheet).
+export const accountingStrings = {
+ list: {
+ title: "Гүйлгээ",
+ hideToggle: "Мөнгөн дүн нуух",
+ income: "Орлого",
+ expense: "Зарлага",
+ empty: "Гүйлгээ алга",
+ transferTag: "Шилжүүлэг",
+ },
+ detail: {
+ total: "Нийт",
+ date: "Огноо",
+ category: "Ангилал",
+ name: "Нэр",
+ balance: "Үлдэгдэл",
+ type: "Төрөл",
+ income: "Орлого",
+ expense: "Зарлага",
+ note: "Тэмдэглэл",
+ noteAdd: "Нэмэх",
+ noteSave: "Хадгалах",
+ noteCancel: "Болих",
+ notFound: "Гүйлгээ олдсонгүй",
+ back: "Буцах",
+ },
+ rename: {
+ title: "Нэр өөрчлөх",
+ placeholder: "Шинэ нэр",
+ confirmTitle: "Нэр өөрчлөх үү?",
+ confirmDescription: "Энэ худалдагчийн бүх өмнөх болон дараагийн гүйлгээнд шинэ нэрийг хэрэглэнэ.",
+ confirmCancel: "Болих",
+ confirmSave: "Хадгалах",
+ },
+ categoryPicker: {
+ title: "Гарчиг",
+ cancel: "Болих",
+ select: "Сонгох",
+ },
+} as const;
diff --git a/src/features/accounting/txnRoute.ts b/src/features/accounting/txnRoute.ts
new file mode 100644
index 0000000..5640d07
--- /dev/null
+++ b/src/features/accounting/txnRoute.ts
@@ -0,0 +1,18 @@
+import type { Txn } from "@/api/schemas";
+
+/**
+ * Stable per-row identifier for linking a list row to `/accounting/[id]`.
+ * Settled transactions carry a numeric `txnId`; pending holds (no id yet,
+ * same as iOS's `TxnDTO.txnId == nil`) fall back to a composite of their
+ * match key + date so the row is still linkable, best-effort, without a
+ * dedicated "get one transaction" endpoint.
+ */
+export function txnRouteId(txn: Txn): string {
+ if (txn.txnId != null) return String(txn.txnId);
+ return `p_${encodeURIComponent(txn.matchKey ?? txn.title)}_${encodeURIComponent(txn.date)}`;
+}
+
+/** Finds the transaction in `txns` that a given `/accounting/[id]` id refers to. */
+export function findTxnByRouteId(txns: Txn[], id: string): Txn | undefined {
+ return txns.find((t) => txnRouteId(t) === id);
+}
diff --git a/src/features/accounting/useHiddenAmounts.ts b/src/features/accounting/useHiddenAmounts.ts
new file mode 100644
index 0000000..794a785
--- /dev/null
+++ b/src/features/accounting/useHiddenAmounts.ts
@@ -0,0 +1,34 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { HIDE_AMOUNTS_EVENT } from "@/ds";
+import { isHidden } from "@/ds/money";
+
+/**
+ * Tracks the global hide-amounts flag reactively. `tugrik()`/`isHidden()`
+ * read `localStorage` synchronously but don't cause a re-render on their
+ * own — pages that show masked amounts need to listen for the
+ * `HideAmountsToggle`-dispatched event (and other tabs' storage writes) to
+ * update immediately when the switch flips.
+ */
+export function useHiddenAmounts(): boolean {
+ const [hidden, setHiddenState] = useState(() => isHidden());
+
+ useEffect(() => {
+ function onToggle(e: Event) {
+ const detail = (e as CustomEvent<{ hidden: boolean }>).detail;
+ setHiddenState(detail ? detail.hidden : isHidden());
+ }
+ function onStorage() {
+ setHiddenState(isHidden());
+ }
+ window.addEventListener(HIDE_AMOUNTS_EVENT, onToggle);
+ window.addEventListener("storage", onStorage);
+ return () => {
+ window.removeEventListener(HIDE_AMOUNTS_EVENT, onToggle);
+ window.removeEventListener("storage", onStorage);
+ };
+ }, []);
+
+ return hidden;
+}