merge task/t11: assets, net worth, manual assets, lending
This commit is contained in:
commit
beb1915bd8
13 changed files with 1573 additions and 0 deletions
6
src/app/(app)/assets/account/[id]/page.tsx
Normal file
6
src/app/(app)/assets/account/[id]/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
6
src/app/(app)/assets/lending/[id]/page.tsx
Normal file
6
src/app/(app)/assets/lending/[id]/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
6
src/app/(app)/assets/manual/[name]/page.tsx
Normal file
6
src/app/(app)/assets/manual/[name]/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
7
src/app/(app)/assets/page.tsx
Normal file
7
src/app/(app)/assets/page.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
104
src/features/assets/AccountDetail.tsx
Normal file
104
src/features/assets/AccountDetail.tsx
Normal 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)" }} />;
|
||||||
|
}
|
||||||
52
src/features/assets/AssetsView.test.tsx
Normal file
52
src/features/assets/AssetsView.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
582
src/features/assets/AssetsView.tsx
Normal file
582
src/features/assets/AssetsView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
src/features/assets/ConfirmDialog.tsx
Normal file
74
src/features/assets/ConfirmDialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
src/features/assets/LendingDetail.tsx
Normal file
260
src/features/assets/LendingDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
262
src/features/assets/ManualAssetDetail.tsx
Normal file
262
src/features/assets/ManualAssetDetail.tsx
Normal 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)" }} />;
|
||||||
|
}
|
||||||
84
src/features/assets/ValueHistoryChart.tsx
Normal file
84
src/features/assets/ValueHistoryChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
src/features/assets/strings.ts
Normal file
106
src/features/assets/strings.ts
Normal 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;
|
||||||
24
src/features/assets/useHideAmountsTick.ts
Normal file
24
src/features/assets/useHideAmountsTick.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue