merge task/t11: assets, net worth, manual assets, lending

This commit is contained in:
Munkherdene 2026-08-22 21:00:44 +08:00
commit beb1915bd8
13 changed files with 1573 additions and 0 deletions

View file

@ -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 <AccountDetail accountId={Number(id)} />;
}

View file

@ -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 <LendingDetail id={Number(id)} />;
}

View file

@ -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 <ManualAssetDetail name={decodeURIComponent(name)} />;
}

View file

@ -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 <AssetsView />;
}

View file

@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{account?.bank ?? s.accountDetail.bank}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ fontSize: 12, ...mutedStyle }}>{account?.accountNumber}</div>
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(account?.balance ?? "0")}</div>
</Card>
<Card style={{ padding: "6px 20px" }}>
<DetailRow label={s.accountDetail.bank} value={account?.bank ?? "—"} />
<Divider />
<DetailRow label={s.accountDetail.accountNumber} value={account?.accountNumber ?? "—"} />
<Divider />
<DetailRow label={s.accountDetail.currency} value={account?.currency ?? "—"} />
</Card>
<section>
<h3 style={{ margin: "0 0 10px", fontSize: 14, fontWeight: 700 }}>{s.accountDetail.recentTransactions}</h3>
{transactions.isLoading ? (
<p style={{ fontSize: 13, ...mutedStyle }}></p>
) : (transactions.data ?? []).length === 0 ? (
<p style={{ fontSize: 13, ...mutedStyle }}>{s.accountDetail.noTransactions}</p>
) : (
<Card style={{ padding: 0 }}>
{(transactions.data ?? []).map((txn, i) => {
const income = txn.direction === "income";
return (
<React.Fragment key={i}>
{i > 0 && <Divider />}
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 16px" }}>
<div>
<div style={{ fontWeight: 600 }}>{txn.title || txn.category}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>{txn.date.slice(0, 10)}</div>
</div>
<div
style={{
fontWeight: 700,
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{income ? "+" : ""}
{tugrikRaw(txn.amount)}
</div>
</div>
</React.Fragment>
);
})}
</Card>
)}
</section>
</div>
);
}
function BackLink() {
return (
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
{s.common.back}
</a>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
<span style={mutedStyle}>{label}</span>
<span style={{ fontWeight: 600 }}>{value}</span>
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
}

View file

@ -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(<AssetsView />);
expect(screen.getByText(tugrik("1250000"))).toBeInTheDocument();
expect(screen.getByText("Toyota Prius")).toBeInTheDocument();
});
});

View file

@ -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<string | null>(null);
const [revalueAssetName, setRevalueAssetName] = React.useState<string | null>(null);
const [deleteLoan, setDeleteLoan] = React.useState<Lending | null>(null);
const accounts: Account[] = netWorth.data?.accounts ?? [];
const assets: ManualAsset[] = manualAssets.data ?? [];
const loans: Lending[] = lending.data ?? [];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{s.header.title}</h1>
<HideAmountsToggle />
</header>
<NetWorthCard netWorth={netWorth.data} loading={netWorth.isLoading} />
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionTitle>{s.accounts.title}</SectionTitle>
{netWorth.isLoading ? (
<SkeletonRows count={2} />
) : accounts.length === 0 ? (
<EmptyText>{s.accounts.empty}</EmptyText>
) : (
<Card style={{ padding: 0 }}>
{accounts.map((account, i) => (
<React.Fragment key={account.accountId}>
{i > 0 && <Divider />}
<Link
href={`/assets/account/${account.accountId}`}
style={{ ...rowStyle, color: "inherit", textDecoration: "none" }}
>
<div>
<div style={{ fontWeight: 600 }}>{account.bank}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>{account.accountNumber}</div>
</div>
<div style={{ fontWeight: 700 }}>{tugrik(account.balance)}</div>
</Link>
</React.Fragment>
))}
</Card>
)}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeaderRow title={s.manualAssets.title} onAdd={() => setAddAssetOpen(true)} addLabel={s.manualAssets.add} />
{manualAssets.isLoading ? (
<SkeletonRows count={3} />
) : assets.length === 0 ? (
<EmptyBlock title={s.manualAssets.emptyTitle} subtitle={s.manualAssets.emptySubtitle} />
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{assets.map((asset) => (
<ManualAssetRow
key={asset.name}
asset={asset}
revaluing={assetMutations.revalue.isPending && revalueAssetName === asset.name}
onRevalue={() => setRevalueAssetName(asset.name)}
onDelete={() => setDeleteAssetName(asset.name)}
/>
))}
</div>
)}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeaderRow title={s.lending.title} onAdd={() => setAddLoanOpen(true)} addLabel={s.lending.add} />
{lending.isLoading ? (
<SkeletonRows count={2} />
) : loans.length === 0 ? (
<EmptyText>{s.lending.empty}</EmptyText>
) : (
<Card style={{ padding: 0 }}>
{loans.map((loan, i) => (
<React.Fragment key={loan.id}>
{i > 0 && <Divider />}
<LoanRow loan={loan} onDelete={() => setDeleteLoan(loan)} />
</React.Fragment>
))}
</Card>
)}
</section>
{addAssetOpen && (
<AddManualAssetSheet
onClose={() => setAddAssetOpen(false)}
saving={assetMutations.add.isPending}
onSave={async (values) => {
await assetMutations.add.mutateAsync(values);
setAddAssetOpen(false);
}}
/>
)}
{addLoanOpen && (
<AddLoanSheet
onClose={() => setAddLoanOpen(false)}
saving={lendingMutations.create.isPending}
onSave={async (values) => {
await lendingMutations.create.mutateAsync(values);
setAddLoanOpen(false);
}}
/>
)}
<ConfirmDialog
open={deleteAssetName !== null}
title={s.manualAssets.deleteTitle}
description={deleteAssetName ? s.manualAssets.deleteDescription(deleteAssetName) : undefined}
busy={assetMutations.delete.isPending}
onCancel={() => setDeleteAssetName(null)}
onConfirm={async () => {
if (!deleteAssetName) return;
await assetMutations.delete.mutateAsync(deleteAssetName);
setDeleteAssetName(null);
}}
/>
<ConfirmDialog
open={revalueAssetName !== null}
title={s.manualAssets.revalueTitle}
description={s.manualAssets.revalueDescription}
confirmLabel={s.manualAssets.revalue}
destructive={false}
busy={assetMutations.revalue.isPending}
onCancel={() => setRevalueAssetName(null)}
onConfirm={async () => {
const name = revalueAssetName;
if (!name) return;
setRevalueAssetName(null);
await assetMutations.revalue.mutateAsync(name);
}}
/>
<ConfirmDialog
open={deleteLoan !== null}
title={s.lending.deleteTitle}
description={deleteLoan ? s.lending.deleteDescription(deleteLoan.person) : undefined}
busy={lendingMutations.delete.isPending}
onCancel={() => setDeleteLoan(null)}
onConfirm={async () => {
if (!deleteLoan) return;
await lendingMutations.delete.mutateAsync(deleteLoan.id);
setDeleteLoan(null);
}}
/>
</div>
);
}
// --- Net worth header -------------------------------------------------------
function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; assets: string; liabilities: string }; loading: boolean }) {
return (
<Card style={{ padding: "24px 20px", background: "var(--mercury-balance-card)" }}>
{loading ? (
<Skeleton height="40px" />
) : (
<>
<div style={{ fontSize: 13, opacity: 0.75 }}>{s.netWorth.total}</div>
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 6 }}>{tugrik(netWorth?.total ?? "0")}</div>
<div style={{ display: "flex", gap: 20, marginTop: 16 }}>
<div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.assets}</div>
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.assets ?? "0")}</div>
</div>
<div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.liabilities}</div>
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.liabilities ?? "0")}</div>
</div>
</div>
</>
)}
</Card>
);
}
// --- Small shared bits -------------------------------------------------------
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{children}</h2>;
}
function SectionHeaderRow({ title, onAdd, addLabel }: { title: string; onAdd: () => void; addLabel: string }) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SectionTitle>{title}</SectionTitle>
<button
type="button"
onClick={onAdd}
aria-label={addLabel}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 22, lineHeight: 1, padding: 4 }}
>
+
</button>
</div>
);
}
function EmptyText({ children }: { children: React.ReactNode }) {
return (
<p style={{ ...mutedStyle, fontSize: 13, padding: "12px 4px", margin: 0 }}>{children}</p>
);
}
function EmptyBlock({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div style={{ textAlign: "center", padding: "40px 20px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontWeight: 600 }}>{title}</div>
<div style={{ fontSize: 13, ...mutedStyle }}>{subtitle}</div>
</div>
);
}
function SkeletonRows({ count }: { count: number }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} height="64px" />
))}
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
}
// --- 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 (
<Card style={{ padding: 0 }}>
<div style={rowStyle}>
<Link href={`/assets/manual/${encodeURIComponent(asset.name)}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
<div style={{ fontWeight: 700 }}>{asset.name}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>
{categoryLabel} · {conditionLabel}
</div>
</Link>
<div style={{ textAlign: "right" }}>
<div style={{ fontWeight: 700 }}>{tugrikRaw(asset.value)}</div>
<div style={{ fontSize: 11, ...mutedStyle }}>
{s.manualAssets.acquiredPrefix}: {tugrikRaw(asset.acquiredValue)}
</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{positive ? "+" : ""}
{tugrikRaw(Math.abs(change))}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, padding: "0 16px 12px" }}>
<button
type="button"
onClick={onRevalue}
disabled={revaluing}
style={{
flex: 1,
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
borderRadius: 8,
background: "none",
padding: "6px 10px",
fontSize: 12,
cursor: revaluing ? "default" : "pointer",
}}
>
{revaluing ? "…" : s.manualAssets.revalue}
</button>
<button
type="button"
onClick={onDelete}
style={{
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
borderRadius: 8,
background: "none",
padding: "6px 10px",
fontSize: 12,
color: "var(--seed-color-fg-critical)",
cursor: "pointer",
}}
>
{s.manualAssets.delete}
</button>
</div>
</Card>
);
}
// --- 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 (
<div style={rowStyle}>
<Link href={`/assets/lending/${loan.id}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
<div style={{ fontWeight: 600 }}>{loan.person}</div>
<div style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
{statusLabel(loan)}
</div>
</Link>
<div style={{ textAlign: "right" }}>
<div style={{ fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>/ {tugrikRaw(loan.principal)}</div>
</div>
<button
type="button"
onClick={onDelete}
aria-label={s.lending.delete}
style={{ background: "none", border: "none", color: "var(--seed-color-fg-neutral-subtle)", cursor: "pointer", fontSize: 16 }}
>
×
</button>
</div>
);
}
// --- 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<void>;
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 (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.manualAssets.add}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={name} onValueChange={setName} name="asset-name">
<TextFieldInput placeholder={s.manualAssets.fields.name} aria-label={s.manualAssets.fields.name} autoFocus />
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.manualAssets.fields.category}
<select value={category} onChange={(e) => setCategory(e.target.value)} style={{ padding: 8, borderRadius: 8 }}>
{Object.entries(s.manualAssets.categories).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<TextFieldRoot value={price} onValueChange={setPrice} name="asset-price">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.manualAssets.fields.price}
aria-label={s.manualAssets.fields.price}
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.manualAssets.fields.condition}
<select value={condition} onChange={(e) => setCondition(e.target.value)} style={{ padding: 8, borderRadius: 8 }}>
{Object.entries(s.manualAssets.conditions).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() =>
onSave({
name: name.trim(),
category,
value: price,
acquiredValue: price,
condition,
})
}
>
{s.common.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}
// --- 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<void>;
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 (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.lending.add}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={person} onValueChange={setPerson} name="loan-person">
<TextFieldInput placeholder={s.lending.fields.person} aria-label={s.lending.fields.person} autoFocus />
</TextFieldRoot>
<TextFieldRoot value={amount} onValueChange={setAmount} name="loan-amount">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.lending.fields.amount}
aria-label={s.lending.fields.amount}
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.lentOn}
<input type="date" value={lentOn} onChange={(e) => setLentOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.dueOn}
<input type="date" value={dueOn} onChange={(e) => setDueOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
<TextFieldRoot value={note} onValueChange={setNote} name="loan-note">
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
</TextFieldRoot>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() =>
onSave({
person: person.trim(),
amount,
lentOn,
dueOn: dueOn || undefined,
note: note.trim() || undefined,
})
}
>
{s.common.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}

View file

@ -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 (
<DialogRoot
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<DialogBackdrop />
<DialogPositioner>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{description && <DialogDescription>{description}</DialogDescription>}
<DialogFooter style={{ display: "flex", gap: 12, paddingTop: 16 }}>
<MercuryButton variant="secondary" onClick={onCancel} style={{ flex: 1 }} disabled={busy}>
{s.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
onClick={onConfirm}
loading={busy}
style={{ flex: 1, ...(destructive ? { background: "var(--seed-color-fg-critical)", color: "#fff" } : {}) }}
>
{confirmLabel}
</MercuryButton>
</DialogFooter>
</DialogContent>
</DialogPositioner>
</DialogRoot>
);
}

View file

@ -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<LendingRepayment | null>(null);
const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false);
const loan: Lending | undefined = (lending.data ?? []).find((l) => l.id === id);
if (lending.isLoading && !loan) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Skeleton height="40px" />
<Skeleton height="140px" />
</div>
);
}
if (!loan) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackLink />
<p style={mutedStyle}>{s.lending.empty}</p>
</div>
);
}
async function runDeleteEntry() {
setConfirmDeleteEntry(false);
await mutations.delete.mutateAsync(id);
router.push("/assets");
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{loan.person}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ fontSize: 32, fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
<div style={{ marginTop: 6, ...mutedStyle }}>
{s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong>
</div>
<div
style={{
display: "inline-block",
marginTop: 10,
padding: "5px 12px",
borderRadius: 999,
fontSize: 12,
fontWeight: 600,
background: "var(--seed-color-bg-layer-floating)",
color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)",
}}
>
{statusLabel(loan)}
</div>
</Card>
<Card>
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.lending.repayments.title}</h3>
<strong style={{ color: "var(--seed-color-fg-positive)" }}>{tugrikRaw(loan.repaid)}</strong>
</div>
{loan.repayments.length === 0 ? (
<p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.lending.repayments.empty}</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
{loan.repayments.map((r, i) => (
<React.Fragment key={r.id}>
{i > 0 && <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 0" }}>
<div>
<div style={{ fontWeight: 600 }}>{tugrikRaw(r.amount)}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>
{r.paidOn}
{r.note ? ` · ${r.note}` : ""}
</div>
</div>
<button
type="button"
onClick={() => setDeletingRepayment(r)}
aria-label={s.lending.repayments.deleteTitle}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--seed-color-fg-neutral-subtle)" }}
>
×
</button>
</div>
</React.Fragment>
))}
</div>
)}
</Card>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<MercuryButton variant="primary" onClick={() => setAddRepaymentOpen(true)}>
{s.lending.repayments.add}
</MercuryButton>
<MercuryButton variant="ghost" onClick={() => setConfirmDeleteEntry(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
{s.lending.delete}
</MercuryButton>
</div>
{addRepaymentOpen && (
<AddRepaymentSheet
onClose={() => setAddRepaymentOpen(false)}
saving={mutations.addRepayment.isPending}
onSave={async (values) => {
await mutations.addRepayment.mutateAsync({ id, ...values });
setAddRepaymentOpen(false);
}}
/>
)}
<ConfirmDialog
open={deletingRepayment !== null}
title={s.lending.repayments.deleteTitle}
description={s.lending.repayments.deleteDescription}
busy={mutations.deleteRepayment.isPending}
onCancel={() => setDeletingRepayment(null)}
onConfirm={async () => {
if (!deletingRepayment) return;
await mutations.deleteRepayment.mutateAsync({ id, repaymentId: deletingRepayment.id });
setDeletingRepayment(null);
}}
/>
<ConfirmDialog
open={confirmDeleteEntry}
title={s.lending.deleteTitle}
description={s.lending.deleteDescription(loan.person)}
busy={mutations.delete.isPending}
onCancel={() => setConfirmDeleteEntry(false)}
onConfirm={runDeleteEntry}
/>
</div>
);
}
function BackLink() {
return (
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
{s.common.back}
</a>
);
}
function AddRepaymentSheet({
onClose,
onSave,
saving,
}: {
onClose: () => void;
onSave: (values: { amount: string; paidOn: string; note?: string }) => void | Promise<void>;
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 (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.lending.repayments.add}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={amount} onValueChange={setAmount} name="repayment-amount">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.lending.fields.amount}
aria-label={s.lending.fields.amount}
autoFocus
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.lentOn}
<input type="date" value={paidOn} onChange={(e) => setPaidOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
<TextFieldRoot value={note} onValueChange={setNote} name="repayment-note">
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
</TextFieldRoot>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined })}
>
{s.common.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}

View file

@ -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<RevalueResult | null>(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 (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Skeleton height="40px" />
<Skeleton height="160px" />
</div>
);
}
if (!asset) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackLink />
<p style={mutedStyle}>{name}</p>
</div>
);
}
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 (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{asset.name}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ fontSize: 12, ...mutedStyle }}>{categoryLabel} · {conditionLabel}</div>
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(value)}</div>
<div
style={{
marginTop: 6,
fontWeight: 600,
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{positive ? "+" : ""}
{tugrikRaw(Math.abs(change))}
{percent !== null && ` (${positive ? "+" : ""}${Math.abs(percent).toFixed(1)}%)`}
</div>
</Card>
{lastFinding && (
<Card style={{ background: "color-mix(in srgb, var(--seed-color-fg-positive) 10%, transparent)" }}>
<div style={{ fontWeight: 600 }}>
{lastFinding.source} {lastFinding.count} {s.assetDetail.findingSuffix}
</div>
<div style={{ fontSize: 13, marginTop: 4 }}>
{tugrikShortRaw(lastFinding.low)}{tugrikShortRaw(lastFinding.high)} · {s.assetDetail.avg}{" "}
<strong>{tugrikShortRaw(lastFinding.value)}</strong>
</div>
</Card>
)}
{revalueFailed && !lastFinding && (
<Card>
<div style={{ fontWeight: 600 }}>{s.assetDetail.notFound}</div>
</Card>
)}
<Card>
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.chartTitle}</h3>
{history.isLoading ? <Skeleton height="160px" /> : <ValueHistoryChart points={points} positive={positive} />}
</Card>
{!history.isLoading && (
<Card>
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.research}</h3>
{fetchPoints.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 12 }}>
{fetchPoints.map((p, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13 }}>
<span>{p.recordedAt.slice(0, 10)}</span>
<span style={{ ...mutedStyle }}>
{p.low && p.high ? `${tugrikShortRaw(p.low)}${tugrikShortRaw(p.high)} · ` : ""}
{p.count ?? 0} зар
</span>
<strong>{tugrikShortRaw(p.value)}</strong>
</div>
))}
</div>
)}
<h4 style={{ margin: "0 0 8px", fontSize: 13, fontWeight: 700 }}>{s.assetDetail.listings(listingRows.length)}</h4>
{listingRows.length === 0 ? (
<p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.assetDetail.listingsEmpty}</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{listingRows.slice(0, 15).map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noreferrer"
style={{ display: "flex", justifyContent: "space-between", gap: 10, color: "inherit", textDecoration: "none" }}
>
<span style={{ fontSize: 13, flex: 1 }}>{l.title}</span>
<strong style={{ fontSize: 13, whiteSpace: "nowrap" }}>{tugrikShortRaw(l.price)}</strong>
</a>
))}
</div>
)}
</Card>
)}
<Card style={{ padding: "6px 20px" }}>
<DetailRow label={s.assetDetail.acquiredValue} value={tugrikRaw(acquired)} />
<Divider />
<DetailRow label={s.assetDetail.currentValue} value={tugrikRaw(value)} />
<Divider />
<DetailRow
label={s.assetDetail.change}
value={`${positive ? "+" : ""}${tugrikRaw(Math.abs(change))}`}
color={positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)"}
/>
<Divider />
<DetailRow label={s.assetDetail.category} value={categoryLabel} />
<Divider />
<DetailRow label={s.assetDetail.condition} value={conditionLabel} />
</Card>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<MercuryButton variant="primary" onClick={() => setConfirmRevalue(true)} disabled={mutations.revalue.isPending}>
{s.manualAssets.revalue}
</MercuryButton>
<MercuryButton variant="ghost" onClick={() => setConfirmDelete(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
{s.manualAssets.delete}
</MercuryButton>
</div>
<ConfirmDialog
open={confirmRevalue}
title={s.manualAssets.revalueTitle}
description={s.manualAssets.revalueDescription}
confirmLabel={s.manualAssets.revalue}
destructive={false}
busy={mutations.revalue.isPending}
onCancel={() => setConfirmRevalue(false)}
onConfirm={runRevalue}
/>
<ConfirmDialog
open={confirmDelete}
title={s.manualAssets.deleteTitle}
description={s.manualAssets.deleteDescription(asset.name)}
busy={mutations.delete.isPending}
onCancel={() => setConfirmDelete(false)}
onConfirm={runDelete}
/>
</div>
);
}
function BackLink() {
return (
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
{s.common.back}
</a>
);
}
function DetailRow({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
<span style={mutedStyle}>{label}</span>
<span style={{ fontWeight: 600, color }}>{value}</span>
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
}

View file

@ -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 (
<div
style={{
height: HEIGHT,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
textAlign: "center",
}}
>
<p style={{ margin: 0, fontWeight: 600 }}>{s.assetDetail.chartEmptyTitle}</p>
<p style={{ margin: 0, fontSize: 12, color: "var(--seed-color-fg-neutral-subtle)" }}>
{s.assetDetail.chartEmptySubtitle}
</p>
</div>
);
}
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 (
<svg
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
width="100%"
height={HEIGHT}
preserveAspectRatio="none"
role="img"
aria-label={s.assetDetail.chartTitle}
style={{ display: "block", overflow: "visible" }}
>
<path d={linePath} fill="none" stroke={lineColor} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
{points.map((p, i) => (
<circle key={i} cx={xFor(p.date.getTime())} cy={yFor(p.value)} r={3} fill="var(--mercury-brand-yellow)" />
))}
</svg>
);
}

View file

@ -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<string, string>,
conditions: {
new: "Шинэ",
used: "Хуучин",
} as Record<string, string>,
},
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;

View file

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