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.header.title}

+ +
+ + + +
+ {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 ( +
+
{title}
+
{subtitle}
+
+ ); +} + +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 ( +
+ +

{s.lending.empty}

+
+ ); + } + + async function runDeleteEntry() { + setConfirmDeleteEntry(false); + await mutations.delete.mutateAsync(id); + router.push("/assets"); + } + + return ( +
+
+ +

{loan.person}

+
+ + +
{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 ( +
+ +

{name}

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

{asset.name}

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

+ ) : ( +
+ {listingRows.slice(0, 15).map((l, i) => ( + + {l.title} + {tugrikShortRaw(l.price)} + + ))} +
+ )} +
+ )} + + + + + + + + + + + + + +
+ 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 ( + + + {points.map((p, i) => ( + + ))} + + ); +} 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; +}