diff --git a/src/app/(app)/assets/account/[id]/page.tsx b/src/app/(app)/assets/account/[id]/page.tsx
new file mode 100644
index 0000000..bea20f3
--- /dev/null
+++ b/src/app/(app)/assets/account/[id]/page.tsx
@@ -0,0 +1,6 @@
+import { AccountDetail } from "@/features/assets/AccountDetail";
+
+export default async function AccountDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return ;
+}
diff --git a/src/app/(app)/assets/lending/[id]/page.tsx b/src/app/(app)/assets/lending/[id]/page.tsx
new file mode 100644
index 0000000..358891a
--- /dev/null
+++ b/src/app/(app)/assets/lending/[id]/page.tsx
@@ -0,0 +1,6 @@
+import { LendingDetail } from "@/features/assets/LendingDetail";
+
+export default async function LendingDetailPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return ;
+}
diff --git a/src/app/(app)/assets/manual/[name]/page.tsx b/src/app/(app)/assets/manual/[name]/page.tsx
new file mode 100644
index 0000000..19cc877
--- /dev/null
+++ b/src/app/(app)/assets/manual/[name]/page.tsx
@@ -0,0 +1,6 @@
+import { ManualAssetDetail } from "@/features/assets/ManualAssetDetail";
+
+export default async function ManualAssetPage({ params }: { params: Promise<{ name: string }> }) {
+ const { name } = await params;
+ return ;
+}
diff --git a/src/app/(app)/assets/page.tsx b/src/app/(app)/assets/page.tsx
new file mode 100644
index 0000000..a831d91
--- /dev/null
+++ b/src/app/(app)/assets/page.tsx
@@ -0,0 +1,7 @@
+import { AssetsView } from "@/features/assets/AssetsView";
+
+// Ports ios/Mercury/Features/Assets/AssetsView.swift's Хөрөнгө tab: net worth
+// header, read-only bank accounts, manual (physical) assets, and lending.
+export default function AssetsPage() {
+ return ;
+}
diff --git a/src/features/assets/AccountDetail.tsx b/src/features/assets/AccountDetail.tsx
new file mode 100644
index 0000000..1a64c78
--- /dev/null
+++ b/src/features/assets/AccountDetail.tsx
@@ -0,0 +1,104 @@
+"use client";
+
+import * as React from "react";
+import { useNetWorth, useTransactions } from "@/api/hooks/reads";
+import { Card } from "@/ds";
+import { tugrikRaw } from "@/ds/money";
+import { assetsStrings as s } from "./strings";
+
+const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
+
+export interface AccountDetailProps {
+ accountId: number;
+}
+
+/**
+ * Read-only account detail — no bank-server actions live here (that's iOS's
+ * `AccountDetailView`, gated behind a live bank connection this web app
+ * doesn't drive yet). Shows the balance from `/networth` plus this
+ * account's recent transactions.
+ */
+export function AccountDetail({ accountId }: AccountDetailProps) {
+ const netWorth = useNetWorth();
+ const transactions = useTransactions({ account: accountId, limit: 30 });
+
+ const account = (netWorth.data?.accounts ?? []).find((a) => a.accountId === accountId);
+
+ return (
+
+
+
+ {account?.bank ?? s.accountDetail.bank}
+
+
+
+ {account?.accountNumber}
+ {tugrikRaw(account?.balance ?? "0")}
+
+
+
+
+
+
+
+
+
+
+
+ {s.accountDetail.recentTransactions}
+ {transactions.isLoading ? (
+ …
+ ) : (transactions.data ?? []).length === 0 ? (
+ {s.accountDetail.noTransactions}
+ ) : (
+
+ {(transactions.data ?? []).map((txn, i) => {
+ const income = txn.direction === "income";
+ return (
+
+ {i > 0 && }
+
+
+
{txn.title || txn.category}
+
{txn.date.slice(0, 10)}
+
+
+ {income ? "+" : "−"}
+ {tugrikRaw(txn.amount)}
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
+
+function BackLink() {
+ return (
+
+ ← {s.common.back}
+
+ );
+}
+
+function DetailRow({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function Divider() {
+ return ;
+}
diff --git a/src/features/assets/AssetsView.test.tsx b/src/features/assets/AssetsView.test.tsx
new file mode 100644
index 0000000..7ddaf2b
--- /dev/null
+++ b/src/features/assets/AssetsView.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from "vitest";
+import { AssetsView } from "./AssetsView";
+import { tugrik } from "@/ds/money";
+
+const mutationStub = () => ({ mutateAsync: vi.fn(), isPending: false });
+
+vi.mock("@/api/hooks/reads", () => ({
+ useNetWorth: () => ({
+ data: { total: "1250000", assets: "1500000", liabilities: "250000", accounts: [] },
+ isLoading: false,
+ }),
+ useManualAssets: () => ({
+ data: [
+ {
+ name: "Toyota Prius",
+ category: "car",
+ value: "45000000",
+ acquiredValue: "40000000",
+ currency: "MNT",
+ condition: "used",
+ isLiability: false,
+ change: "5000000",
+ },
+ ],
+ isLoading: false,
+ }),
+ useLending: () => ({ data: [], isLoading: false }),
+}));
+
+vi.mock("@/api/hooks/mutations", () => ({
+ useManualAssetMutations: () => ({
+ add: mutationStub(),
+ delete: mutationStub(),
+ revalue: mutationStub(),
+ }),
+ useLendingMutations: () => ({
+ create: mutationStub(),
+ delete: mutationStub(),
+ addRepayment: mutationStub(),
+ deleteRepayment: mutationStub(),
+ }),
+}));
+
+describe("AssetsView", () => {
+ it("renders the net worth total and the manual asset row", () => {
+ render();
+
+ expect(screen.getByText(tugrik("1250000"))).toBeInTheDocument();
+ expect(screen.getByText("Toyota Prius")).toBeInTheDocument();
+ });
+});
diff --git a/src/features/assets/AssetsView.tsx b/src/features/assets/AssetsView.tsx
new file mode 100644
index 0000000..6f46e35
--- /dev/null
+++ b/src/features/assets/AssetsView.tsx
@@ -0,0 +1,582 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import {
+ BottomSheetRoot,
+ BottomSheetBackdrop,
+ BottomSheetPositioner,
+ BottomSheetContent,
+ BottomSheetHeader,
+ BottomSheetTitle,
+ BottomSheetBody,
+ BottomSheetFooter,
+ TextFieldRoot,
+ TextFieldInput,
+ Skeleton,
+} from "@seed-design/react";
+import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
+import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
+import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
+import { tugrik, tugrikRaw } from "@/ds/money";
+import type { Account, ManualAsset, Lending } from "@/api/schemas";
+import { assetsStrings as s } from "./strings";
+import { useHideAmountsTick } from "./useHideAmountsTick";
+import { ConfirmDialog } from "./ConfirmDialog";
+
+const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
+const rowStyle: React.CSSProperties = {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ padding: "14px 16px",
+};
+
+export function AssetsView() {
+ // Re-renders when the global hide-amounts flag flips (tugrik()/tugrikRaw()
+ // read it from localStorage synchronously, so this is how the numbers on
+ // this page react to the header toggle without a reload).
+ useHideAmountsTick();
+
+ const netWorth = useNetWorth();
+ const manualAssets = useManualAssets();
+ const lending = useLending();
+ const assetMutations = useManualAssetMutations();
+ const lendingMutations = useLendingMutations();
+
+ const [addAssetOpen, setAddAssetOpen] = React.useState(false);
+ const [addLoanOpen, setAddLoanOpen] = React.useState(false);
+ const [deleteAssetName, setDeleteAssetName] = React.useState(null);
+ const [revalueAssetName, setRevalueAssetName] = React.useState(null);
+ const [deleteLoan, setDeleteLoan] = React.useState(null);
+
+ const accounts: Account[] = netWorth.data?.accounts ?? [];
+ const assets: ManualAsset[] = manualAssets.data ?? [];
+ const loans: Lending[] = lending.data ?? [];
+
+ return (
+
+
+
+
+
+
+ {s.accounts.title}
+ {netWorth.isLoading ? (
+
+ ) : accounts.length === 0 ? (
+ {s.accounts.empty}
+ ) : (
+
+ {accounts.map((account, i) => (
+
+ {i > 0 && }
+
+
+
{account.bank}
+
{account.accountNumber}
+
+ {tugrik(account.balance)}
+
+
+ ))}
+
+ )}
+
+
+
+ setAddAssetOpen(true)} addLabel={s.manualAssets.add} />
+ {manualAssets.isLoading ? (
+
+ ) : assets.length === 0 ? (
+
+ ) : (
+
+ {assets.map((asset) => (
+ setRevalueAssetName(asset.name)}
+ onDelete={() => setDeleteAssetName(asset.name)}
+ />
+ ))}
+
+ )}
+
+
+
+ setAddLoanOpen(true)} addLabel={s.lending.add} />
+ {lending.isLoading ? (
+
+ ) : loans.length === 0 ? (
+ {s.lending.empty}
+ ) : (
+
+ {loans.map((loan, i) => (
+
+ {i > 0 && }
+ setDeleteLoan(loan)} />
+
+ ))}
+
+ )}
+
+
+ {addAssetOpen && (
+
setAddAssetOpen(false)}
+ saving={assetMutations.add.isPending}
+ onSave={async (values) => {
+ await assetMutations.add.mutateAsync(values);
+ setAddAssetOpen(false);
+ }}
+ />
+ )}
+
+ {addLoanOpen && (
+ setAddLoanOpen(false)}
+ saving={lendingMutations.create.isPending}
+ onSave={async (values) => {
+ await lendingMutations.create.mutateAsync(values);
+ setAddLoanOpen(false);
+ }}
+ />
+ )}
+
+ setDeleteAssetName(null)}
+ onConfirm={async () => {
+ if (!deleteAssetName) return;
+ await assetMutations.delete.mutateAsync(deleteAssetName);
+ setDeleteAssetName(null);
+ }}
+ />
+
+ setRevalueAssetName(null)}
+ onConfirm={async () => {
+ const name = revalueAssetName;
+ if (!name) return;
+ setRevalueAssetName(null);
+ await assetMutations.revalue.mutateAsync(name);
+ }}
+ />
+
+ setDeleteLoan(null)}
+ onConfirm={async () => {
+ if (!deleteLoan) return;
+ await lendingMutations.delete.mutateAsync(deleteLoan.id);
+ setDeleteLoan(null);
+ }}
+ />
+
+ );
+}
+
+// --- Net worth header -------------------------------------------------------
+
+function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; assets: string; liabilities: string }; loading: boolean }) {
+ return (
+
+ {loading ? (
+
+ ) : (
+ <>
+ {s.netWorth.total}
+ {tugrik(netWorth?.total ?? "0")}
+
+
+
{s.netWorth.assets}
+
{tugrik(netWorth?.assets ?? "0")}
+
+
+
{s.netWorth.liabilities}
+
{tugrik(netWorth?.liabilities ?? "0")}
+
+
+ >
+ )}
+
+ );
+}
+
+// --- Small shared bits -------------------------------------------------------
+
+function SectionTitle({ children }: { children: React.ReactNode }) {
+ return {children}
;
+}
+
+function SectionHeaderRow({ title, onAdd, addLabel }: { title: string; onAdd: () => void; addLabel: string }) {
+ return (
+
+ {title}
+
+
+ );
+}
+
+function EmptyText({ children }: { children: React.ReactNode }) {
+ return (
+ {children}
+ );
+}
+
+function EmptyBlock({ title, subtitle }: { title: string; subtitle: string }) {
+ return (
+
+ );
+}
+
+function SkeletonRows({ count }: { count: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+function Divider() {
+ return ;
+}
+
+// --- Manual asset row ---------------------------------------------------
+
+function ManualAssetRow({
+ asset,
+ revaluing,
+ onRevalue,
+ onDelete,
+}: {
+ asset: ManualAsset;
+ revaluing: boolean;
+ onRevalue: () => void;
+ onDelete: () => void;
+}) {
+ const change = Number(asset.change || "0");
+ const positive = change >= 0;
+ const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
+ const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
+
+ return (
+
+
+
+
{asset.name}
+
+ {categoryLabel} · {conditionLabel}
+
+
+
+
{tugrikRaw(asset.value)}
+
+ {s.manualAssets.acquiredPrefix}: {tugrikRaw(asset.acquiredValue)}
+
+
+ {positive ? "+" : "−"}
+ {tugrikRaw(Math.abs(change))}
+
+
+
+
+
+
+
+
+ );
+}
+
+// --- Lending row ---------------------------------------------------------
+
+function statusLabel(loan: Lending): string {
+ if (loan.overdue) return s.lending.statusOverdue;
+ switch (loan.status) {
+ case "repaid":
+ return s.lending.statusPaid;
+ case "partial":
+ return s.lending.statusPartial;
+ default:
+ return s.lending.statusUnpaid;
+ }
+}
+
+function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
+ return (
+
+
+
{loan.person}
+
+ {statusLabel(loan)}
+
+
+
+
{tugrikRaw(loan.remaining)}
+
/ {tugrikRaw(loan.principal)}
+
+
+
+ );
+}
+
+// --- Add manual asset sheet ------------------------------------------------
+
+interface NewAssetValues {
+ name: string;
+ category: string;
+ value: string;
+ acquiredValue: string;
+ condition: string;
+}
+
+function AddManualAssetSheet({
+ onClose,
+ onSave,
+ saving,
+}: {
+ onClose: () => void;
+ onSave: (values: NewAssetValues) => void | Promise;
+ saving: boolean;
+}) {
+ const [name, setName] = React.useState("");
+ const [category, setCategory] = React.useState("car");
+ const [condition, setCondition] = React.useState("used");
+ const [price, setPrice] = React.useState("");
+
+ const canSave = name.trim().length > 0 && Number(price) > 0 && !saving;
+
+ return (
+ { if (!next) onClose(); }}>
+
+
+
+
+ {s.manualAssets.add}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {s.common.cancel}
+
+
+ onSave({
+ name: name.trim(),
+ category,
+ value: price,
+ acquiredValue: price,
+ condition,
+ })
+ }
+ >
+ {s.common.save}
+
+
+
+
+
+ );
+}
+
+// --- Add loan sheet ---------------------------------------------------------
+
+interface NewLoanValues {
+ person: string;
+ amount: string;
+ lentOn: string;
+ dueOn?: string;
+ note?: string;
+}
+
+function todayISO(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+function AddLoanSheet({
+ onClose,
+ onSave,
+ saving,
+}: {
+ onClose: () => void;
+ onSave: (values: NewLoanValues) => void | Promise;
+ saving: boolean;
+}) {
+ const [person, setPerson] = React.useState("");
+ const [amount, setAmount] = React.useState("");
+ const [lentOn, setLentOn] = React.useState(todayISO());
+ const [dueOn, setDueOn] = React.useState("");
+ const [note, setNote] = React.useState("");
+
+ const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
+
+ return (
+ { if (!next) onClose(); }}>
+
+
+
+
+ {s.lending.add}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {s.common.cancel}
+
+
+ onSave({
+ person: person.trim(),
+ amount,
+ lentOn,
+ dueOn: dueOn || undefined,
+ note: note.trim() || undefined,
+ })
+ }
+ >
+ {s.common.save}
+
+
+
+
+
+ );
+}
diff --git a/src/features/assets/ConfirmDialog.tsx b/src/features/assets/ConfirmDialog.tsx
new file mode 100644
index 0000000..9026aa8
--- /dev/null
+++ b/src/features/assets/ConfirmDialog.tsx
@@ -0,0 +1,74 @@
+"use client";
+
+import {
+ DialogRoot,
+ DialogBackdrop,
+ DialogPositioner,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogDescription,
+ DialogFooter,
+} from "@seed-design/react";
+import { MercuryButton } from "@/ds";
+import { assetsStrings as s } from "./strings";
+
+export interface ConfirmDialogProps {
+ open: boolean;
+ title: string;
+ description?: string;
+ confirmLabel?: string;
+ destructive?: boolean;
+ busy?: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+/**
+ * A confirmation dialog built on Seed's `Dialog` primitives — used for every
+ * destructive/irreversible action in the assets + lending feature (delete,
+ * revalue) instead of a native `window.confirm`, per Mercury's UI
+ * conventions (mirrors iOS's `seedDialog`).
+ */
+export function ConfirmDialog({
+ open,
+ title,
+ description,
+ confirmLabel = s.common.delete,
+ destructive = true,
+ busy = false,
+ onConfirm,
+ onCancel,
+}: ConfirmDialogProps) {
+ return (
+ {
+ if (!next) onCancel();
+ }}
+ >
+
+
+
+
+ {title}
+
+ {description && {description}}
+
+
+ {s.common.cancel}
+
+
+ {confirmLabel}
+
+
+
+
+
+ );
+}
diff --git a/src/features/assets/LendingDetail.tsx b/src/features/assets/LendingDetail.tsx
new file mode 100644
index 0000000..69debe2
--- /dev/null
+++ b/src/features/assets/LendingDetail.tsx
@@ -0,0 +1,260 @@
+"use client";
+
+import * as React from "react";
+import { useRouter } from "next/navigation";
+import {
+ BottomSheetRoot,
+ BottomSheetBackdrop,
+ BottomSheetPositioner,
+ BottomSheetContent,
+ BottomSheetHeader,
+ BottomSheetTitle,
+ BottomSheetBody,
+ BottomSheetFooter,
+ TextFieldRoot,
+ TextFieldInput,
+ Skeleton,
+} from "@seed-design/react";
+import { useLending } from "@/api/hooks/reads";
+import { useLendingMutations } from "@/api/hooks/mutations";
+import type { Lending } from "@/api/schemas";
+import { Card, MercuryButton } from "@/ds";
+import { tugrikRaw } from "@/ds/money";
+import { assetsStrings as s } from "./strings";
+import { ConfirmDialog } from "./ConfirmDialog";
+
+type LendingRepayment = Lending["repayments"][number];
+
+const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
+
+function todayISO(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+
+function statusLabel(loan: Lending): string {
+ if (loan.overdue) return s.lending.statusOverdue;
+ switch (loan.status) {
+ case "repaid":
+ return s.lending.statusPaid;
+ case "partial":
+ return s.lending.statusPartial;
+ default:
+ return s.lending.statusUnpaid;
+ }
+}
+
+export interface LendingDetailProps {
+ id: number;
+}
+
+export function LendingDetail({ id }: LendingDetailProps) {
+ const router = useRouter();
+ const lending = useLending();
+ const mutations = useLendingMutations();
+
+ const [addRepaymentOpen, setAddRepaymentOpen] = React.useState(false);
+ const [deletingRepayment, setDeletingRepayment] = React.useState(null);
+ const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false);
+
+ const loan: Lending | undefined = (lending.data ?? []).find((l) => l.id === id);
+
+ if (lending.isLoading && !loan) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!loan) {
+ return (
+
+ );
+ }
+
+ async function runDeleteEntry() {
+ setConfirmDeleteEntry(false);
+ await mutations.delete.mutateAsync(id);
+ router.push("/assets");
+ }
+
+ return (
+
+
+
+
+ {tugrikRaw(loan.remaining)}
+
+ {s.lending.total} {tugrikRaw(loan.principal)}
+
+
+ {statusLabel(loan)}
+
+
+
+
+
+
{s.lending.repayments.title}
+ {tugrikRaw(loan.repaid)}
+
+ {loan.repayments.length === 0 ? (
+ {s.lending.repayments.empty}
+ ) : (
+
+ {loan.repayments.map((r, i) => (
+
+ {i > 0 && }
+
+
+
{tugrikRaw(r.amount)}
+
+ {r.paidOn}
+ {r.note ? ` · ${r.note}` : ""}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+ setAddRepaymentOpen(true)}>
+ {s.lending.repayments.add}
+
+ setConfirmDeleteEntry(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
+ {s.lending.delete}
+
+
+
+ {addRepaymentOpen && (
+
setAddRepaymentOpen(false)}
+ saving={mutations.addRepayment.isPending}
+ onSave={async (values) => {
+ await mutations.addRepayment.mutateAsync({ id, ...values });
+ setAddRepaymentOpen(false);
+ }}
+ />
+ )}
+
+ setDeletingRepayment(null)}
+ onConfirm={async () => {
+ if (!deletingRepayment) return;
+ await mutations.deleteRepayment.mutateAsync({ id, repaymentId: deletingRepayment.id });
+ setDeletingRepayment(null);
+ }}
+ />
+
+ setConfirmDeleteEntry(false)}
+ onConfirm={runDeleteEntry}
+ />
+
+ );
+}
+
+function BackLink() {
+ return (
+
+ ← {s.common.back}
+
+ );
+}
+
+function AddRepaymentSheet({
+ onClose,
+ onSave,
+ saving,
+}: {
+ onClose: () => void;
+ onSave: (values: { amount: string; paidOn: string; note?: string }) => void | Promise;
+ saving: boolean;
+}) {
+ const [amount, setAmount] = React.useState("");
+ const [paidOn, setPaidOn] = React.useState(todayISO());
+ const [note, setNote] = React.useState("");
+
+ const canSave = Number(amount) > 0 && !saving;
+
+ return (
+ { if (!next) onClose(); }}>
+
+
+
+
+ {s.lending.repayments.add}
+
+
+
+
+
+
+
+
+
+
+
+
+ {s.common.cancel}
+
+ onSave({ amount, paidOn, note: note.trim() || undefined })}
+ >
+ {s.common.save}
+
+
+
+
+
+ );
+}
diff --git a/src/features/assets/ManualAssetDetail.tsx b/src/features/assets/ManualAssetDetail.tsx
new file mode 100644
index 0000000..99ec9a8
--- /dev/null
+++ b/src/features/assets/ManualAssetDetail.tsx
@@ -0,0 +1,262 @@
+"use client";
+
+import * as React from "react";
+import { useRouter } from "next/navigation";
+import { useQuery } from "@tanstack/react-query";
+import { z } from "zod";
+import { Skeleton } from "@seed-design/react";
+import { apiGet } from "@/api/client";
+import { useManualAssets } from "@/api/hooks/reads";
+import { useManualAssetMutations } from "@/api/hooks/mutations";
+import { AssetPointSchema, AssetListingSchema, type ManualAsset, type RevalueResult } from "@/api/schemas";
+import { Card, MercuryButton } from "@/ds";
+import { tugrikRaw, tugrikShortRaw } from "@/ds/money";
+import { assetsStrings as s } from "./strings";
+import { ValueHistoryChart } from "./ValueHistoryChart";
+import { ConfirmDialog } from "./ConfirmDialog";
+
+// --- Local reads (no hook exists yet for asset history/listings; these mirror
+// the shape of reads.ts's other hooks and hit the same endpoints iOS uses:
+// GET /manual-assets/{name}/history and /listings — see MercuryAPI.swift). ---
+
+const HistoryResponseSchema = z.object({ history: z.array(AssetPointSchema) });
+const ListingsResponseSchema = z.object({ listings: z.array(AssetListingSchema) });
+
+function useAssetHistory(name: string) {
+ return useQuery({
+ queryKey: ["assetHistory", name],
+ queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/history`, HistoryResponseSchema)).history,
+ });
+}
+
+function useAssetListings(name: string) {
+ return useQuery({
+ queryKey: ["assetListings", name],
+ queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/listings`, ListingsResponseSchema)).listings,
+ });
+}
+
+const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
+
+export interface ManualAssetDetailProps {
+ name: string;
+}
+
+export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
+ const router = useRouter();
+ const manualAssets = useManualAssets();
+ const history = useAssetHistory(name);
+ const listings = useAssetListings(name);
+ const mutations = useManualAssetMutations();
+
+ const [confirmRevalue, setConfirmRevalue] = React.useState(false);
+ const [confirmDelete, setConfirmDelete] = React.useState(false);
+ const [lastFinding, setLastFinding] = React.useState(null);
+ const [revalueFailed, setRevalueFailed] = React.useState(false);
+
+ const asset: ManualAsset | undefined = (manualAssets.data ?? []).find((a) => a.name === name);
+
+ async function runRevalue() {
+ setConfirmRevalue(false);
+ setRevalueFailed(false);
+ const result = await mutations.revalue.mutateAsync(name);
+ setLastFinding(result ?? null);
+ setRevalueFailed(!result);
+ await Promise.all([history.refetch(), listings.refetch()]);
+ }
+
+ async function runDelete() {
+ setConfirmDelete(false);
+ await mutations.delete.mutateAsync(name);
+ router.push("/assets");
+ }
+
+ if (manualAssets.isLoading && !asset) {
+ return (
+
+
+
+
+ );
+ }
+
+ if (!asset) {
+ return (
+
+ );
+ }
+
+ const value = Number(asset.value || "0");
+ const acquired = Number(asset.acquiredValue || "0");
+ const change = Number(asset.change || "0");
+ const positive = change >= 0;
+ const percent = acquired > 0 ? (change / acquired) * 100 : null;
+
+ const points = (history.data ?? [])
+ .map((p) => ({ date: new Date(p.recordedAt), value: Number(p.value) }))
+ .filter((p) => !Number.isNaN(p.date.getTime()) && !Number.isNaN(p.value))
+ .sort((a, b) => a.date.getTime() - b.date.getTime());
+
+ const fetchPoints = (history.data ?? [])
+ .filter((p) => p.kind === "fetch")
+ .slice()
+ .sort((a, b) => (a.recordedAt < b.recordedAt ? 1 : -1));
+
+ const listingRows = listings.data ?? [];
+ const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
+ const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
+
+ return (
+
+
+
+
+ {categoryLabel} · {conditionLabel}
+ {tugrikRaw(value)}
+
+ {positive ? "+" : "−"}
+ {tugrikRaw(Math.abs(change))}
+ {percent !== null && ` (${positive ? "+" : "−"}${Math.abs(percent).toFixed(1)}%)`}
+
+
+
+ {lastFinding && (
+
+
+ {lastFinding.source} — {lastFinding.count} {s.assetDetail.findingSuffix}
+
+
+ {tugrikShortRaw(lastFinding.low)}–{tugrikShortRaw(lastFinding.high)} · {s.assetDetail.avg}{" "}
+ {tugrikShortRaw(lastFinding.value)}
+
+
+ )}
+ {revalueFailed && !lastFinding && (
+
+ {s.assetDetail.notFound}
+
+ )}
+
+
+ {s.assetDetail.chartTitle}
+ {history.isLoading ? : }
+
+
+ {!history.isLoading && (
+
+ {s.assetDetail.research}
+ {fetchPoints.length > 0 && (
+
+ {fetchPoints.map((p, i) => (
+
+ {p.recordedAt.slice(0, 10)}
+
+ {p.low && p.high ? `${tugrikShortRaw(p.low)}–${tugrikShortRaw(p.high)} · ` : ""}
+ {p.count ?? 0} зар
+
+ {tugrikShortRaw(p.value)}
+
+ ))}
+
+ )}
+ {s.assetDetail.listings(listingRows.length)}
+ {listingRows.length === 0 ? (
+ {s.assetDetail.listingsEmpty}
+ ) : (
+
+ )}
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ setConfirmRevalue(true)} disabled={mutations.revalue.isPending}>
+ {s.manualAssets.revalue}
+
+ setConfirmDelete(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
+ {s.manualAssets.delete}
+
+
+
+
setConfirmRevalue(false)}
+ onConfirm={runRevalue}
+ />
+ setConfirmDelete(false)}
+ onConfirm={runDelete}
+ />
+
+ );
+}
+
+function BackLink() {
+ return (
+
+ ← {s.common.back}
+
+ );
+}
+
+function DetailRow({ label, value, color }: { label: string; value: string; color?: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function Divider() {
+ return ;
+}
diff --git a/src/features/assets/ValueHistoryChart.tsx b/src/features/assets/ValueHistoryChart.tsx
new file mode 100644
index 0000000..a7ec264
--- /dev/null
+++ b/src/features/assets/ValueHistoryChart.tsx
@@ -0,0 +1,84 @@
+import { assetsStrings as s } from "./strings";
+
+export interface ValueHistoryPoint {
+ date: Date;
+ value: number;
+}
+
+export interface ValueHistoryChartProps {
+ points: ValueHistoryPoint[];
+ positive: boolean;
+}
+
+const WIDTH = 320;
+const HEIGHT = 160;
+const PAD_X = 6;
+const PAD_Y = 12;
+
+/**
+ * A minimal, dependency-light line chart (inline SVG, no charting library)
+ * mirroring the value-over-time chart in
+ * ios/Mercury/Features/Assets/AssetDetailView.swift (`chart` / `chartCard`):
+ * a catmull-rom-ish straight-segment line through ascending (date, value)
+ * points, y-padded ~8%, with a dot per point. Below 2 points it falls back
+ * to the same empty-state copy as iOS.
+ */
+export function ValueHistoryChart({ points, positive }: ValueHistoryChartProps) {
+ if (points.length < 2) {
+ return (
+
+
{s.assetDetail.chartEmptyTitle}
+
+ {s.assetDetail.chartEmptySubtitle}
+
+
+ );
+ }
+
+ const values = points.map((p) => p.value);
+ const lo = Math.min(...values);
+ const hi = Math.max(...values);
+ const pad = Math.max((hi - lo) * 0.08, hi * 0.02, 1);
+ const yMin = lo - pad;
+ const yMax = hi + pad;
+
+ const minTime = points[0].date.getTime();
+ const maxTime = points[points.length - 1].date.getTime();
+ const timeSpan = Math.max(maxTime - minTime, 1);
+
+ const xFor = (t: number) => PAD_X + ((t - minTime) / timeSpan) * (WIDTH - PAD_X * 2);
+ const yFor = (v: number) => HEIGHT - PAD_Y - ((v - yMin) / (yMax - yMin)) * (HEIGHT - PAD_Y * 2);
+
+ const linePath = points
+ .map((p, i) => `${i === 0 ? "M" : "L"} ${xFor(p.date.getTime()).toFixed(1)} ${yFor(p.value).toFixed(1)}`)
+ .join(" ");
+
+ const lineColor = positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)";
+
+ return (
+
+ );
+}
diff --git a/src/features/assets/strings.ts b/src/features/assets/strings.ts
new file mode 100644
index 0000000..a406ce7
--- /dev/null
+++ b/src/features/assets/strings.ts
@@ -0,0 +1,106 @@
+// Assets + Lending feature copy, ported from
+// ios/Mercury/Features/Assets/*.swift and ios/Mercury/Features/Lending/*.swift.
+// The net-worth header has no direct iOS analogue (there it only backs the
+// Home balance card) — labels there use standard Mongolian finance terms.
+export const assetsStrings = {
+ header: {
+ title: "Хөрөнгө",
+ },
+ netWorth: {
+ total: "Цэвэр хөрөнгө",
+ assets: "Хөрөнгө",
+ liabilities: "Өр төлбөр",
+ },
+ accounts: {
+ title: "Миний дансууд",
+ empty: "Холбосон данс алга",
+ },
+ manualAssets: {
+ title: "Хөрөнгийн жагсаалт",
+ totalLabel: "Нийт хөрөнгийн үнэлгээ",
+ add: "Хөрөнгө нэмэх",
+ edit: "Хөрөнгө засах",
+ emptyTitle: "Хөрөнгө бүртгээгүй байна",
+ emptySubtitle: "Машин, утас зэрэг хөрөнгөө нэмэхийн тулд + дарна уу",
+ acquiredPrefix: "Авсан",
+ revalue: "Зах зээлийн үнэ шинэчлэх",
+ revalueTitle: "Зах зээлийн үнэ шинэчлэх үү?",
+ revalueDescription: "Зар хайж, хамгийн сүүлийн зах зээлийн үнийг тооцно.",
+ delete: "Устгах",
+ deleteTitle: "Хөрөнгө устгах уу?",
+ deleteDescription: (name: string) => `«${name}» жагсаалтаас хасагдана.`,
+ fields: {
+ name: "Хөрөнгийн нэр",
+ category: "Ангилал",
+ condition: "Байдал",
+ price: "Авсан үнэ",
+ },
+ categories: {
+ car: "Машин",
+ electronics: "Электрон",
+ property: "Үл хөдлөх",
+ other: "Бус",
+ } as Record,
+ conditions: {
+ new: "Шинэ",
+ used: "Хуучин",
+ } as Record,
+ },
+ assetDetail: {
+ acquiredValue: "Авсан үнэ",
+ currentValue: "Одоогийн үнэ",
+ change: "Өөрчлөлт",
+ category: "Ангилал",
+ condition: "Нөхцөл",
+ chartTitle: "Үнийн түүх",
+ chartEmptyTitle: "Үнийн түүх хомс",
+ chartEmptySubtitle: "Зах зээлийн үнэ шинэчлэх бүрт нэг цэг нэмэгдэнэ",
+ research: "Зах зээлийн судалгаа",
+ listings: (count: number) => `Зар (${count})`,
+ listingsEmpty: "Зар олдоогүй — үнэ шинэчилнэ үү",
+ findingSuffix: "зар олдлоо",
+ avg: "дунджаар",
+ notFound: "Зах зээлийн үнэ олдсонгүй",
+ },
+ lending: {
+ title: "Найзуудад өгсөн зээл",
+ empty: "Зээл бүртгэгдээгүй байна",
+ add: "Шинэ зээл",
+ total: "нийт",
+ statusPaid: "Төлсөн",
+ statusPartial: "Хэсэгчлэн төлсөн",
+ statusUnpaid: "Төлөөгүй",
+ statusOverdue: "Хугацаа хэтэрсэн",
+ deleteTitle: "Зээл устгах уу?",
+ deleteDescription: (person: string) => `«${person}»-д өгсөн зээл устана.`,
+ delete: "Зээл устгах",
+ fields: {
+ person: "Хэнд өгсөн",
+ amount: "Дүн",
+ lentOn: "Өгсөн огноо",
+ dueOn: "Төлөх огноо",
+ note: "Тэмдэглэл",
+ },
+ repayments: {
+ title: "Төлөлтийн түүх",
+ empty: "Төлөлт бүртгэгдээгүй",
+ add: "Төлөлт нэмэх",
+ deleteTitle: "Төлөлт устгах уу?",
+ deleteDescription: "Энэ төлөлтийг түүхээс хасна.",
+ },
+ },
+ accountDetail: {
+ balance: "Үлдэгдэл",
+ accountNumber: "Дансны дугаар",
+ bank: "Банк",
+ currency: "Валют",
+ recentTransactions: "Сүүлийн гүйлгээ",
+ noTransactions: "Гүйлгээ алга",
+ },
+ common: {
+ save: "Хадгалах",
+ cancel: "Болих",
+ delete: "Устгах",
+ back: "Буцах",
+ },
+} as const;
diff --git a/src/features/assets/useHideAmountsTick.ts b/src/features/assets/useHideAmountsTick.ts
new file mode 100644
index 0000000..d2a6423
--- /dev/null
+++ b/src/features/assets/useHideAmountsTick.ts
@@ -0,0 +1,24 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { HIDE_AMOUNTS_EVENT } from "@/ds";
+
+/**
+ * `tugrik()` / `tugrikShort()` (web/src/ds/money.ts) read the global
+ * hide-amounts flag from localStorage synchronously, so a component that
+ * calls them needs a reason to re-render when `HideAmountsToggle` flips it.
+ * Call this in any component that formats money with those helpers.
+ */
+export function useHideAmountsTick(): number {
+ const [tick, setTick] = useState(0);
+
+ useEffect(() => {
+ function onChange() {
+ setTick((t) => t + 1);
+ }
+ window.addEventListener(HIDE_AMOUNTS_EVENT, onChange);
+ return () => window.removeEventListener(HIDE_AMOUNTS_EVENT, onChange);
+ }, []);
+
+ return tick;
+}