mercury-web/src/features/assets/AssetsView.tsx

609 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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, IconChip, SectionHeader, EmptyState, Icon, type IconName } 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",
};
const titleStyle: React.CSSProperties = {
fontSize: 15,
fontWeight: 700,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
};
const subtitleStyle: React.CSSProperties = { fontSize: 12, ...mutedStyle };
const amountStyle: React.CSSProperties = { fontSize: 15, fontWeight: 700, flexShrink: 0 };
// Bank/lending/asset chip tints — soft tones matching the categoryStyle
// palette used elsewhere in Mercury (transactions, planner).
const BANK_TINT = { tint: "#DDEAF6", fg: "#215C9A" };
const LENDING_TINT = { tint: "#E3F0D8", fg: "#3F7A1E" };
function assetChip(category: string): { icon: IconName; tint: string; fg: string } {
switch (category) {
case "car":
return { icon: "car", tint: "#DDE6F6", fg: "#2A4B9A" };
case "electronics":
return { icon: "monitor", tint: "#E1E5EA", fg: "#42505F" };
default:
return { icon: "layers", tint: "#EDEBE7", fg: "#6B6257" };
}
}
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 }}>
<SectionHeader title={s.accounts.title} />
{netWorth.isLoading ? (
<SkeletonRows count={2} />
) : accounts.length === 0 ? (
<Card style={{ padding: 0 }}>
<EmptyState icon="bank" title={s.accounts.empty} hint={s.accounts.emptyHint} />
</Card>
) : (
<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 style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip icon="bank" {...BANK_TINT} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={titleStyle}>{account.bank}</span>
<span style={subtitleStyle}>{account.accountNumber}</span>
</div>
</div>
<span style={amountStyle}>{tugrik(account.balance)}</span>
</Link>
</React.Fragment>
))}
</Card>
)}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeader title={s.manualAssets.title} onAction={() => setAddAssetOpen(true)} actionLabel={s.manualAssets.add} />
{manualAssets.isLoading ? (
<SkeletonRows count={3} />
) : assets.length === 0 ? (
<Card style={{ padding: 0 }}>
<EmptyState icon="layers" title={s.manualAssets.emptyTitle} hint={s.manualAssets.emptySubtitle} />
</Card>
) : (
<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 }}>
<SectionHeader title={s.lending.title} onAction={() => setAddLoanOpen(true)} actionLabel={s.lending.add} />
{lending.isLoading ? (
<SkeletonRows count={2} />
) : loans.length === 0 ? (
<Card style={{ padding: 0 }}>
<EmptyState icon="hand-coins" title={s.lending.empty} hint={s.lending.emptyHint} />
</Card>
) : (
<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: "26px 22px", background: "var(--mercury-balance-card)" }}>
{loading ? (
<Skeleton height="40px" />
) : (
<>
<div style={{ fontSize: 13, fontWeight: 600, letterSpacing: 0.2, opacity: 0.75 }}>{s.netWorth.total}</div>
<div style={{ fontSize: 34, fontWeight: 800, marginTop: 8, letterSpacing: -0.5 }}>{tugrik(netWorth?.total ?? "0")}</div>
<div style={{ display: "flex", gap: 28, marginTop: 20 }}>
<div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.assets}</div>
<div style={{ fontSize: 15, fontWeight: 700, marginTop: 2 }}>{tugrik(netWorth?.assets ?? "0")}</div>
</div>
<div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.liabilities}</div>
<div style={{ fontSize: 15, fontWeight: 700, marginTop: 2 }}>{tugrik(netWorth?.liabilities ?? "0")}</div>
</div>
</div>
</>
)}
</Card>
);
}
// --- Small shared bits -------------------------------------------------------
function SkeletonRows({ count }: { count: number }) {
return (
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} height="48px" style={{ borderRadius: "var(--seed-radius-r2, 8px)" }} />
))}
</Card>
);
}
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;
const chip = assetChip(asset.category);
return (
<Card style={{ padding: 0 }}>
<div style={rowStyle}>
<Link
href={`/assets/manual/${encodeURIComponent(asset.name)}`}
style={{ display: "flex", alignItems: "center", gap: 12, color: "inherit", textDecoration: "none", flex: 1, minWidth: 0 }}
>
<IconChip icon={chip.icon} tint={chip.tint} fg={chip.fg} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={titleStyle}>{asset.name}</span>
<span style={subtitleStyle}>
{categoryLabel} · {conditionLabel}
</span>
</div>
</Link>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={amountStyle}>{tugrikRaw(asset.value)}</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
marginTop: 2,
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 14px" }}>
<button
type="button"
onClick={onRevalue}
disabled={revaluing}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
border: "none",
borderRadius: 10,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
padding: "8px 10px",
fontSize: 12,
fontWeight: 600,
color: "var(--seed-color-fg-neutral)",
cursor: revaluing ? "default" : "pointer",
}}
>
<Icon name="trending" size={14} />
{revaluing ? "…" : s.manualAssets.revalue}
</button>
<button
type="button"
onClick={onDelete}
style={{
border: "none",
borderRadius: 10,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
padding: "8px 14px",
fontSize: 12,
fontWeight: 600,
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={{ display: "flex", alignItems: "center", gap: 12, color: "inherit", textDecoration: "none", flex: 1, minWidth: 0 }}
>
<IconChip icon="hand-coins" {...LENDING_TINT} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={titleStyle}>{loan.person}</span>
<span style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
{statusLabel(loan)}
</span>
</div>
</Link>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={amountStyle}>{tugrikRaw(loan.remaining)}</div>
<div style={subtitleStyle}>/ {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: 18,
lineHeight: 1,
padding: 4,
flexShrink: 0,
}}
>
×
</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>
);
}