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; +}