Compare commits

...

14 commits

Author SHA1 Message Date
6913c1e5ee feat(web): desktop layout (wider container + 2-col dashboard) + redirect-loop fix + mn string
- Shell content widens to max-w-6xl on desktop (was phone-strip max-w-2xl)
- Dashboard reflows into a 2-col grid on desktop instead of stretching
- On 401, clear session cookie via logout before redirect (prevents /login<->/home loop when cookie outlives server session)
- Profile 'Subscriptions' -> 'Захиалга'
2026-08-22 21:33:14 +08:00
d94e961d97 fix(web): apply Seed canonical theme setup (body fg-neutral color + data-seed attrs)
Default text resolved to white-on-white because globals.css set a background
but no base color, and <html> carried no data-seed attributes. Mirrors
daangn.com's live setup: html[data-seed][data-seed-color-mode] + body
color: var(--seed-color-fg-neutral). Fixes invisible text app-wide.
2026-08-22 21:30:30 +08:00
b02674aa62 fix(web): SwitchHiddenInput on settings hideAmounts toggle + correct smoke comment 2026-08-22 21:16:45 +08:00
6e84d99d46 test(web): exclude e2e/ from vitest collection 2026-08-22 21:10:48 +08:00
95729de201 test(web): MSW harness + Playwright smoke + QA pass 2026-08-22 21:09:59 +08:00
3014385e9b merge task/t10: transactions list + detail + edits 2026-08-22 21:01:05 +08:00
beb1915bd8 merge task/t11: assets, net worth, manual assets, lending 2026-08-22 21:00:44 +08:00
c8fd8eecc0 merge task/t13: profile settings/categories/subscriptions/connected-banks (read-only) 2026-08-22 21:00:11 +08:00
c52a2d731d merge task/t12: planner budget/limits/savings goals 2026-08-22 20:59:59 +08:00
0b2a786791 feat(web): assets, net worth, manual assets, lending 2026-08-22 20:58:36 +08:00
24642be543 feat(web): profile settings, categories, subscriptions, connected banks (read-only) 2026-08-22 20:57:19 +08:00
922d2b54ef feat(web): transactions list + detail + categorize/rename/note 2026-08-22 20:57:07 +08:00
f9195c5ffa feat(web): planner budget/limits/savings goals 2026-08-22 20:57:01 +08:00
762b572d30 feat(web): home dashboard + data assembly ported from iOS 2026-08-22 20:53:02 +08:00
58 changed files with 5426 additions and 9 deletions

101
e2e/smoke.spec.ts Normal file
View file

@ -0,0 +1,101 @@
import { test, expect } from "@playwright/test";
import * as fx from "../src/test/fixtures";
/**
* One smoke path: /login submit /home renders the real dashboard.
*
* There's no live Go backend in CI, so `page.route` intercepts
* `/api/auth/login` and `/api/v1/*` at the BROWSER network layer, before
* either request ever reaches the Next server the real
* `src/app/api/auth/login/route.ts` handler (which calls the Go backend,
* strips `token`, and calls `buildSetCookie`) never runs in this test; this
* spec fabricates the `Set-Cookie` header and the token-free `{user}` body
* itself. What DOES run for real: the Next dev server's page rendering, the
* client-side login form + navigation, and critically the
* `src/middleware.ts` route guard, which reads the `mercury_session` cookie
* this test's fake `Set-Cookie` puts in the browser's real cookie jar to
* decide whether `/home` is reachable. So this smoke covers the
* login navigate guarded-route dashboard-render path end to end; it
* does NOT cover the real auth route's token-stripping/cookie-building logic
* (that's covered separately by src/app/api/auth/auth.route.test.ts). The
* httpOnly/no-raw-token assertions below validate this test's own mocked
* response shape, i.e. they document the contract the real route must also
* satisfy they are not independent proof that the real route satisfies it.
*/
test("login → home renders the dashboard; session cookie is httpOnly; no raw token leaks", async ({
page,
context,
}) => {
const responseBodies: string[] = [];
page.on("response", (response) => {
response
.text()
.then((body) => responseBodies.push(body))
.catch(() => {
/* non-text bodies (redirects, static assets, etc.) — not relevant here */
});
});
await page.route("**/api/auth/login", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"set-cookie": "mercury_session=e2e-fake-session; Path=/; HttpOnly; SameSite=Lax",
},
body: JSON.stringify({ user: fx.user }),
});
});
await page.route("**/api/v1/**", async (route) => {
const url = new URL(route.request().url());
const path = url.pathname.replace(/^\/api\/v1/, "");
const body: unknown =
path === "/networth"
? fx.netWorth
: path === "/analyze"
? url.searchParams.has("from") || url.searchParams.has("to")
? fx.analyzeToday
: fx.analyzeMonth
: path === "/budget"
? fx.budget
: path === "/transactions"
? { transactions: [fx.txn] }
: path === "/categories"
? { categories: fx.categories }
: path === "/subscriptions"
? { subscriptions: fx.subscriptions, bills: fx.bills }
: path === "/manual-assets"
? { manualAssets: fx.manualAssets }
: path === "/lending"
? { entries: [fx.lending] }
: path === "/settings"
? fx.settings
: path === "/connections"
? { connections: fx.connections }
: path === "/me"
? { user: fx.user }
: {};
await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(body) });
});
await page.goto("/login");
await page.getByLabel("Имэйл хаяг").fill("test@example.com");
await page.getByLabel("Нууц үг").fill("password12345");
await page.getByRole("button", { name: "Нэвтрэх" }).click();
await page.waitForURL("**/home");
// The dashboard header wordmark, and the real safe-to-spend figure derived
// from the intercepted /networth + /analyze + /budget fixtures (900,000
// monthLimit 800,000 discretionaryExpense — see buildHome.test.ts).
await expect(page.getByText("MERCURY")).toBeVisible();
await expect(page.getByText("100,000₮")).toBeVisible();
const cookies = await context.cookies();
const session = cookies.find((c) => c.name === "mercury_session");
expect(session, "mercury_session cookie should be set after login").toBeTruthy();
expect(session?.httpOnly).toBe(true);
const leaked = responseBodies.some((body) => /"token"\s*:\s*"/.test(body));
expect(leaked, "no observed response body should contain a raw token field").toBe(false);
});

29
playwright.config.ts Normal file
View file

@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
/**
* The one smoke path (e2e/smoke.spec.ts) drives the real Next dev server
* no live Go backend in CI, so the spec intercepts `/api/auth/login` and
* `/api/v1/*` at the browser network layer (page.route) and fulfills them
* with fixtures, rather than pointing FMS_API_URL at a stub server. This
* keeps auth (httpOnly Set-Cookie) and the app's real client-side code
* exactly as shipped; only the network boundary is faked.
*/
export default defineConfig({
testDir: "./e2e",
timeout: 30_000,
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: "list",
use: {
baseURL: "http://localhost:3100",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "bun run dev -- -p 3100",
url: "http://localhost:3100",
reuseExistingServer: !process.env.CI,
timeout: 60_000,
},
});

View file

@ -17,7 +17,13 @@ async function req(method: string, path: string, body?: unknown): Promise<unknow
body: body === undefined ? undefined : JSON.stringify(body), body: body === undefined ? undefined : JSON.stringify(body),
}); });
if (res.status === 401) { if (res.status === 401) {
if (typeof window !== "undefined") window.location.href = "/login"; // Clear the (now-stale) session cookie before redirecting. Otherwise the
// middleware bounces /login → /home on mere cookie presence, and a
// server-invalidated-but-not-expired session produces an infinite loop.
if (typeof window !== "undefined") {
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }).catch(() => {});
window.location.href = "/login";
}
throw new ApiError(401, "unauthorized"); throw new ApiError(401, "unauthorized");
} }
if (!res.ok) throw new ApiError(res.status, await res.text()); if (!res.ok) throw new ApiError(res.status, await res.text());

View file

@ -0,0 +1,11 @@
import { TransactionDetail } from "@/features/accounting/TransactionDetail";
// Ports ios/Mercury/Features/Transactions/TransactionDetailView.swift.
export default async function TransactionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <TransactionDetail id={id} />;
}

View file

@ -0,0 +1,7 @@
import { TransactionList } from "@/features/accounting/TransactionList";
// Ports ios/Mercury/Features/Transactions/TransactionsView.swift's ledger
// tab (narrowed to this task's scope — see TransactionList).
export default function AccountingPage() {
return <TransactionList />;
}

View file

@ -0,0 +1,6 @@
import { AccountDetail } from "@/features/assets/AccountDetail";
export default async function AccountDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <AccountDetail accountId={Number(id)} />;
}

View file

@ -0,0 +1,6 @@
import { LendingDetail } from "@/features/assets/LendingDetail";
export default async function LendingDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
return <LendingDetail id={Number(id)} />;
}

View file

@ -0,0 +1,6 @@
import { ManualAssetDetail } from "@/features/assets/ManualAssetDetail";
export default async function ManualAssetPage({ params }: { params: Promise<{ name: string }> }) {
const { name } = await params;
return <ManualAssetDetail name={decodeURIComponent(name)} />;
}

View file

@ -0,0 +1,7 @@
import { AssetsView } from "@/features/assets/AssetsView";
// Ports ios/Mercury/Features/Assets/AssetsView.swift's Хөрөнгө tab: net worth
// header, read-only bank accounts, manual (physical) assets, and lending.
export default function AssetsPage() {
return <AssetsView />;
}

View file

@ -1,6 +1,5 @@
import { t } from "@/i18n/common"; import { DashboardView } from "@/features/home/DashboardView";
// Placeholder — Task 9 replaces this with the real dashboard.
export default function HomePage() { export default function HomePage() {
return <h1 style={{ fontWeight: 700 }}>{t.tabs.home}</h1>; return <DashboardView />;
} }

View file

@ -11,7 +11,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
<AppQueryProvider> <AppQueryProvider>
<div className="md:flex md:min-h-screen"> <div className="md:flex md:min-h-screen">
<AppNav /> <AppNav />
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-24 pt-6 md:pb-6 md:pt-8"> <main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-24 pt-6 md:max-w-4xl md:px-8 md:pb-10 md:pt-10 lg:max-w-6xl lg:px-12">
{children} {children}
</main> </main>
</div> </div>

View file

@ -0,0 +1,10 @@
import { CategoryTransactions } from "@/features/planner/CategoryTransactions";
export default async function CategoryTransactionsPage({
params,
}: {
params: Promise<{ category: string }>;
}) {
const { category } = await params;
return <CategoryTransactions category={decodeURIComponent(category)} />;
}

View file

@ -0,0 +1,5 @@
import { PlannerView } from "@/features/planner/PlannerView";
export default function PlannerPage() {
return <PlannerView />;
}

View file

@ -0,0 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { CategoriesManager } from "@/features/profile/CategoriesManager";
export default function ProfileCategoriesPage() {
const router = useRouter();
return <CategoriesManager onBack={() => router.push("/profile")} />;
}

View file

@ -0,0 +1,5 @@
import { ProfileView } from "@/features/profile/ProfileView";
export default function ProfilePage() {
return <ProfileView />;
}

View file

@ -0,0 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { SubscriptionsView } from "@/features/profile/SubscriptionsView";
export default function ProfileSubscriptionsPage() {
const router = useRouter();
return <SubscriptionsView onBack={() => router.push("/profile")} />;
}

View file

@ -6,5 +6,9 @@
html, body { html, body {
font-family: var(--mercury-font); font-family: var(--mercury-font);
/* Canonical Seed setup (matches daangn.com): body carries the neutral
foreground + default layer background so all default text is legible.
Without an explicit color, default text resolved to white-on-white. */
color: var(--seed-color-fg-neutral, #1a1c20);
background: var(--seed-color-bg-layer-default, #fff); background: var(--seed-color-bg-layer-default, #fff);
} }

View file

@ -4,7 +4,7 @@ export const metadata = { title: "Mercury" };
export default function RootLayout({ children }: { children: React.ReactNode }) { export default function RootLayout({ children }: { children: React.ReactNode }) {
return ( return (
<html lang="mn"> <html lang="mn" data-seed data-seed-color-mode="light-only">
<body>{children}</body> <body>{children}</body>
</html> </html>
); );

View file

@ -0,0 +1,35 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { it, expect, beforeEach } from "vitest";
import { HideAmountsToggle } from "./HideAmountsToggle";
import { isHidden, tugrik } from "./money";
beforeEach(() => {
try {
localStorage.clear();
} catch {}
});
// Flagged in review: the global hide-amounts switch must actually persist
// (write through to localStorage via setHidden), not just flip its own local
// `checked` state — so isHidden() (and every amount formatted with tugrik())
// reflects the change, and a freshly-mounted toggle picks the persisted value
// back up (e.g. after navigating to a different tab and back).
it("hideAmounts toggle persists: flips isHidden() and masks amounts, and survives remount", () => {
expect(isHidden()).toBe(false);
render(<HideAmountsToggle />);
const toggle = screen.getByRole("switch");
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(isHidden()).toBe(true);
expect(tugrik("52000")).toBe("••••••");
expect(toggle).toBeChecked();
// Persists across a remount (e.g. leaving and returning to the page) since
// the initial `checked` state is read from isHidden(), not re-initialized.
cleanup();
render(<HideAmountsToggle />);
expect(screen.getByRole("switch")).toBeChecked();
});

View file

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel } from "@seed-design/react"; import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel, SwitchHiddenInput } from "@seed-design/react";
import { isHidden, setHidden } from "./money"; import { isHidden, setHidden } from "./money";
/** Dispatched on `window` whenever the global hide-amounts flag changes, so /** Dispatched on `window` whenever the global hide-amounts flag changes, so
@ -25,6 +25,12 @@ export function HideAmountsToggle({ label = "Мөнгөн дүн нуух" }: Hi
return ( return (
<SwitchRoot checked={checked} onCheckedChange={handleChange}> <SwitchRoot checked={checked} onCheckedChange={handleChange}>
{/* The actual interactive/accessible element (role="switch",
checked/onChange) lives on the hidden input SwitchControl and
SwitchThumb are purely decorative (aria-hidden). Without this the
switch renders but nothing is clickable or announced to
assistive tech. */}
<SwitchHiddenInput />
<SwitchControl> <SwitchControl>
<SwitchThumb /> <SwitchThumb />
</SwitchControl> </SwitchControl>

View file

@ -0,0 +1,97 @@
"use client";
import {
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetCloseButton,
BottomSheetBody,
Icon,
ListRoot,
ListItem,
ListContent,
ListTitle,
} from "@seed-design/react";
import type { Category } from "@/api/schemas";
import { accountingStrings as s } from "./strings";
export interface CategorizeSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
categories: Category[];
/** The transaction's current category (main or sub) — highlighted in the list. */
selected?: string;
onSelect: (category: Category) => void;
}
const closeSvg = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
);
const checkSvg = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
<path d="M5 12.5l4.5 4.5L19 7" />
</svg>
);
/**
* Category picker bottom sheet (ports `CategoryPickerSheet` from
* `ios/Mercury/Features/Planner/PlannerEditViews.swift`, opened from the
* transaction detail's Ангилал row): a titled list of all categories,
* tap-to-select, checkmark on the current pick.
*/
export function CategorizeSheet({ open, onOpenChange, categories, selected, onSelect }: CategorizeSheetProps) {
return (
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<BottomSheetTitle>{s.categoryPicker.title}</BottomSheetTitle>
<BottomSheetCloseButton aria-label={s.categoryPicker.cancel}>
<Icon svg={closeSvg} size="16px" />
</BottomSheetCloseButton>
</BottomSheetHeader>
<BottomSheetBody style={{ maxHeight: "60vh", overflowY: "auto" }}>
<ListRoot>
{categories.map((cat) => {
const isSelected = cat.name === selected;
return (
<ListItem key={cat.name} style={{ padding: 0 }}>
<button
type="button"
onClick={() => onSelect(cat)}
style={{
display: "flex",
width: "100%",
alignItems: "center",
justifyContent: "space-between",
background: "none",
border: "none",
textAlign: "left",
cursor: "pointer",
padding: "12px 4px",
font: "inherit",
color: "inherit",
}}
>
<ListContent>
<ListTitle>{cat.name}</ListTitle>
</ListContent>
{isSelected && <Icon svg={checkSvg} size="18px" />}
</button>
</ListItem>
);
})}
</ListRoot>
</BottomSheetBody>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}

View file

@ -0,0 +1,321 @@
"use client";
import type { CSSProperties } from "react";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
DialogRoot,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogAction,
TextFieldRoot,
TextFieldTextarea,
} from "@seed-design/react";
import { useTransactions, useCategories } from "@/api/hooks/reads";
import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations";
import { Card, MercuryButton, NameEdit } from "@/ds";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
import { CategorizeSheet } from "./CategorizeSheet";
import { findTxnByRouteId } from "./txnRoute";
import { useHiddenAmounts } from "./useHiddenAmounts";
export interface TransactionDetailProps {
id: string;
}
const ROW_BORDER: CSSProperties = { borderBottom: "1px solid var(--seed-color-border-neutral, #e5e5e5)" };
function formatDateTime(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}.${pad(d.getMonth() + 1)}.${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function DetailRow({
label,
value,
chevron,
bold,
last,
onClick,
}: {
label: string;
value: string;
chevron?: boolean;
bold?: boolean;
last?: boolean;
onClick?: () => void;
}) {
const wrapperStyle: CSSProperties = last ? {} : ROW_BORDER;
const body = (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "12px 16px" }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)", flexShrink: 0 }}>{label}</span>
<span
style={{
fontSize: bold ? 14 : 13,
fontWeight: bold ? 700 : 400,
textAlign: "right",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{value}
</span>
{chevron && (
<span aria-hidden style={{ color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
</span>
)}
</div>
);
if (!onClick) return <div style={wrapperStyle}>{body}</div>;
return (
<button
type="button"
onClick={onClick}
style={{ all: "unset", display: "block", width: "100%", boxSizing: "border-box", cursor: "pointer", ...wrapperStyle }}
>
{body}
</button>
);
}
/**
* Transaction detail (ports `TransactionDetailView.swift`, narrowed to this
* task's scope): a key-value table plus categorize / rename / note edits.
* Categorize and rename both "learn a rule" server-side keyed on the
* merchant's match key (applies to all of that merchant's past + future
* transactions) renaming shows a confirm dialog because of that broad
* effect; categorizing (like iOS) applies immediately.
*/
export function TransactionDetail({ id }: TransactionDetailProps) {
const router = useRouter();
const { data: transactions } = useTransactions();
const { data: categories = [] } = useCategories();
const categorize = useCategorize();
const renameTxn = useRenameTxn();
const setNoteMutation = useSetNote();
const hiddenAmounts = useHiddenAmounts();
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
// Local overrides so an edit reflects immediately, matching iOS's
// `assigned` / `displayTitle` @State — the server call runs in the
// background and the list refetch (via mutation `onSuccess` invalidation)
// reconciles afterwards.
const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
const [displayTitle, setDisplayTitle] = useState<string | null>(null);
const [noteOverride, setNoteOverride] = useState<string | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
const [pendingName, setPendingName] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [editingNote, setEditingNote] = useState(false);
const [noteDraft, setNoteDraft] = useState("");
useEffect(() => {
setAssignedCategory(null);
setDisplayTitle(null);
setNoteOverride(null);
setRenaming(false);
setEditingNote(false);
}, [id]);
if (!txn) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Card>
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.detail.notFound}</p>
</Card>
</div>
);
}
const income = txn.direction === "income";
const category = assignedCategory ?? txn.category;
const title = displayTitle ?? txn.title;
const note = noteOverride ?? txn.note ?? "";
const canNote = txn.txnId != null && txn.txnId > 0;
const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : ""}${tugrikRaw(txn.amount)}`;
async function confirmRename() {
if (!pendingName) return;
const name = pendingName;
setDisplayTitle(name);
setPendingName(null);
try {
await renameTxn.mutateAsync({ matchKey: txn!.matchKey ?? txn!.title, name });
} catch {
// Best-effort, matches iOS's `renameMerchant` — leave the optimistic
// title in place; the next successful list load reconciles it.
}
}
async function saveNote() {
const trimmed = noteDraft.trim();
setNoteOverride(trimmed);
setEditingNote(false);
if (txn!.txnId != null) {
try {
await setNoteMutation.mutateAsync({ id: txn!.txnId, note: trimmed });
} catch {
// Best-effort, matches iOS's `saveNote`.
}
}
}
if (renaming) {
return (
<div style={{ paddingTop: 24 }}>
<NameEdit
title={s.rename.title}
initial={title}
placeholder={s.rename.placeholder}
onCancel={() => setRenaming(false)}
onSave={(name) => {
setRenaming(false);
setPendingName(name);
setConfirmOpen(true);
}}
/>
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 24 }}>
<BackButton onClick={() => router.push("/accounting")} />
<Card style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 16, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{title || category}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{category}</span>
</div>
<span
style={{
fontSize: 18,
fontWeight: 700,
flexShrink: 0,
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{signedAmount}
</span>
</Card>
<Card style={{ padding: 0 }}>
<DetailRow label={s.detail.total} value={amountRaw} bold />
<DetailRow label={s.detail.date} value={formatDateTime(txn.date)} />
<DetailRow label={s.detail.category} value={category} chevron onClick={() => setPickerOpen(true)} />
<DetailRow label={s.detail.name} value={title} chevron onClick={() => setRenaming(true)} />
{txn.balanceAfter != null && (
<DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} />
)}
<DetailRow
label={s.detail.type}
value={income ? s.detail.income : s.detail.expense}
last={!canNote}
/>
{canNote &&
(editingNote ? (
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
<TextFieldRoot value={noteDraft} onValueChange={setNoteDraft}>
<TextFieldTextarea
aria-label={s.detail.note}
placeholder={s.detail.noteAdd}
style={{ minHeight: 72 }}
/>
</TextFieldRoot>
<div style={{ display: "flex", gap: 8 }}>
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setEditingNote(false)}>
{s.detail.noteCancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
loading={setNoteMutation.isPending}
onClick={saveNote}
>
{s.detail.noteSave}
</MercuryButton>
</div>
</div>
) : (
<DetailRow
label={s.detail.note}
value={note || s.detail.noteAdd}
chevron
last
onClick={() => {
setNoteDraft(note);
setEditingNote(true);
}}
/>
))}
</Card>
<CategorizeSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
categories={categories}
selected={category}
onSelect={(cat) => {
setAssignedCategory(cat.name);
setPickerOpen(false);
categorize.mutate({
matchKey: txn.matchKey ?? txn.title,
category: cat.name,
kind: txn.direction === "income" ? "income" : "expense",
});
}}
/>
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
<DialogBackdrop />
<DialogPositioner>
<DialogContent>
<DialogHeader>
<DialogTitle>{s.rename.confirmTitle}</DialogTitle>
<DialogDescription>{s.rename.confirmDescription}</DialogDescription>
</DialogHeader>
<DialogFooter style={{ display: "flex", gap: 8 }}>
<DialogAction style={{ flex: 1 }}>{s.rename.confirmCancel}</DialogAction>
<DialogAction style={{ flex: 1 }} onClick={confirmRename}>
{s.rename.confirmSave}
</DialogAction>
</DialogFooter>
</DialogContent>
</DialogPositioner>
</DialogRoot>
</div>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label={s.detail.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer", alignSelf: "flex-start", padding: 0 }}
>
</button>
);
}

View file

@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { it, expect, vi, beforeEach } from "vitest";
import type { Txn } from "@/api/schemas";
import { setHidden } from "@/ds/money";
const txns: Txn[] = [
{
date: "2026-08-20T09:00:00Z",
amount: "15000",
direction: "expense",
category: "Хоол",
title: "Кофе шоп",
accountId: 1,
txnId: 101,
},
{
date: "2026-08-20T08:00:00Z",
amount: "2500000",
direction: "income",
category: "Цалин",
title: "ХХК цалин",
accountId: 1,
txnId: 102,
salary: true,
},
];
vi.mock("@/api/hooks/reads", () => ({
useTransactions: () => ({ data: txns, isLoading: false }),
}));
import { TransactionList } from "./TransactionList";
beforeEach(() => {
setHidden(false);
});
it("renders transaction titles and formatted amounts, hiding salary rows by default", () => {
render(<TransactionList />);
// Non-salary row: title + a signed, grouped amount.
expect(screen.getByText("Кофе шоп")).toBeInTheDocument();
expect(screen.getByText("15,000₮")).toBeInTheDocument();
// Salary row is filtered out of the row list by default...
expect(screen.queryByText("ХХК цалин")).not.toBeInTheDocument();
// ...but its amount still counts toward the income total (salary is
// included in totals per TransactionsModel.recompute, only excluded from
// the row list).
expect(screen.getByText("2,500,000₮")).toBeInTheDocument();
});

View file

@ -0,0 +1,174 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { Skeleton } from "@seed-design/react";
import { useTransactions } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas";
import { Card, HideAmountsToggle } from "@/ds";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
import { txnRouteId } from "./txnRoute";
import { useHiddenAmounts } from "./useHiddenAmounts";
/** Local calendar day (browser-local time), for grouping + the day header
* a dependency-free stand-in for iOS's Asia/Ulaanbaatar `Calendar`. */
function dayKeyAndLabel(iso: string): { key: string; label: string } {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return { key: iso.slice(0, 10), label: iso.slice(0, 10) };
const y = d.getFullYear();
const m = d.getMonth() + 1;
const day = d.getDate();
return { key: `${y}-${String(m).padStart(2, "0")}-${String(day).padStart(2, "0")}`, label: `${m}-р сарын ${day}` };
}
interface DayGroup {
key: string;
label: string;
items: Txn[];
}
/** Groups already-sorted (newest-first) rows into consecutive same-day buckets. */
function groupByDay(items: Txn[]): DayGroup[] {
const groups: DayGroup[] = [];
for (const txn of items) {
const { key, label } = dayKeyAndLabel(txn.date);
const last = groups[groups.length - 1];
if (last && last.key === key) {
last.items.push(txn);
} else {
groups.push({ key, label, items: [txn] });
}
}
return groups;
}
function amountOf(txn: Txn): number {
return parseFloat(txn.amount) || 0;
}
function SummaryRow({ label, value, hidden, tone }: { label: string; value: number; hidden: boolean; tone: "income" | "expense" }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span style={{ fontSize: 16 }}>{label}</span>
<span
style={{
fontSize: 16,
fontWeight: 700,
color: tone === "income" ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{hidden ? MASKED : tugrikRaw(value)}
</span>
</div>
);
}
function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
const income = txn.direction === "income";
const isTransfer = txn.transfer === true;
const sign = income ? "+" : "";
const amountText = hidden ? MASKED : `${sign}${tugrikRaw(txn.amount)}`;
const amountColor = isTransfer
? "var(--seed-color-fg-neutral-muted, #8b8b8b)"
: income
? "var(--mercury-success, #1e9e6b)"
: "var(--mercury-critical, #e5484d)";
return (
<Link
href={`/accounting/${txnRouteId(txn)}`}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "12px 0",
textDecoration: "none",
color: "inherit",
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 4, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title || txn.category}
</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{isTransfer ? `${txn.category} · ${s.list.transferTag}` : txn.category}
</span>
</div>
<span style={{ fontSize: 16, fontWeight: 700, color: amountColor, flexShrink: 0 }}>{amountText}</span>
</Link>
);
}
/**
* The Тооцоо list: income/expense totals for the loaded month, then the
* transaction rows grouped by day. Mirrors `TransactionsView.ledgerTab` /
* `TransactionsModel.recompute` (narrowed to this task's scope no month
* nav or category chips): salary deposits (`salary === true`) are hidden by
* default, and transfers are excluded from the totals and tagged in the row
* meta line instead of colored green/red.
*/
export function TransactionList() {
const { data, isLoading } = useTransactions();
const all = useMemo(() => data ?? [], [data]);
const hidden = useHiddenAmounts();
const totals = useMemo(() => {
let income = 0;
let expense = 0;
for (const txn of all) {
if (txn.transfer === true) continue;
if (txn.direction === "income") income += amountOf(txn);
else expense += amountOf(txn);
}
return { income, expense };
}, [all]);
const visible = useMemo(() => all.filter((txn) => txn.salary !== true), [all]);
const groups = useMemo(() => groupByDay(visible), [visible]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
<HideAmountsToggle label={s.list.hideToggle} />
</div>
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" />
<SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" />
</Card>
{isLoading ? (
<Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
) : groups.length === 0 ? (
<Card>
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.list.empty}</p>
</Card>
) : (
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>
{groups.map((group) => (
<div key={group.key}>
<div
style={{
fontSize: 14,
fontWeight: 700,
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
marginBottom: 4,
}}
>
{group.label}
</div>
<div style={{ display: "flex", flexDirection: "column" }}>
{group.items.map((txn, i) => (
<TxnRow key={`${txnRouteId(txn)}-${i}`} txn={txn} hidden={hidden} />
))}
</div>
</div>
))}
</Card>
)}
</div>
);
}

View file

@ -0,0 +1,44 @@
// Accounting (Тооцоо) feature copy, ported verbatim from
// ios/Mercury/Features/Transactions/TransactionsView.swift,
// TransactionDetailView.swift, and
// ios/Mercury/Features/Categories/CategorizeReviewView.swift,
// ios/Mercury/Features/Planner/PlannerEditViews.swift (CategoryPickerSheet).
export const accountingStrings = {
list: {
title: "Гүйлгээ",
hideToggle: "Мөнгөн дүн нуух",
income: "Орлого",
expense: "Зарлага",
empty: "Гүйлгээ алга",
transferTag: "Шилжүүлэг",
},
detail: {
total: "Нийт",
date: "Огноо",
category: "Ангилал",
name: "Нэр",
balance: "Үлдэгдэл",
type: "Төрөл",
income: "Орлого",
expense: "Зарлага",
note: "Тэмдэглэл",
noteAdd: "Нэмэх",
noteSave: "Хадгалах",
noteCancel: "Болих",
notFound: "Гүйлгээ олдсонгүй",
back: "Буцах",
},
rename: {
title: "Нэр өөрчлөх",
placeholder: "Шинэ нэр",
confirmTitle: "Нэр өөрчлөх үү?",
confirmDescription: "Энэ худалдагчийн бүх өмнөх болон дараагийн гүйлгээнд шинэ нэрийг хэрэглэнэ.",
confirmCancel: "Болих",
confirmSave: "Хадгалах",
},
categoryPicker: {
title: "Гарчиг",
cancel: "Болих",
select: "Сонгох",
},
} as const;

View file

@ -0,0 +1,18 @@
import type { Txn } from "@/api/schemas";
/**
* Stable per-row identifier for linking a list row to `/accounting/[id]`.
* Settled transactions carry a numeric `txnId`; pending holds (no id yet,
* same as iOS's `TxnDTO.txnId == nil`) fall back to a composite of their
* match key + date so the row is still linkable, best-effort, without a
* dedicated "get one transaction" endpoint.
*/
export function txnRouteId(txn: Txn): string {
if (txn.txnId != null) return String(txn.txnId);
return `p_${encodeURIComponent(txn.matchKey ?? txn.title)}_${encodeURIComponent(txn.date)}`;
}
/** Finds the transaction in `txns` that a given `/accounting/[id]` id refers to. */
export function findTxnByRouteId(txns: Txn[], id: string): Txn | undefined {
return txns.find((t) => txnRouteId(t) === id);
}

View file

@ -0,0 +1,34 @@
"use client";
import { useEffect, useState } from "react";
import { HIDE_AMOUNTS_EVENT } from "@/ds";
import { isHidden } from "@/ds/money";
/**
* Tracks the global hide-amounts flag reactively. `tugrik()`/`isHidden()`
* read `localStorage` synchronously but don't cause a re-render on their
* own pages that show masked amounts need to listen for the
* `HideAmountsToggle`-dispatched event (and other tabs' storage writes) to
* update immediately when the switch flips.
*/
export function useHiddenAmounts(): boolean {
const [hidden, setHiddenState] = useState<boolean>(() => isHidden());
useEffect(() => {
function onToggle(e: Event) {
const detail = (e as CustomEvent<{ hidden: boolean }>).detail;
setHiddenState(detail ? detail.hidden : isHidden());
}
function onStorage() {
setHiddenState(isHidden());
}
window.addEventListener(HIDE_AMOUNTS_EVENT, onToggle);
window.addEventListener("storage", onStorage);
return () => {
window.removeEventListener(HIDE_AMOUNTS_EVENT, onToggle);
window.removeEventListener("storage", onStorage);
};
}, []);
return hidden;
}

View file

@ -0,0 +1,104 @@
"use client";
import * as React from "react";
import { useNetWorth, useTransactions } from "@/api/hooks/reads";
import { Card } from "@/ds";
import { tugrikRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings";
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
export interface AccountDetailProps {
accountId: number;
}
/**
* Read-only account detail no bank-server actions live here (that's iOS's
* `AccountDetailView`, gated behind a live bank connection this web app
* doesn't drive yet). Shows the balance from `/networth` plus this
* account's recent transactions.
*/
export function AccountDetail({ accountId }: AccountDetailProps) {
const netWorth = useNetWorth();
const transactions = useTransactions({ account: accountId, limit: 30 });
const account = (netWorth.data?.accounts ?? []).find((a) => a.accountId === accountId);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{account?.bank ?? s.accountDetail.bank}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ fontSize: 12, ...mutedStyle }}>{account?.accountNumber}</div>
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(account?.balance ?? "0")}</div>
</Card>
<Card style={{ padding: "6px 20px" }}>
<DetailRow label={s.accountDetail.bank} value={account?.bank ?? "—"} />
<Divider />
<DetailRow label={s.accountDetail.accountNumber} value={account?.accountNumber ?? "—"} />
<Divider />
<DetailRow label={s.accountDetail.currency} value={account?.currency ?? "—"} />
</Card>
<section>
<h3 style={{ margin: "0 0 10px", fontSize: 14, fontWeight: 700 }}>{s.accountDetail.recentTransactions}</h3>
{transactions.isLoading ? (
<p style={{ fontSize: 13, ...mutedStyle }}></p>
) : (transactions.data ?? []).length === 0 ? (
<p style={{ fontSize: 13, ...mutedStyle }}>{s.accountDetail.noTransactions}</p>
) : (
<Card style={{ padding: 0 }}>
{(transactions.data ?? []).map((txn, i) => {
const income = txn.direction === "income";
return (
<React.Fragment key={i}>
{i > 0 && <Divider />}
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 16px" }}>
<div>
<div style={{ fontWeight: 600 }}>{txn.title || txn.category}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>{txn.date.slice(0, 10)}</div>
</div>
<div
style={{
fontWeight: 700,
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{income ? "+" : ""}
{tugrikRaw(txn.amount)}
</div>
</div>
</React.Fragment>
);
})}
</Card>
)}
</section>
</div>
);
}
function BackLink() {
return (
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
{s.common.back}
</a>
);
}
function DetailRow({ label, value }: { label: string; value: string }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
<span style={mutedStyle}>{label}</span>
<span style={{ fontWeight: 600 }}>{value}</span>
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
}

View file

@ -0,0 +1,52 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import { AssetsView } from "./AssetsView";
import { tugrik } from "@/ds/money";
const mutationStub = () => ({ mutateAsync: vi.fn(), isPending: false });
vi.mock("@/api/hooks/reads", () => ({
useNetWorth: () => ({
data: { total: "1250000", assets: "1500000", liabilities: "250000", accounts: [] },
isLoading: false,
}),
useManualAssets: () => ({
data: [
{
name: "Toyota Prius",
category: "car",
value: "45000000",
acquiredValue: "40000000",
currency: "MNT",
condition: "used",
isLiability: false,
change: "5000000",
},
],
isLoading: false,
}),
useLending: () => ({ data: [], isLoading: false }),
}));
vi.mock("@/api/hooks/mutations", () => ({
useManualAssetMutations: () => ({
add: mutationStub(),
delete: mutationStub(),
revalue: mutationStub(),
}),
useLendingMutations: () => ({
create: mutationStub(),
delete: mutationStub(),
addRepayment: mutationStub(),
deleteRepayment: mutationStub(),
}),
}));
describe("AssetsView", () => {
it("renders the net worth total and the manual asset row", () => {
render(<AssetsView />);
expect(screen.getByText(tugrik("1250000"))).toBeInTheDocument();
expect(screen.getByText("Toyota Prius")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,582 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetBody,
BottomSheetFooter,
TextFieldRoot,
TextFieldInput,
Skeleton,
} from "@seed-design/react";
import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
import { tugrik, tugrikRaw } from "@/ds/money";
import type { Account, ManualAsset, Lending } from "@/api/schemas";
import { assetsStrings as s } from "./strings";
import { useHideAmountsTick } from "./useHideAmountsTick";
import { ConfirmDialog } from "./ConfirmDialog";
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
const rowStyle: React.CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
padding: "14px 16px",
};
export function AssetsView() {
// Re-renders when the global hide-amounts flag flips (tugrik()/tugrikRaw()
// read it from localStorage synchronously, so this is how the numbers on
// this page react to the header toggle without a reload).
useHideAmountsTick();
const netWorth = useNetWorth();
const manualAssets = useManualAssets();
const lending = useLending();
const assetMutations = useManualAssetMutations();
const lendingMutations = useLendingMutations();
const [addAssetOpen, setAddAssetOpen] = React.useState(false);
const [addLoanOpen, setAddLoanOpen] = React.useState(false);
const [deleteAssetName, setDeleteAssetName] = React.useState<string | null>(null);
const [revalueAssetName, setRevalueAssetName] = React.useState<string | null>(null);
const [deleteLoan, setDeleteLoan] = React.useState<Lending | null>(null);
const accounts: Account[] = netWorth.data?.accounts ?? [];
const assets: ManualAsset[] = manualAssets.data ?? [];
const loans: Lending[] = lending.data ?? [];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{s.header.title}</h1>
<HideAmountsToggle />
</header>
<NetWorthCard netWorth={netWorth.data} loading={netWorth.isLoading} />
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionTitle>{s.accounts.title}</SectionTitle>
{netWorth.isLoading ? (
<SkeletonRows count={2} />
) : accounts.length === 0 ? (
<EmptyText>{s.accounts.empty}</EmptyText>
) : (
<Card style={{ padding: 0 }}>
{accounts.map((account, i) => (
<React.Fragment key={account.accountId}>
{i > 0 && <Divider />}
<Link
href={`/assets/account/${account.accountId}`}
style={{ ...rowStyle, color: "inherit", textDecoration: "none" }}
>
<div>
<div style={{ fontWeight: 600 }}>{account.bank}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>{account.accountNumber}</div>
</div>
<div style={{ fontWeight: 700 }}>{tugrik(account.balance)}</div>
</Link>
</React.Fragment>
))}
</Card>
)}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeaderRow title={s.manualAssets.title} onAdd={() => setAddAssetOpen(true)} addLabel={s.manualAssets.add} />
{manualAssets.isLoading ? (
<SkeletonRows count={3} />
) : assets.length === 0 ? (
<EmptyBlock title={s.manualAssets.emptyTitle} subtitle={s.manualAssets.emptySubtitle} />
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{assets.map((asset) => (
<ManualAssetRow
key={asset.name}
asset={asset}
revaluing={assetMutations.revalue.isPending && revalueAssetName === asset.name}
onRevalue={() => setRevalueAssetName(asset.name)}
onDelete={() => setDeleteAssetName(asset.name)}
/>
))}
</div>
)}
</section>
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<SectionHeaderRow title={s.lending.title} onAdd={() => setAddLoanOpen(true)} addLabel={s.lending.add} />
{lending.isLoading ? (
<SkeletonRows count={2} />
) : loans.length === 0 ? (
<EmptyText>{s.lending.empty}</EmptyText>
) : (
<Card style={{ padding: 0 }}>
{loans.map((loan, i) => (
<React.Fragment key={loan.id}>
{i > 0 && <Divider />}
<LoanRow loan={loan} onDelete={() => setDeleteLoan(loan)} />
</React.Fragment>
))}
</Card>
)}
</section>
{addAssetOpen && (
<AddManualAssetSheet
onClose={() => setAddAssetOpen(false)}
saving={assetMutations.add.isPending}
onSave={async (values) => {
await assetMutations.add.mutateAsync(values);
setAddAssetOpen(false);
}}
/>
)}
{addLoanOpen && (
<AddLoanSheet
onClose={() => setAddLoanOpen(false)}
saving={lendingMutations.create.isPending}
onSave={async (values) => {
await lendingMutations.create.mutateAsync(values);
setAddLoanOpen(false);
}}
/>
)}
<ConfirmDialog
open={deleteAssetName !== null}
title={s.manualAssets.deleteTitle}
description={deleteAssetName ? s.manualAssets.deleteDescription(deleteAssetName) : undefined}
busy={assetMutations.delete.isPending}
onCancel={() => setDeleteAssetName(null)}
onConfirm={async () => {
if (!deleteAssetName) return;
await assetMutations.delete.mutateAsync(deleteAssetName);
setDeleteAssetName(null);
}}
/>
<ConfirmDialog
open={revalueAssetName !== null}
title={s.manualAssets.revalueTitle}
description={s.manualAssets.revalueDescription}
confirmLabel={s.manualAssets.revalue}
destructive={false}
busy={assetMutations.revalue.isPending}
onCancel={() => setRevalueAssetName(null)}
onConfirm={async () => {
const name = revalueAssetName;
if (!name) return;
setRevalueAssetName(null);
await assetMutations.revalue.mutateAsync(name);
}}
/>
<ConfirmDialog
open={deleteLoan !== null}
title={s.lending.deleteTitle}
description={deleteLoan ? s.lending.deleteDescription(deleteLoan.person) : undefined}
busy={lendingMutations.delete.isPending}
onCancel={() => setDeleteLoan(null)}
onConfirm={async () => {
if (!deleteLoan) return;
await lendingMutations.delete.mutateAsync(deleteLoan.id);
setDeleteLoan(null);
}}
/>
</div>
);
}
// --- Net worth header -------------------------------------------------------
function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; assets: string; liabilities: string }; loading: boolean }) {
return (
<Card style={{ padding: "24px 20px", background: "var(--mercury-balance-card)" }}>
{loading ? (
<Skeleton height="40px" />
) : (
<>
<div style={{ fontSize: 13, opacity: 0.75 }}>{s.netWorth.total}</div>
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 6 }}>{tugrik(netWorth?.total ?? "0")}</div>
<div style={{ display: "flex", gap: 20, marginTop: 16 }}>
<div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.assets}</div>
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.assets ?? "0")}</div>
</div>
<div>
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.liabilities}</div>
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.liabilities ?? "0")}</div>
</div>
</div>
</>
)}
</Card>
);
}
// --- Small shared bits -------------------------------------------------------
function SectionTitle({ children }: { children: React.ReactNode }) {
return <h2 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{children}</h2>;
}
function SectionHeaderRow({ title, onAdd, addLabel }: { title: string; onAdd: () => void; addLabel: string }) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<SectionTitle>{title}</SectionTitle>
<button
type="button"
onClick={onAdd}
aria-label={addLabel}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 22, lineHeight: 1, padding: 4 }}
>
+
</button>
</div>
);
}
function EmptyText({ children }: { children: React.ReactNode }) {
return (
<p style={{ ...mutedStyle, fontSize: 13, padding: "12px 4px", margin: 0 }}>{children}</p>
);
}
function EmptyBlock({ title, subtitle }: { title: string; subtitle: string }) {
return (
<div style={{ textAlign: "center", padding: "40px 20px", display: "flex", flexDirection: "column", gap: 8 }}>
<div style={{ fontWeight: 600 }}>{title}</div>
<div style={{ fontSize: 13, ...mutedStyle }}>{subtitle}</div>
</div>
);
}
function SkeletonRows({ count }: { count: number }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} height="64px" />
))}
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
}
// --- Manual asset row ---------------------------------------------------
function ManualAssetRow({
asset,
revaluing,
onRevalue,
onDelete,
}: {
asset: ManualAsset;
revaluing: boolean;
onRevalue: () => void;
onDelete: () => void;
}) {
const change = Number(asset.change || "0");
const positive = change >= 0;
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
return (
<Card style={{ padding: 0 }}>
<div style={rowStyle}>
<Link href={`/assets/manual/${encodeURIComponent(asset.name)}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
<div style={{ fontWeight: 700 }}>{asset.name}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>
{categoryLabel} · {conditionLabel}
</div>
</Link>
<div style={{ textAlign: "right" }}>
<div style={{ fontWeight: 700 }}>{tugrikRaw(asset.value)}</div>
<div style={{ fontSize: 11, ...mutedStyle }}>
{s.manualAssets.acquiredPrefix}: {tugrikRaw(asset.acquiredValue)}
</div>
<div
style={{
fontSize: 12,
fontWeight: 600,
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{positive ? "+" : ""}
{tugrikRaw(Math.abs(change))}
</div>
</div>
</div>
<div style={{ display: "flex", gap: 8, padding: "0 16px 12px" }}>
<button
type="button"
onClick={onRevalue}
disabled={revaluing}
style={{
flex: 1,
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
borderRadius: 8,
background: "none",
padding: "6px 10px",
fontSize: 12,
cursor: revaluing ? "default" : "pointer",
}}
>
{revaluing ? "…" : s.manualAssets.revalue}
</button>
<button
type="button"
onClick={onDelete}
style={{
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
borderRadius: 8,
background: "none",
padding: "6px 10px",
fontSize: 12,
color: "var(--seed-color-fg-critical)",
cursor: "pointer",
}}
>
{s.manualAssets.delete}
</button>
</div>
</Card>
);
}
// --- Lending row ---------------------------------------------------------
function statusLabel(loan: Lending): string {
if (loan.overdue) return s.lending.statusOverdue;
switch (loan.status) {
case "repaid":
return s.lending.statusPaid;
case "partial":
return s.lending.statusPartial;
default:
return s.lending.statusUnpaid;
}
}
function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
return (
<div style={rowStyle}>
<Link href={`/assets/lending/${loan.id}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
<div style={{ fontWeight: 600 }}>{loan.person}</div>
<div style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
{statusLabel(loan)}
</div>
</Link>
<div style={{ textAlign: "right" }}>
<div style={{ fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
<div style={{ fontSize: 12, ...mutedStyle }}>/ {tugrikRaw(loan.principal)}</div>
</div>
<button
type="button"
onClick={onDelete}
aria-label={s.lending.delete}
style={{ background: "none", border: "none", color: "var(--seed-color-fg-neutral-subtle)", cursor: "pointer", fontSize: 16 }}
>
×
</button>
</div>
);
}
// --- Add manual asset sheet ------------------------------------------------
interface NewAssetValues {
name: string;
category: string;
value: string;
acquiredValue: string;
condition: string;
}
function AddManualAssetSheet({
onClose,
onSave,
saving,
}: {
onClose: () => void;
onSave: (values: NewAssetValues) => void | Promise<void>;
saving: boolean;
}) {
const [name, setName] = React.useState("");
const [category, setCategory] = React.useState("car");
const [condition, setCondition] = React.useState("used");
const [price, setPrice] = React.useState("");
const canSave = name.trim().length > 0 && Number(price) > 0 && !saving;
return (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.manualAssets.add}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={name} onValueChange={setName} name="asset-name">
<TextFieldInput placeholder={s.manualAssets.fields.name} aria-label={s.manualAssets.fields.name} autoFocus />
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.manualAssets.fields.category}
<select value={category} onChange={(e) => setCategory(e.target.value)} style={{ padding: 8, borderRadius: 8 }}>
{Object.entries(s.manualAssets.categories).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
<TextFieldRoot value={price} onValueChange={setPrice} name="asset-price">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.manualAssets.fields.price}
aria-label={s.manualAssets.fields.price}
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.manualAssets.fields.condition}
<select value={condition} onChange={(e) => setCondition(e.target.value)} style={{ padding: 8, borderRadius: 8 }}>
{Object.entries(s.manualAssets.conditions).map(([key, label]) => (
<option key={key} value={key}>
{label}
</option>
))}
</select>
</label>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() =>
onSave({
name: name.trim(),
category,
value: price,
acquiredValue: price,
condition,
})
}
>
{s.common.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}
// --- Add loan sheet ---------------------------------------------------------
interface NewLoanValues {
person: string;
amount: string;
lentOn: string;
dueOn?: string;
note?: string;
}
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
function AddLoanSheet({
onClose,
onSave,
saving,
}: {
onClose: () => void;
onSave: (values: NewLoanValues) => void | Promise<void>;
saving: boolean;
}) {
const [person, setPerson] = React.useState("");
const [amount, setAmount] = React.useState("");
const [lentOn, setLentOn] = React.useState(todayISO());
const [dueOn, setDueOn] = React.useState("");
const [note, setNote] = React.useState("");
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
return (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.lending.add}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={person} onValueChange={setPerson} name="loan-person">
<TextFieldInput placeholder={s.lending.fields.person} aria-label={s.lending.fields.person} autoFocus />
</TextFieldRoot>
<TextFieldRoot value={amount} onValueChange={setAmount} name="loan-amount">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.lending.fields.amount}
aria-label={s.lending.fields.amount}
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.lentOn}
<input type="date" value={lentOn} onChange={(e) => setLentOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.dueOn}
<input type="date" value={dueOn} onChange={(e) => setDueOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
<TextFieldRoot value={note} onValueChange={setNote} name="loan-note">
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
</TextFieldRoot>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() =>
onSave({
person: person.trim(),
amount,
lentOn,
dueOn: dueOn || undefined,
note: note.trim() || undefined,
})
}
>
{s.common.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}

View file

@ -0,0 +1,74 @@
"use client";
import {
DialogRoot,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@seed-design/react";
import { MercuryButton } from "@/ds";
import { assetsStrings as s } from "./strings";
export interface ConfirmDialogProps {
open: boolean;
title: string;
description?: string;
confirmLabel?: string;
destructive?: boolean;
busy?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
/**
* A confirmation dialog built on Seed's `Dialog` primitives used for every
* destructive/irreversible action in the assets + lending feature (delete,
* revalue) instead of a native `window.confirm`, per Mercury's UI
* conventions (mirrors iOS's `seedDialog`).
*/
export function ConfirmDialog({
open,
title,
description,
confirmLabel = s.common.delete,
destructive = true,
busy = false,
onConfirm,
onCancel,
}: ConfirmDialogProps) {
return (
<DialogRoot
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<DialogBackdrop />
<DialogPositioner>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
{description && <DialogDescription>{description}</DialogDescription>}
<DialogFooter style={{ display: "flex", gap: 12, paddingTop: 16 }}>
<MercuryButton variant="secondary" onClick={onCancel} style={{ flex: 1 }} disabled={busy}>
{s.common.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
onClick={onConfirm}
loading={busy}
style={{ flex: 1, ...(destructive ? { background: "var(--seed-color-fg-critical)", color: "#fff" } : {}) }}
>
{confirmLabel}
</MercuryButton>
</DialogFooter>
</DialogContent>
</DialogPositioner>
</DialogRoot>
);
}

View file

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

View file

@ -0,0 +1,262 @@
"use client";
import * as React from "react";
import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { z } from "zod";
import { Skeleton } from "@seed-design/react";
import { apiGet } from "@/api/client";
import { useManualAssets } from "@/api/hooks/reads";
import { useManualAssetMutations } from "@/api/hooks/mutations";
import { AssetPointSchema, AssetListingSchema, type ManualAsset, type RevalueResult } from "@/api/schemas";
import { Card, MercuryButton } from "@/ds";
import { tugrikRaw, tugrikShortRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings";
import { ValueHistoryChart } from "./ValueHistoryChart";
import { ConfirmDialog } from "./ConfirmDialog";
// --- Local reads (no hook exists yet for asset history/listings; these mirror
// the shape of reads.ts's other hooks and hit the same endpoints iOS uses:
// GET /manual-assets/{name}/history and /listings — see MercuryAPI.swift). ---
const HistoryResponseSchema = z.object({ history: z.array(AssetPointSchema) });
const ListingsResponseSchema = z.object({ listings: z.array(AssetListingSchema) });
function useAssetHistory(name: string) {
return useQuery({
queryKey: ["assetHistory", name],
queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/history`, HistoryResponseSchema)).history,
});
}
function useAssetListings(name: string) {
return useQuery({
queryKey: ["assetListings", name],
queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/listings`, ListingsResponseSchema)).listings,
});
}
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
export interface ManualAssetDetailProps {
name: string;
}
export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
const router = useRouter();
const manualAssets = useManualAssets();
const history = useAssetHistory(name);
const listings = useAssetListings(name);
const mutations = useManualAssetMutations();
const [confirmRevalue, setConfirmRevalue] = React.useState(false);
const [confirmDelete, setConfirmDelete] = React.useState(false);
const [lastFinding, setLastFinding] = React.useState<RevalueResult | null>(null);
const [revalueFailed, setRevalueFailed] = React.useState(false);
const asset: ManualAsset | undefined = (manualAssets.data ?? []).find((a) => a.name === name);
async function runRevalue() {
setConfirmRevalue(false);
setRevalueFailed(false);
const result = await mutations.revalue.mutateAsync(name);
setLastFinding(result ?? null);
setRevalueFailed(!result);
await Promise.all([history.refetch(), listings.refetch()]);
}
async function runDelete() {
setConfirmDelete(false);
await mutations.delete.mutateAsync(name);
router.push("/assets");
}
if (manualAssets.isLoading && !asset) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<Skeleton height="40px" />
<Skeleton height="160px" />
</div>
);
}
if (!asset) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<BackLink />
<p style={mutedStyle}>{name}</p>
</div>
);
}
const value = Number(asset.value || "0");
const acquired = Number(asset.acquiredValue || "0");
const change = Number(asset.change || "0");
const positive = change >= 0;
const percent = acquired > 0 ? (change / acquired) * 100 : null;
const points = (history.data ?? [])
.map((p) => ({ date: new Date(p.recordedAt), value: Number(p.value) }))
.filter((p) => !Number.isNaN(p.date.getTime()) && !Number.isNaN(p.value))
.sort((a, b) => a.date.getTime() - b.date.getTime());
const fetchPoints = (history.data ?? [])
.filter((p) => p.kind === "fetch")
.slice()
.sort((a, b) => (a.recordedAt < b.recordedAt ? 1 : -1));
const listingRows = listings.data ?? [];
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
<BackLink />
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{asset.name}</h1>
</header>
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
<div style={{ fontSize: 12, ...mutedStyle }}>{categoryLabel} · {conditionLabel}</div>
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(value)}</div>
<div
style={{
marginTop: 6,
fontWeight: 600,
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{positive ? "+" : ""}
{tugrikRaw(Math.abs(change))}
{percent !== null && ` (${positive ? "+" : ""}${Math.abs(percent).toFixed(1)}%)`}
</div>
</Card>
{lastFinding && (
<Card style={{ background: "color-mix(in srgb, var(--seed-color-fg-positive) 10%, transparent)" }}>
<div style={{ fontWeight: 600 }}>
{lastFinding.source} {lastFinding.count} {s.assetDetail.findingSuffix}
</div>
<div style={{ fontSize: 13, marginTop: 4 }}>
{tugrikShortRaw(lastFinding.low)}{tugrikShortRaw(lastFinding.high)} · {s.assetDetail.avg}{" "}
<strong>{tugrikShortRaw(lastFinding.value)}</strong>
</div>
</Card>
)}
{revalueFailed && !lastFinding && (
<Card>
<div style={{ fontWeight: 600 }}>{s.assetDetail.notFound}</div>
</Card>
)}
<Card>
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.chartTitle}</h3>
{history.isLoading ? <Skeleton height="160px" /> : <ValueHistoryChart points={points} positive={positive} />}
</Card>
{!history.isLoading && (
<Card>
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.research}</h3>
{fetchPoints.length > 0 && (
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 12 }}>
{fetchPoints.map((p, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13 }}>
<span>{p.recordedAt.slice(0, 10)}</span>
<span style={{ ...mutedStyle }}>
{p.low && p.high ? `${tugrikShortRaw(p.low)}${tugrikShortRaw(p.high)} · ` : ""}
{p.count ?? 0} зар
</span>
<strong>{tugrikShortRaw(p.value)}</strong>
</div>
))}
</div>
)}
<h4 style={{ margin: "0 0 8px", fontSize: 13, fontWeight: 700 }}>{s.assetDetail.listings(listingRows.length)}</h4>
{listingRows.length === 0 ? (
<p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.assetDetail.listingsEmpty}</p>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
{listingRows.slice(0, 15).map((l, i) => (
<a
key={i}
href={l.url}
target="_blank"
rel="noreferrer"
style={{ display: "flex", justifyContent: "space-between", gap: 10, color: "inherit", textDecoration: "none" }}
>
<span style={{ fontSize: 13, flex: 1 }}>{l.title}</span>
<strong style={{ fontSize: 13, whiteSpace: "nowrap" }}>{tugrikShortRaw(l.price)}</strong>
</a>
))}
</div>
)}
</Card>
)}
<Card style={{ padding: "6px 20px" }}>
<DetailRow label={s.assetDetail.acquiredValue} value={tugrikRaw(acquired)} />
<Divider />
<DetailRow label={s.assetDetail.currentValue} value={tugrikRaw(value)} />
<Divider />
<DetailRow
label={s.assetDetail.change}
value={`${positive ? "+" : ""}${tugrikRaw(Math.abs(change))}`}
color={positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)"}
/>
<Divider />
<DetailRow label={s.assetDetail.category} value={categoryLabel} />
<Divider />
<DetailRow label={s.assetDetail.condition} value={conditionLabel} />
</Card>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<MercuryButton variant="primary" onClick={() => setConfirmRevalue(true)} disabled={mutations.revalue.isPending}>
{s.manualAssets.revalue}
</MercuryButton>
<MercuryButton variant="ghost" onClick={() => setConfirmDelete(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
{s.manualAssets.delete}
</MercuryButton>
</div>
<ConfirmDialog
open={confirmRevalue}
title={s.manualAssets.revalueTitle}
description={s.manualAssets.revalueDescription}
confirmLabel={s.manualAssets.revalue}
destructive={false}
busy={mutations.revalue.isPending}
onCancel={() => setConfirmRevalue(false)}
onConfirm={runRevalue}
/>
<ConfirmDialog
open={confirmDelete}
title={s.manualAssets.deleteTitle}
description={s.manualAssets.deleteDescription(asset.name)}
busy={mutations.delete.isPending}
onCancel={() => setConfirmDelete(false)}
onConfirm={runDelete}
/>
</div>
);
}
function BackLink() {
return (
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
{s.common.back}
</a>
);
}
function DetailRow({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
<span style={mutedStyle}>{label}</span>
<span style={{ fontWeight: 600, color }}>{value}</span>
</div>
);
}
function Divider() {
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
}

View file

@ -0,0 +1,84 @@
import { assetsStrings as s } from "./strings";
export interface ValueHistoryPoint {
date: Date;
value: number;
}
export interface ValueHistoryChartProps {
points: ValueHistoryPoint[];
positive: boolean;
}
const WIDTH = 320;
const HEIGHT = 160;
const PAD_X = 6;
const PAD_Y = 12;
/**
* A minimal, dependency-light line chart (inline SVG, no charting library)
* mirroring the value-over-time chart in
* ios/Mercury/Features/Assets/AssetDetailView.swift (`chart` / `chartCard`):
* a catmull-rom-ish straight-segment line through ascending (date, value)
* points, y-padded ~8%, with a dot per point. Below 2 points it falls back
* to the same empty-state copy as iOS.
*/
export function ValueHistoryChart({ points, positive }: ValueHistoryChartProps) {
if (points.length < 2) {
return (
<div
style={{
height: HEIGHT,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: 6,
textAlign: "center",
}}
>
<p style={{ margin: 0, fontWeight: 600 }}>{s.assetDetail.chartEmptyTitle}</p>
<p style={{ margin: 0, fontSize: 12, color: "var(--seed-color-fg-neutral-subtle)" }}>
{s.assetDetail.chartEmptySubtitle}
</p>
</div>
);
}
const values = points.map((p) => p.value);
const lo = Math.min(...values);
const hi = Math.max(...values);
const pad = Math.max((hi - lo) * 0.08, hi * 0.02, 1);
const yMin = lo - pad;
const yMax = hi + pad;
const minTime = points[0].date.getTime();
const maxTime = points[points.length - 1].date.getTime();
const timeSpan = Math.max(maxTime - minTime, 1);
const xFor = (t: number) => PAD_X + ((t - minTime) / timeSpan) * (WIDTH - PAD_X * 2);
const yFor = (v: number) => HEIGHT - PAD_Y - ((v - yMin) / (yMax - yMin)) * (HEIGHT - PAD_Y * 2);
const linePath = points
.map((p, i) => `${i === 0 ? "M" : "L"} ${xFor(p.date.getTime()).toFixed(1)} ${yFor(p.value).toFixed(1)}`)
.join(" ");
const lineColor = positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)";
return (
<svg
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
width="100%"
height={HEIGHT}
preserveAspectRatio="none"
role="img"
aria-label={s.assetDetail.chartTitle}
style={{ display: "block", overflow: "visible" }}
>
<path d={linePath} fill="none" stroke={lineColor} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
{points.map((p, i) => (
<circle key={i} cx={xFor(p.date.getTime())} cy={yFor(p.value)} r={3} fill="var(--mercury-brand-yellow)" />
))}
</svg>
);
}

View file

@ -0,0 +1,106 @@
// Assets + Lending feature copy, ported from
// ios/Mercury/Features/Assets/*.swift and ios/Mercury/Features/Lending/*.swift.
// The net-worth header has no direct iOS analogue (there it only backs the
// Home balance card) — labels there use standard Mongolian finance terms.
export const assetsStrings = {
header: {
title: "Хөрөнгө",
},
netWorth: {
total: "Цэвэр хөрөнгө",
assets: "Хөрөнгө",
liabilities: "Өр төлбөр",
},
accounts: {
title: "Миний дансууд",
empty: "Холбосон данс алга",
},
manualAssets: {
title: "Хөрөнгийн жагсаалт",
totalLabel: "Нийт хөрөнгийн үнэлгээ",
add: "Хөрөнгө нэмэх",
edit: "Хөрөнгө засах",
emptyTitle: "Хөрөнгө бүртгээгүй байна",
emptySubtitle: "Машин, утас зэрэг хөрөнгөө нэмэхийн тулд + дарна уу",
acquiredPrefix: "Авсан",
revalue: "Зах зээлийн үнэ шинэчлэх",
revalueTitle: "Зах зээлийн үнэ шинэчлэх үү?",
revalueDescription: "Зар хайж, хамгийн сүүлийн зах зээлийн үнийг тооцно.",
delete: "Устгах",
deleteTitle: "Хөрөнгө устгах уу?",
deleteDescription: (name: string) => `«${name}» жагсаалтаас хасагдана.`,
fields: {
name: "Хөрөнгийн нэр",
category: "Ангилал",
condition: "Байдал",
price: "Авсан үнэ",
},
categories: {
car: "Машин",
electronics: "Электрон",
property: "Үл хөдлөх",
other: "Бус",
} as Record<string, string>,
conditions: {
new: "Шинэ",
used: "Хуучин",
} as Record<string, string>,
},
assetDetail: {
acquiredValue: "Авсан үнэ",
currentValue: "Одоогийн үнэ",
change: "Өөрчлөлт",
category: "Ангилал",
condition: "Нөхцөл",
chartTitle: "Үнийн түүх",
chartEmptyTitle: "Үнийн түүх хомс",
chartEmptySubtitle: "Зах зээлийн үнэ шинэчлэх бүрт нэг цэг нэмэгдэнэ",
research: "Зах зээлийн судалгаа",
listings: (count: number) => `Зар (${count})`,
listingsEmpty: "Зар олдоогүй — үнэ шинэчилнэ үү",
findingSuffix: "зар олдлоо",
avg: "дунджаар",
notFound: "Зах зээлийн үнэ олдсонгүй",
},
lending: {
title: "Найзуудад өгсөн зээл",
empty: "Зээл бүртгэгдээгүй байна",
add: "Шинэ зээл",
total: "нийт",
statusPaid: "Төлсөн",
statusPartial: "Хэсэгчлэн төлсөн",
statusUnpaid: "Төлөөгүй",
statusOverdue: "Хугацаа хэтэрсэн",
deleteTitle: "Зээл устгах уу?",
deleteDescription: (person: string) => `«${person}»-д өгсөн зээл устана.`,
delete: "Зээл устгах",
fields: {
person: "Хэнд өгсөн",
amount: "Дүн",
lentOn: "Өгсөн огноо",
dueOn: "Төлөх огноо",
note: "Тэмдэглэл",
},
repayments: {
title: "Төлөлтийн түүх",
empty: "Төлөлт бүртгэгдээгүй",
add: "Төлөлт нэмэх",
deleteTitle: "Төлөлт устгах уу?",
deleteDescription: "Энэ төлөлтийг түүхээс хасна.",
},
},
accountDetail: {
balance: "Үлдэгдэл",
accountNumber: "Дансны дугаар",
bank: "Банк",
currency: "Валют",
recentTransactions: "Сүүлийн гүйлгээ",
noTransactions: "Гүйлгээ алга",
},
common: {
save: "Хадгалах",
cancel: "Болих",
delete: "Устгах",
back: "Буцах",
},
} as const;

View file

@ -0,0 +1,24 @@
"use client";
import { useEffect, useState } from "react";
import { HIDE_AMOUNTS_EVENT } from "@/ds";
/**
* `tugrik()` / `tugrikShort()` (web/src/ds/money.ts) read the global
* hide-amounts flag from localStorage synchronously, so a component that
* calls them needs a reason to re-render when `HideAmountsToggle` flips it.
* Call this in any component that formats money with those helpers.
*/
export function useHideAmountsTick(): number {
const [tick, setTick] = useState(0);
useEffect(() => {
function onChange() {
setTick((t) => t + 1);
}
window.addEventListener(HIDE_AMOUNTS_EVENT, onChange);
return () => window.removeEventListener(HIDE_AMOUNTS_EVENT, onChange);
}, []);
return tick;
}

View file

@ -247,7 +247,7 @@ export function AuthForm({
function FormError({ message }: { message?: string }) { function FormError({ message }: { message?: string }) {
if (!message) return null; if (!message) return null;
return ( return (
<p role="alert" className="text-sm" style={{ color: "#d92626" }}> <p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
{message} {message}
</p> </p>
); );

View file

@ -0,0 +1,225 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react";
import { Card, AmountToggle, HideAmountsToggle } from "../../ds";
import { tugrikShortRaw } from "../../ds/money";
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
import { buildHome, type HomeData } from "./buildHome";
import { sample } from "./sample";
import { homeStrings as s } from "./strings";
/** A number that's redacted with a Seed skeleton block while loading, and a
* tap-to-reveal `AmountToggle` once real data has arrived. Mirrors iOS's
* `.skeleton(loading)` view modifier, which redacts the finished layout in
* place rather than swapping in a separate spinner. */
function Amount({ value, loading, width = "72px" }: { value: number; loading: boolean; width?: string }) {
if (loading) return <Skeleton height="1em" width={width} style={{ display: "inline-block" }} />;
return <AmountToggle value={value} />;
}
/** The circular "₮" badge used on every card (togrogCircle in DashboardView.swift). */
function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
return (
<div
style={{
width: 49,
height: 49,
borderRadius: "50%",
background: bg,
color: fg,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: 700,
fontSize: 20,
flexShrink: 0,
}}
>
</div>
);
}
/** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit +
* top budgets, this-month spend, and recent income/expense. Figures come
* from `buildHome` (real where the backend has them, `sample` otherwise).
* Ported from ios/Mercury/Features/Home/DashboardView.swift. */
export function DashboardView() {
const netWorthQ = useNetWorth();
const monthQ = useAnalyzeMonth();
const todayQ = useAnalyzeToday();
const budgetQ = useBudget();
const loading = netWorthQ.isLoading || monthQ.isLoading || todayQ.isLoading || budgetQ.isLoading;
const built = buildHome({
netWorth: netWorthQ.data,
month: monthQ.data,
today: todayQ.data,
budget: budgetQ.data,
});
const data: HomeData = built ?? sample;
const budgetDenominator = data.availableBudget;
const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0;
const dailyFraction = data.dailyLimitTotal > 0 ? Math.min(1, data.dailyLimitUsed / data.dailyLimitTotal) : 0;
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<span style={{ fontWeight: 600, fontSize: 18, color: "var(--seed-color-fg-neutral)" }}>{s.wordmark}</span>
<HideAmountsToggle label={s.hideAmounts} />
</header>
<div className="grid grid-cols-1 gap-[18px] md:grid-cols-2 md:items-start md:gap-5">
{/* Safe-to-spend hero (green): how much is left to spend this month
after committed obligations and what's already been spent. */}
<Link
href="/accounting"
style={{
display: "block",
textDecoration: "none",
color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
<Amount value={data.safeToSpend} loading={loading} width="140px" />
</span>
</div>
</div>
<div
style={{
marginTop: 12,
height: 6,
borderRadius: 999,
background: "rgba(0,0,0,0.13)",
overflow: "hidden",
}}
>
<div
style={{
height: "100%",
width: `${Math.max(2, fraction * 100)}%`,
borderRadius: 999,
background: data.overspent ? "var(--seed-color-bg-critical, #d92d20)" : "var(--mercury-on-brand)",
}}
/>
</div>
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 12 }}>
<span>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={loading} />
<span style={{ fontWeight: 400, fontSize: 11 }}> / {tugrikShortRaw(budgetDenominator)}</span>
</span>
</div>
</Link>
{/* Daily limit + top budgets (blue). */}
<Link
href="/planner"
style={{
display: "block",
textDecoration: "none",
color: "#fff",
background: "var(--mercury-limit-card)",
borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite">
<ProgressCircleTrack />
<ProgressCircleRange />
</ProgressCircleRoot>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 12 }}>{s.dailyLimit}</span>
<span style={{ fontSize: 22, fontWeight: 700 }}>
<Amount value={data.dailyLimitUsed} loading={loading} width="60px" />
{" / "}
<Amount value={data.dailyLimitTotal} loading={loading} width="60px" />
</span>
</div>
</div>
<div style={{ marginTop: 4, fontSize: 11, opacity: 0.85 }}>
{s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" />
</div>
<hr style={{ margin: "12px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
{data.budgets.map((b) => (
<div key={b.name} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}>
<span style={{ fontSize: 12 }}>{b.name}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>
<span style={{ color: "var(--mercury-warning-chip)" }}>
<Amount value={b.spent} loading={loading} width="50px" />
</span>
<span style={{ fontWeight: 700, fontSize: 11 }}> / {b.range}</span>
</span>
</div>
))}
</Link>
{/* This month's spend (white). */}
<Card style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.thisMonth}</span>
<div style={{ display: "flex", alignItems: "baseline", gap: 4 }}>
{loading ? (
<Skeleton height="1.6em" width="120px" />
) : (
<span style={{ fontSize: 26, fontWeight: 700, color: "var(--seed-color-fg-neutral)" }}>
{tugrikShortRaw(data.monthlyExpense)}
</span>
)}
<span style={{ fontSize: 16, fontWeight: 600, color: "var(--seed-color-fg-placeholder)" }}>
{s.expenseLabel}
</span>
</div>
<div style={{ display: "flex", justifyContent: "flex-end" }}>
<Link
href="/accounting"
style={{
fontSize: 12,
color: "var(--mercury-on-brand)",
background: "var(--mercury-warning-chip)",
borderRadius: 999,
padding: "8px 12px",
textDecoration: "none",
}}
>
{s.viewAll}
</Link>
</div>
</Card>
{/* Recent income / expense ledgers. */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Card>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.income}</span>
{data.income.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))}
</Card>
<Card>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.expense}</span>
{data.expense.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))}
</Card>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,97 @@
import { describe, it, expect } from "vitest";
import { buildHome } from "./buildHome";
import * as fx from "../../test/fixtures";
describe("buildHome", () => {
it("uses monthLimit when set and discretionary spend", () => {
const h = buildHome({ netWorth: fx.netWorth, month: fx.analyzeMonth, today: fx.analyzeMonth, budget: fx.budget })!;
expect(h.availableBudget).toBe(900000); // monthLimit
expect(h.monthlyExpense).toBe(800000); // discretionaryExpense
expect(h.safeToSpend).toBe(100000);
expect(h.overspent).toBe(false);
expect(h.totalBalance).toBe(452200);
expect(h.todayExpense).toBe(1200000); // today's `expense` field (not discretionary)
});
it("returns null for empty account", () =>
expect(
buildHome({
netWorth: { assets: "0", liabilities: "0", total: "0" },
month: { ...fx.analyzeMonth, expense: "0", discretionaryExpense: "0", expensePayees: [] },
today: null,
budget: null,
}),
).toBeNull());
it("returns null when month is missing", () =>
expect(buildHome({ netWorth: fx.netWorth, month: null, today: null, budget: null })).toBeNull());
it("falls back to availableIncome when monthLimit is 0", () => {
const h = buildHome({
netWorth: fx.netWorth,
month: fx.analyzeMonth,
today: null,
budget: { ...fx.budget, monthLimit: "0" },
})!;
expect(h.availableBudget).toBe(2036795); // availableIncome
});
it("falls back to expense when discretionaryExpense is absent", () => {
const h = buildHome({
netWorth: fx.netWorth,
month: { ...fx.analyzeMonth, discretionaryExpense: null },
today: null,
budget: fx.budget,
})!;
expect(h.monthlyExpense).toBe(1200000); // expense
});
it("flags overspent when monthlyExpense exceeds availableBudget", () => {
const h = buildHome({
netWorth: fx.netWorth,
month: { ...fx.analyzeMonth, discretionaryExpense: "1000000" },
today: null,
budget: { ...fx.budget, monthLimit: "900000" },
})!;
expect(h.overspent).toBe(true);
expect(h.safeToSpend).toBe(0); // floored at 0, not negative
});
it("takes the top-3 day-horizon budget rows with limit > 0, sorted desc", () => {
const h = buildHome({
netWorth: fx.netWorth,
month: fx.analyzeMonth,
today: null,
budget: {
...fx.budget,
report: {
...fx.budget.report,
day: {
overallSpent: "15500",
overallLimit: "35500",
rows: [
{ category: "Тээвэр", spent: "0", limit: "5000" },
{ category: "Хоол", spent: "23700", limit: "27000" },
{ category: "Coffee", spent: "23700", limit: "6000" },
{ category: "Бусад", spent: "0", limit: "0" },
],
},
},
},
})!;
expect(h.budgets).toHaveLength(3);
expect(h.budgets.map((b) => b.name)).toEqual(["Хоол", "Coffee", "Тээвэр"]);
expect(h.budgets.every((b) => b.range.includes("₮"))).toBe(true);
});
it("falls back to sample income/expense ledgers when payees are empty", () => {
const h = buildHome({
netWorth: fx.netWorth,
month: { ...fx.analyzeMonth, incomePayees: [], expensePayees: [] },
today: null,
budget: fx.budget,
})!;
expect(h.income.length).toBeGreaterThan(0);
expect(h.expense.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,128 @@
import type { NetWorth } from "../../api/schemas/networth";
import type { Analyze } from "../../api/schemas/analyze";
import type { Budget } from "../../api/schemas/budget";
import { tugrik, tugrikRaw } from "../../ds/money";
import { sample } from "./sample";
/** Parse a backend decimal string ("900000") into a number. Mirrors
* `Decimal(string:) ?? 0` in HomeData.swift's local `dec` helper. */
function dec(s?: string | null): number {
if (!s) return 0;
const n = Number(s);
return Number.isFinite(n) ? n : 0;
}
/** One budgeted category row on the blue limit card. */
export interface BudgetRow {
name: string;
spent: number;
/** Formatted target amount, e.g. "27,000₮". */
range: string;
}
/** One income/expense entry under the Орлого / Зарлага cards. Field names
* mirror iOS's `LedgerRow` exactly: `title` holds the formatted amount,
* `date` holds a label (the payee name on the real-data path, a literal
* date string in `.sample`). */
export interface LedgerRow {
title: string;
date: string;
}
/** Everything the dashboard renders. Populated from the backend where
* endpoints exist (balance, monthly spend); the rest uses representative
* sample values until the matching backend features land. */
export interface HomeData {
totalBalance: number;
todayExpense: number;
dailyLimitUsed: number;
dailyLimitTotal: number;
budgets: BudgetRow[];
monthlyExpense: number;
/** The monthly budget denominator: the user's explicit monthly cap when
* set (> 0), otherwise salary-derived disposable income. */
availableBudget: number;
income: LedgerRow[];
expense: LedgerRow[];
/** What's left to spend this month, floored at 0. */
safeToSpend: number;
/** Whether monthly spend has exceeded the available budget. */
overspent: boolean;
}
export interface BuildHomeInput {
netWorth: NetWorth | null | undefined;
month: Analyze | null | undefined;
today: Analyze | null | undefined;
budget: Budget | null | undefined;
}
/** Build real dashboard data from the backend. Balance comes from /networth;
* this-month spend, the spend breakdown, and the income/expense ledgers come
* from /analyze (month) via real payees; today's spend from /analyze
* (today). Returns null when the account has no data yet, so the caller
* keeps `sample`. Ported EXACTLY from `HomeData.live` in
* ios/Mercury/Features/Home/HomeData.swift. */
export function buildHome({ netWorth, month, today, budget }: BuildHomeInput): HomeData | null {
if (!month) return null;
const balance = dec(netWorth?.total);
// "Spent" = real discretionary spend (excludes the loan payment + transfers),
// so the safe-to-spend hero and the monthly figure aren't inflated by money
// that's already committed or just moved between people/accounts.
const monthlyExpense = dec(month.discretionaryExpense ?? month.expense);
const payees = month.expensePayees ?? [];
const incomePayees = month.incomePayees ?? [];
// An empty account → no real data; fall back to the sample.
if (balance === 0 && monthlyExpense === 0 && payees.length === 0) return null;
const todayExpense = dec(today?.expense);
// Hero budget (the "Энэ сар зарцуулж болох" denominator): honor the user's
// explicitly-set monthly limit when present; only fall back to salary-
// derived disposable income (planned income loans savings subs) when
// no limit is set. Paired with the DISCRETIONARY monthlyExpense above (not
// the budget report's all-expense overallSpent), since the cap is a
// spending cap.
const monthLimit = dec(budget?.monthLimit);
const availableBudget = monthLimit > 0 ? monthLimit : dec(budget?.availableIncome);
// Daily limit + per-category budgets come from the budget's DAY horizon —
// the same source as the Төлөвлөгөө hub (real limits, no payee fallback).
const dayReport = budget?.report.day;
const dailyLimitTotal = dec(dayReport?.overallLimit);
const dailyLimitUsed = dec(dayReport?.overallSpent);
// Only the categories with a real daily limit, biggest first — a proper
// daily budget (e.g. Хоол хүнс, Кофе, Тээвэр), not the uncategorized bucket.
const budgets: BudgetRow[] = (dayReport?.rows ?? [])
.filter((row) => dec(row.limit) > 0)
.sort((a, b) => dec(b.limit) - dec(a.limit))
.slice(0, 3)
.map((row) => ({
name: row.category,
spent: dec(row.spent),
range: tugrikRaw(dec(row.limit)),
}));
const income: LedgerRow[] = incomePayees
.slice(0, 2)
.map((p) => ({ title: tugrik(dec(p.total)), date: p.name }));
const expense: LedgerRow[] = payees
.slice(0, 2)
.map((p) => ({ title: tugrik(dec(p.total)), date: p.name }));
return {
totalBalance: balance,
todayExpense,
dailyLimitUsed,
dailyLimitTotal,
budgets,
monthlyExpense,
availableBudget,
income: income.length === 0 ? sample.income : income,
expense: expense.length === 0 ? sample.expense : expense,
safeToSpend: Math.max(0, availableBudget - monthlyExpense),
overspent: monthlyExpense > availableBudget,
};
}

View file

@ -0,0 +1,46 @@
import { render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
import { server } from "../../test/server";
import { DashboardView } from "./DashboardView";
/**
* Integration test: DashboardView wired to real react-query hooks, backed by
* MSW (not a hook mock) serving /networth + /analyze (month & today) +
* /budget from the fixtures in src/test/fixtures.ts. Asserts the dashboard
* renders the REAL computed figures (buildHome output) rather than falling
* back to the `sample` placeholder see buildHome.test.ts for the expected
* numbers this fixture set produces: safeToSpend 100,000 (900,000 monthLimit
* 800,000 discretionaryExpense) and monthlyExpense 800,000, both of which
* differ from `sample`'s 836,795 / 1,2сая.
*/
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
function renderDashboard() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(
<QueryClientProvider client={queryClient}>
<DashboardView />
</QueryClientProvider>,
);
}
describe("DashboardView (MSW integration)", () => {
it("renders the real safe-to-spend and monthly-spend figures, not the sample fallback", async () => {
renderDashboard();
// Real safe-to-spend (900,000 monthLimit 800,000 discretionaryExpense).
await waitFor(() => expect(screen.getByText("100,000₮")).toBeInTheDocument());
// Real this-month spend (discretionaryExpense, sub-million so shown in
// full) — appears both in the hero's "spent" line (AmountToggle button)
// and the "This month" card (plain text), so allow either/both.
expect(screen.getAllByText("800,000₮").length).toBeGreaterThan(0);
// The sample fallback's distinctive figures must NOT appear.
expect(screen.queryByText("836,795₮")).not.toBeInTheDocument();
expect(screen.queryByText("1,2сая₮")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,25 @@
import type { HomeData } from "./buildHome";
const monthlyExpense = 1_200_000;
const availableBudget = 2_036_795;
/** Figma sample state (also the fallback before a bank is connected). Ported
* verbatim from `HomeData.sample` in
* ios/Mercury/Features/Home/HomeData.swift. */
export const sample: HomeData = {
totalBalance: 452_200,
todayExpense: 52_000,
dailyLimitUsed: 15_500,
dailyLimitTotal: 35_500,
budgets: [
{ name: "Хоол", spent: 23_700, range: "22,000₮ - 27,000₮" },
{ name: "Coffee", spent: 23_700, range: "4500₮ - 6000₮" },
{ name: "Тээвэр", spent: 0, range: "5000₮" },
],
monthlyExpense,
availableBudget,
income: [{ title: "1,345,634₮", date: "04.01" }],
expense: [{ title: "худалдан авалт", date: "04.01" }],
safeToSpend: Math.max(0, availableBudget - monthlyExpense),
overspent: monthlyExpense > availableBudget,
};

View file

@ -0,0 +1,16 @@
// Home dashboard copy, ported verbatim from
// ios/Mercury/Features/Home/DashboardView.swift.
export const homeStrings = {
wordmark: "MERCURY",
safeToSpend: "Энэ сар зарцуулж болох",
overspent: "Төсвөөс хэтэрсэн",
spent: "Зарцуулсан",
dailyLimit: "Өнөөдрийн лимит",
thisMonth: "Энэ сард",
expenseLabel: "зарлага",
viewAll: "Бүгдийг харах",
hideAmounts: "Мөнгөн дүн нуух",
income: "Орлого",
expense: "Зарлага",
todaySpent: "Өнөөдөр зарцуулсан",
} as const;

View file

@ -0,0 +1,103 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
import { Card } from "@/ds";
import { tugrik } from "@/ds/money";
import { useTransactions } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas";
import { plannerStrings as s } from "./strings";
export interface CategoryTransactionsProps {
category: string;
}
/** MM.dd from an RFC3339/ISO timestamp mirrors CategoryTransactionsView's
* `shortDate` on iOS. Falls back to an empty string on unparsable input. */
function shortDate(rfc: string): string {
const d = new Date(rfc);
if (Number.isNaN(d.getTime())) return "";
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${mm}.${dd}`;
}
/** Every transaction in one category (server expands to sub-categories too)
* opened by tapping a category-limit row on the planner hub. Ports
* `CategoryTransactionsView.swift`: header + filter + list, tap-free (the web
* port has no transaction detail cover yet). */
export function CategoryTransactions({ category }: CategoryTransactionsProps) {
const { data, isLoading } = useTransactions({
category,
from: "2020-01-01",
to: "2027-12-31",
limit: 300,
});
const [search, setSearch] = React.useState("");
const rows: Txn[] = React.useMemo(() => {
const all = data ?? [];
const needle = search.trim().toLowerCase();
if (!needle) return all;
return all.filter((t) => t.title.toLowerCase().includes(needle));
}, [data, search]);
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Link href="/planner" aria-label={s.amountEntry.cancel} style={{ color: "var(--seed-color-fg-neutral)" }}>
</Link>
<h1 style={{ fontWeight: 700, fontSize: 16 }}>{category}</h1>
</div>
<TextFieldRoot value={search} onValueChange={setSearch}>
<TextFieldInput
placeholder={s.categoryTransactions.filterPlaceholder}
aria-label={s.categoryTransactions.filterPlaceholder}
/>
</TextFieldRoot>
<Card>
{isLoading && <p style={{ color: "var(--seed-color-fg-placeholder)" }}></p>}
{!isLoading && rows.length === 0 && (
<p style={{ color: "var(--seed-color-fg-placeholder)" }}>{s.categoryTransactions.empty}</p>
)}
{!isLoading && rows.length > 0 && (
<ul className="flex flex-col gap-3">
{rows.map((t, i) => {
const income = t.direction === "income";
return (
<li
key={`${t.txnId ?? i}`}
className="flex items-center justify-between gap-3"
style={{
borderBottom: i < rows.length - 1 ? "1px solid var(--seed-color-border-default, #eee)" : undefined,
paddingBottom: 12,
}}
>
<div className="flex flex-col">
<span style={{ fontWeight: 700 }}>{t.title || t.category}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
{shortDate(t.date)} · {t.category}
</span>
</div>
<span
style={{
fontWeight: 700,
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{income ? "+" : ""}
{tugrik(t.amount)}
</span>
</li>
);
})}
</ul>
)}
</Card>
</div>
);
}

View file

@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import { describe, it, expect, vi } from "vitest";
import type { Budget } from "@/api/schemas";
// jsdom has no CSS.supports(); Seed's SegmentedControl/TextField call it via
// @seed-design/react-supports to detect :focus-visible support.
if (typeof (globalThis as any).CSS === "undefined") {
(globalThis as any).CSS = { supports: () => false };
} else if (typeof (globalThis as any).CSS.supports !== "function") {
(globalThis as any).CSS.supports = () => false;
}
const budgetFixture: Budget = {
dayLimit: "50000",
weekLimit: "300000",
monthLimit: "1200000",
plannedIncome: "2000000",
plannedIncomeManual: "0",
loanObligations: "0",
savingsContributions: "0",
subscriptionContributions: "0",
availableIncome: "2000000",
categories: [{ name: "хоол", day: "10000", week: "60000", month: "240000" }],
savingsGoals: [],
report: {
day: { overallSpent: "15000", overallLimit: "50000", rows: [{ category: "хоол", spent: "5000", limit: "20000" }] },
week: { overallSpent: "0", overallLimit: "300000", rows: [] },
month: { overallSpent: "0", overallLimit: "1200000", rows: [] },
},
};
vi.mock("@/api/hooks/reads", () => ({
useBudget: () => ({ data: budgetFixture, isLoading: false }),
useNetWorth: () => ({ data: { assets: "0", liabilities: "0", total: "0", accounts: [] }, isLoading: false }),
}));
vi.mock("@/api/hooks/mutations", () => ({
usePutBudget: () => ({ mutate: vi.fn() }),
useSavingsGoalMutations: () => ({ post: { mutate: vi.fn() }, delete: { mutate: vi.fn() } }),
}));
import { PlannerView } from "./PlannerView";
describe("PlannerView", () => {
it("renders the day-horizon overall total and a category limit", () => {
render(<PlannerView />);
// Overall (day) card: spent / limit from budget.report.day.
expect(screen.getByText("15,000₮ / 50,000₮")).toBeInTheDocument();
// Category limit row: name + spent / limit from budget.report.day.rows.
expect(screen.getByText("хоол")).toBeInTheDocument();
expect(screen.getByText("5,000₮ / 20,000₮")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,821 @@
"use client";
import * as React from "react";
import Link from "next/link";
import {
TextFieldRoot,
TextFieldInput,
SegmentedControlRoot,
SegmentedControlItem,
SegmentedControlItemHiddenInput,
ProgressCircleRoot,
ProgressCircleTrack,
ProgressCircleRange,
ContentDialogRoot,
ContentDialogBackdrop,
ContentDialogPositioner,
ContentDialogContent,
ContentDialogHeader,
ContentDialogTitle,
ContentDialogBody,
ContentDialogFooter,
Skeleton,
} from "@seed-design/react";
import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds";
import { tugrik, tugrikShort } from "@/ds/money";
import { useBudget, useNetWorth } from "@/api/hooks/reads";
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
import type { Budget, SavingsGoal, Account } from "@/api/schemas";
import { plannerStrings as s } from "./strings";
type Horizon = "day" | "week" | "month";
type CategoryLimit = Budget["categories"][number];
type HorizonReport = Budget["report"]["day"];
type LimitRow = HorizonReport["rows"][number];
function dec(v: string | undefined | null): number {
return parseFloat(v ?? "0") || 0;
}
function onlyDigits(v: string): string {
return v.replace(/[^0-9]/g, "");
}
const HORIZON_LABEL: Record<Horizon, string> = {
day: s.horizon.day,
week: s.horizon.week,
month: s.horizon.month,
};
const OVERALL_LABEL: Record<Horizon, string> = {
day: s.overall.label.day,
week: s.overall.label.week,
month: s.overall.label.month,
};
/** The Төлөвлөгөө hub ports `PlannerView.swift` + `LimitsHubModel.swift`.
* Shows the editable planned income, a day/week/month horizon toggle, the
* overall spend-vs-limit for that horizon, per-category limits, and savings
* goals. Every edit persists via `PUT /budget`, echoing back the full
* `categories` array (the DTO contract nothing else may be dropped). */
export function PlannerView() {
const { data: budget, isLoading } = useBudget();
const { data: netWorth } = useNetWorth();
const putBudget = usePutBudget();
const goalMutations = useSavingsGoalMutations();
const [horizon, setHorizon] = React.useState<Horizon>("day");
const accounts: Account[] = netWorth?.accounts ?? [];
function reportFor(h: Horizon): HorizonReport | undefined {
return budget?.report[h];
}
function categoryLimitFor(name: string): CategoryLimit {
return budget?.categories.find((c) => c.name === name) ?? { name, day: "0", week: "0", month: "0" };
}
/** Save a partial change, echoing the current budget for everything else
* (mirrors `LimitsHubModel.save` nothing, including the planned-income
* override, may be silently lost). */
function save(partial: {
dayLimit?: string;
weekLimit?: string;
monthLimit?: string;
plannedIncomeManual?: string;
categories?: CategoryLimit[];
}) {
if (!budget) return;
putBudget.mutate({
dayLimit: partial.dayLimit ?? budget.dayLimit,
weekLimit: partial.weekLimit ?? budget.weekLimit,
monthLimit: partial.monthLimit ?? budget.monthLimit,
plannedIncomeManual: partial.plannedIncomeManual ?? budget.plannedIncomeManual,
categories: partial.categories ?? budget.categories,
});
}
function saveOverall(h: Horizon, value: number) {
if (!budget) return;
save({
dayLimit: h === "day" ? String(value) : budget.dayLimit,
weekLimit: h === "week" ? String(value) : budget.weekLimit,
monthLimit: h === "month" ? String(value) : budget.monthLimit,
});
}
function saveCategoryLimit(name: string, day: number, week: number, month: number) {
if (!budget) return;
const cats = [...budget.categories];
const updated: CategoryLimit = { name, day: String(day), week: String(week), month: String(month) };
const idx = cats.findIndex((c) => c.name === name);
if (idx >= 0) cats[idx] = updated;
else cats.push(updated);
save({ categories: cats });
}
function removeCategoryLimit(name: string) {
if (!budget) return;
save({ categories: budget.categories.filter((c) => c.name !== name) });
}
const report = reportFor(horizon);
const overallSpent = dec(report?.overallSpent);
const overallLimit = dec(report?.overallLimit);
const rows: LimitRow[] = report?.rows ?? [];
const goals: SavingsGoal[] = budget?.savingsGoals ?? [];
return (
<div className="flex flex-col gap-4">
<header className="flex items-center justify-between">
<h1 style={{ fontWeight: 700, fontSize: 18 }}>{s.header.title}</h1>
<HideAmountsToggle />
</header>
<PlannedIncomeCard budget={budget} loading={isLoading} onSave={(v) => save({ plannedIncomeManual: String(v) })} />
<SegmentedControlRoot value={horizon} onValueChange={(v) => setHorizon(v as Horizon)}>
{(["day", "week", "month"] as const).map((h) => (
<SegmentedControlItem key={h} value={h}>
<SegmentedControlItemHiddenInput />
<span>{HORIZON_LABEL[h]}</span>
</SegmentedControlItem>
))}
</SegmentedControlRoot>
<OverallCard
horizon={horizon}
spent={overallSpent}
limit={overallLimit}
loading={isLoading}
onSave={(v) => saveOverall(horizon, v)}
/>
<CategoryList
loading={isLoading}
rows={rows}
horizon={horizon}
categoryLimitFor={categoryLimitFor}
onSaveLimit={saveCategoryLimit}
onRemoveLimit={removeCategoryLimit}
/>
<SavingsSection
goals={goals}
accounts={accounts}
onSave={(goal) =>
goalMutations.post.mutate({
originalName: goal.originalName,
name: goal.name,
target: String(goal.target),
monthlyContribution: String(goal.monthly),
accountId: goal.accountId,
targetDate: goal.targetDate,
})
}
onDelete={(name) => goalMutations.delete.mutate(name)}
/>
</div>
);
}
// --- Planned income ---------------------------------------------------------
function PlannedIncomeCard({
budget,
loading,
onSave,
}: {
budget: Budget | undefined;
loading: boolean;
onSave: (value: number) => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const plannedIncome = dec(budget?.plannedIncome);
const loanObligations = dec(budget?.loanObligations);
const savingsContributions = dec(budget?.savingsContributions);
const availableIncome = dec(budget?.availableIncome);
const showBreakdown = loanObligations > 0 || savingsContributions > 0;
return (
<Card>
{loading ? (
<Skeleton height="64px" />
) : editing ? (
<div className="flex flex-col gap-3">
<span style={{ fontWeight: 700, fontSize: 14 }}>{s.plannedIncome.editTitle}</span>
<TextFieldRoot value={draft} onValueChange={(v) => setDraft(onlyDigits(v))}>
<TextFieldInput
inputMode="numeric"
aria-label={s.plannedIncome.editTitle}
placeholder="0"
/>
</TextFieldRoot>
<p style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
{s.plannedIncome.autoHint(tugrik(plannedIncome))}
</p>
<div className="flex gap-2">
<MercuryButton variant="secondary" onClick={() => setEditing(false)} style={{ flex: 1 }}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
onClick={() => {
onSave(parseFloat(draft) || 0);
setEditing(false);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
</div>
) : (
<button
type="button"
onClick={() => {
setDraft(budget?.plannedIncomeManual && dec(budget.plannedIncomeManual) > 0 ? budget.plannedIncomeManual : "");
setEditing(true);
}}
className="flex w-full flex-col items-start gap-3 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
>
<span style={{ fontWeight: 700, fontSize: 14 }}>{s.plannedIncome.label}</span>
<span style={{ fontWeight: 700, fontSize: 26 }}>+{tugrikShort(plannedIncome)}</span>
{showBreakdown && (
<div className="flex w-full flex-col gap-1">
{loanObligations > 0 && (
<BreakdownRow label={s.plannedIncome.loanObligations} value={`${tugrikShort(loanObligations)}`} />
)}
{savingsContributions > 0 && (
<BreakdownRow label={s.plannedIncome.savings} value={`${tugrikShort(savingsContributions)}`} />
)}
<BreakdownRow label={s.plannedIncome.available} value={tugrikShort(availableIncome)} bold />
</div>
)}
</button>
)}
</Card>
);
}
function BreakdownRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div className="flex w-full items-center justify-between">
<span style={{ fontSize: 13, fontWeight: bold ? 700 : 400, color: bold ? undefined : "var(--seed-color-fg-placeholder)" }}>
{label}
</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{value}</span>
</div>
);
}
// --- Overall (horizon) card --------------------------------------------------
function OverallCard({
horizon,
spent,
limit,
loading,
onSave,
}: {
horizon: Horizon;
spent: number;
limit: number;
loading: boolean;
onSave: (value: number) => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
return (
<div
style={{
background: "var(--mercury-limit-card)",
borderRadius: "var(--seed-radius-r5)",
padding: "16px 20px",
color: "#fff",
}}
>
{loading ? (
<Skeleton height="64px" />
) : editing ? (
<div className="flex flex-col gap-3">
<span style={{ fontWeight: 700 }}>{s.overall.editTitle(HORIZON_LABEL[horizon])}</span>
<TextFieldRoot value={draft} onValueChange={(v) => setDraft(onlyDigits(v))}>
<TextFieldInput inputMode="numeric" aria-label={s.overall.editTitle(HORIZON_LABEL[horizon])} placeholder="0" />
</TextFieldRoot>
<div className="flex gap-2">
<MercuryButton variant="secondary" onClick={() => setEditing(false)} style={{ flex: 1 }}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
onClick={() => {
onSave(parseFloat(draft) || 0);
setEditing(false);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
</div>
) : (
<button
type="button"
onClick={() => {
setDraft(limit > 0 ? String(limit) : "");
setEditing(true);
}}
className="flex w-full items-center gap-4 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff" }}
>
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
<ProgressCircleTrack style={{ opacity: 0.3 }} />
<ProgressCircleRange />
</ProgressCircleRoot>
<div className="flex flex-col gap-1">
<span style={{ fontSize: 12 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)}
{limit > 0 ? ` / ${tugrikShort(limit)}` : " / —"}
</span>
</div>
</button>
)}
</div>
);
}
// --- Category limits ---------------------------------------------------------
function CategoryList({
loading,
rows,
horizon,
categoryLimitFor,
onSaveLimit,
onRemoveLimit,
}: {
loading: boolean;
rows: LimitRow[];
horizon: Horizon;
categoryLimitFor: (name: string) => CategoryLimit;
onSaveLimit: (name: string, day: number, week: number, month: number) => void;
onRemoveLimit: (name: string) => void;
}) {
const [editingName, setEditingName] = React.useState<string | null>(null);
const [drafts, setDrafts] = React.useState({ day: "", week: "", month: "" });
const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState("");
const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null);
function openEditor(name: string) {
const limit = categoryLimitFor(name);
setDrafts({ day: dec(limit.day) > 0 ? limit.day : "", week: dec(limit.week) > 0 ? limit.week : "", month: dec(limit.month) > 0 ? limit.month : "" });
setEditingName(name);
}
return (
<Card>
<div className="flex items-center justify-between">
<h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.categories.title}</h2>
<MercuryButton variant="ghost" onClick={() => { setNewName(""); setAdding(true); }}>
{s.categories.add}
</MercuryButton>
</div>
{adding && (
<div className="mt-3 flex flex-col gap-2">
<TextFieldRoot value={newName} onValueChange={setNewName}>
<TextFieldInput placeholder={s.categories.addNamePlaceholder} aria-label={s.categories.addNamePlaceholder} />
</TextFieldRoot>
<div className="flex gap-2">
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setAdding(false)}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!newName.trim()}
onClick={() => {
const name = newName.trim();
setAdding(false);
openEditor(name);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
</div>
)}
{loading && (
<div className="mt-4 flex flex-col gap-3">
<Skeleton height="56px" />
<Skeleton height="56px" />
</div>
)}
{!loading && rows.length === 0 && (
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
{s.categories.empty}
</p>
)}
{!loading && rows.length > 0 && (
<ul className="mt-4 flex flex-col gap-4">
{rows.map((row) => {
const spent = dec(row.spent);
const limit = dec(row.limit);
const over = limit > 0 && spent > limit;
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
if (editingName === row.category) {
return (
<li key={row.category} className="flex flex-col gap-2">
<span style={{ fontWeight: 700 }}>{s.categories.editTitle(row.category, HORIZON_LABEL[horizon])}</span>
<AmountField label={s.horizon.day} value={drafts.day} onChange={(v) => setDrafts((d) => ({ ...d, day: v }))} />
<AmountField label={s.horizon.week} value={drafts.week} onChange={(v) => setDrafts((d) => ({ ...d, week: v }))} />
<AmountField label={s.horizon.month} value={drafts.month} onChange={(v) => setDrafts((d) => ({ ...d, month: v }))} />
<div className="flex gap-2">
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setEditingName(null)}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
onClick={() => {
onSaveLimit(row.category, parseFloat(drafts.day) || 0, parseFloat(drafts.week) || 0, parseFloat(drafts.month) || 0);
setEditingName(null);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
<button
type="button"
onClick={() => setConfirmRemove(row.category)}
style={{ color: "var(--seed-color-fg-critical)", background: "none", border: "none", cursor: "pointer" }}
>
{s.categories.remove}
</button>
</li>
);
}
return (
<li key={row.category} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Link
href={`/planner/${encodeURIComponent(row.category)}`}
className="flex flex-1 flex-col gap-2"
style={{ color: "inherit", textDecoration: "none" }}
>
<div className="flex items-center justify-between">
<span style={{ fontWeight: 700 }}>{row.category}</span>
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>
{tugrik(spent)}
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
</span>
</div>
<ProgressBar percent={percent} tone={over ? "critical" : "brand"} />
</Link>
<button
type="button"
onClick={() => openEditor(row.category)}
aria-label={s.categories.editTitle(row.category, HORIZON_LABEL[horizon])}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-placeholder)" }}
>
</button>
</div>
</li>
);
})}
</ul>
)}
<ConfirmDialog
open={confirmRemove !== null}
onOpenChange={(open) => !open && setConfirmRemove(null)}
title={s.categories.removeConfirmTitle}
body={confirmRemove ? s.categories.removeConfirmBody(confirmRemove) : ""}
confirmLabel={s.categories.remove}
onConfirm={() => {
if (confirmRemove) {
onRemoveLimit(confirmRemove);
setEditingName(null);
}
}}
/>
</Card>
);
}
function AmountField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="flex items-center gap-2">
<span style={{ width: 72, fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{label}</span>
<TextFieldRoot value={value} onValueChange={(v) => onChange(onlyDigits(v))} style={{ flex: 1 }}>
<TextFieldInput inputMode="numeric" aria-label={label} placeholder="0" />
</TextFieldRoot>
</div>
);
}
function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
return (
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${percent}%`,
borderRadius: 999,
background: tone === "critical" ? "var(--seed-color-fg-critical)" : "var(--mercury-limit-circle)",
}}
/>
</div>
);
}
// --- Savings goals ------------------------------------------------------------
interface GoalDraft {
originalName?: string;
name: string;
target: number;
monthly: number;
accountId: number;
targetDate: string;
}
function SavingsSection({
goals,
accounts,
onSave,
onDelete,
}: {
goals: SavingsGoal[];
accounts: Account[];
onSave: (goal: GoalDraft) => void;
onDelete: (name: string) => void;
}) {
const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null);
const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null);
return (
<Card>
<div className="flex items-center justify-between">
<h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.title}</h2>
<MercuryButton variant="ghost" onClick={() => setEditing({ mode: "add" })}>
{s.savings.add}
</MercuryButton>
</div>
{goals.length === 0 && !editing && (
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
{s.savings.empty}
</p>
)}
{goals.length > 0 && (
<ul className="mt-4 flex flex-col gap-4">
{goals.map((goal) => {
const saved = dec(goal.saved);
const target = dec(goal.target);
const monthly = dec(goal.monthlyContribution);
const remaining = Math.max(0, target - saved);
const done = target > 0 && saved >= target;
const percent = target > 0 ? Math.min(100, (saved / target) * 100) : 0;
return (
<li key={goal.name}>
<button
type="button"
onClick={() => setEditing({ mode: "edit", goal })}
className="flex w-full flex-col gap-2 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
>
<div className="flex items-center justify-between">
<span style={{ fontWeight: 700 }}>{goal.name}</span>
<span style={{ fontWeight: 700, color: done ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-placeholder)" }}>
{tugrik(saved)}
{target > 0 ? ` / ${tugrik(target)}` : " / —"}
</span>
</div>
<ProgressBar percent={percent} tone={done ? "brand" : "brand"} />
<div className="flex items-center justify-between" style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
<span>{goal.accountId === 0 ? s.savings.linkAccount : goal.accountName || s.savings.linkAccount}</span>
{monthly > 0 && <span>{s.savings.perMonth} {tugrik(monthly)}</span>}
</div>
{remaining > 0 && target > 0 && (
<span style={{ fontSize: 11, color: "var(--seed-color-fg-placeholder)" }}>
{s.savings.remaining} {tugrik(remaining)}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
{editing && (
<GoalEditor
existing={editing.goal}
accounts={accounts}
onCancel={() => setEditing(null)}
onSave={(draft) => {
onSave(draft);
setEditing(null);
}}
onRequestDelete={() => editing.goal && setConfirmDelete(editing.goal.name)}
/>
)}
<ConfirmDialog
open={confirmDelete !== null}
onOpenChange={(open) => !open && setConfirmDelete(null)}
title={s.savings.deleteConfirmTitle}
body={confirmDelete ? s.savings.deleteConfirmBody(confirmDelete) : ""}
confirmLabel={s.savings.delete}
onConfirm={() => {
if (confirmDelete) {
onDelete(confirmDelete);
setConfirmDelete(null);
setEditing(null);
}
}}
/>
</Card>
);
}
function GoalEditor({
existing,
accounts,
onSave,
onCancel,
onRequestDelete,
}: {
existing?: SavingsGoal;
accounts: Account[];
onSave: (draft: GoalDraft) => void;
onCancel: () => void;
onRequestDelete: () => void;
}) {
const [name, setName] = React.useState(existing?.name ?? "");
const [editingName, setEditingName] = React.useState(false);
const [target, setTarget] = React.useState(existing?.target ?? "");
const [monthly, setMonthly] = React.useState(existing?.monthlyContribution ?? "");
const [accountId, setAccountId] = React.useState<number>(existing?.accountId ?? 0);
const [targetDate, setTargetDate] = React.useState(existing?.targetDate ?? "");
const canSave = name.trim().length > 0 && (parseFloat(target) || 0) > 0;
return (
<div className="mt-4 flex flex-col gap-3" style={{ borderTop: "1px solid var(--seed-color-border-default, #eee)", paddingTop: 12 }}>
<span style={{ fontWeight: 700 }}>{existing ? s.savings.editTitle : s.savings.addTitle}</span>
{editingName ? (
<NameEdit
initial={name}
title={s.savings.name}
placeholder={s.savings.namePlaceholder}
onSave={(n) => {
setName(n);
setEditingName(false);
}}
onCancel={() => setEditingName(false)}
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className="flex items-center justify-between"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left" }}
>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.name}</span>
<span style={{ fontWeight: 700 }}>{name || "—"}</span>
</button>
)}
<AmountField label={s.savings.target} value={target} onChange={setTarget} />
<AmountField label={s.savings.monthly} value={monthly} onChange={setMonthly} />
<label className="flex items-center gap-2">
<span style={{ width: 100, fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.account}</span>
<select
value={accountId}
onChange={(e) => setAccountId(Number(e.target.value))}
aria-label={s.savings.account}
style={{ flex: 1, padding: 8 }}
>
<option value={0}>{s.savings.noAccount}</option>
{accounts.map((a) => (
<option key={a.accountId} value={a.accountId}>
{a.bank} ·{a.accountNumber.slice(-4)}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span style={{ width: 100, fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.date}</span>
<input
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
aria-label={s.savings.date}
style={{ flex: 1, padding: 8 }}
/>
</label>
<div className="flex gap-2">
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={onCancel}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
onClick={() =>
onSave({
originalName: existing?.name,
name: name.trim(),
target: parseFloat(target) || 0,
monthly: parseFloat(monthly) || 0,
accountId,
targetDate,
})
}
>
{s.amountEntry.save}
</MercuryButton>
</div>
{existing && (
<button
type="button"
onClick={onRequestDelete}
style={{ color: "var(--seed-color-fg-critical)", background: "none", border: "none", cursor: "pointer" }}
>
{s.savings.delete}
</button>
)}
</div>
);
}
// --- Shared confirmation dialog (never a native alert) -----------------------
function ConfirmDialog({
open,
onOpenChange,
title,
body,
confirmLabel,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
body: string;
confirmLabel: string;
onConfirm: () => void;
}) {
return (
<ContentDialogRoot open={open} onOpenChange={onOpenChange}>
<ContentDialogBackdrop />
<ContentDialogPositioner>
<ContentDialogContent>
<ContentDialogHeader>
<ContentDialogTitle>{title}</ContentDialogTitle>
</ContentDialogHeader>
<ContentDialogBody>
<p>{body}</p>
</ContentDialogBody>
<ContentDialogFooter>
<MercuryButton variant="secondary" onClick={() => onOpenChange(false)}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
onClick={() => {
onConfirm();
onOpenChange(false);
}}
>
{confirmLabel}
</MercuryButton>
</ContentDialogFooter>
</ContentDialogContent>
</ContentDialogPositioner>
</ContentDialogRoot>
);
}

View file

@ -0,0 +1,66 @@
// Planner feature copy, ported verbatim from
// ios/Mercury/Features/Planner/{PlannerView,LimitsHubModel,PlannerEditViews,
// SavingsGoalEditView,CategoryTransactionsView}.swift.
export const plannerStrings = {
header: { title: "Төлөвлөгөө" },
horizon: {
day: "Өдөр",
week: "7 хоног",
month: "Сар",
},
plannedIncome: {
label: "Төлөвлөгдсөн орлого",
loanObligations: "Зээлийн төлбөр",
savings: "Хадгаламж",
available: "Зарцуулах боломжтой",
editTitle: "Төлөвлөгдсөн орлого",
autoHint: (detected: string) => `Цалингаар илрүүлсэн: ${detected}`,
autoAction: "Автоматаар тооцох (цалингаар)",
},
overall: {
label: {
day: "Өнөөдрийн лимит",
week: "7 хоногийн лимит",
month: "Энэ сарын лимит",
},
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
},
categories: {
title: "Ангиллын лимит",
empty: "Лимит алга — ангилал нэмнэ үү",
add: "Ангилал нэмэх",
addNamePlaceholder: "Ангиллын нэр",
editTitle: (name: string, horizonLabel: string) => `${name} · ${horizonLabel}`,
remove: "Лимит хасах",
removeConfirmTitle: "Ангиллын лимит хасах уу?",
removeConfirmBody: (name: string) => `«${name}» ангиллын лимит хасагдана.`,
},
savings: {
title: "Хадгаламж",
empty: "Зорилго алга — хадгаламжийн зорилго нэмнэ үү",
add: "Зорилго нэмэх",
linkAccount: "Данс холбох",
noAccount: "Холбохгүй",
perMonth: "Сар бүр",
remaining: "Үлдсэн",
addTitle: "Шинэ зорилго",
editTitle: "Зорилго засах",
name: "Нэр",
namePlaceholder: "Жишээ: Машины балон сан",
target: "Зорилтот дүн",
account: "Холбосон данс",
monthly: "Сар бүрийн хуримтлал",
date: "Зорилтот огноо",
delete: "Зорилго устгах",
deleteConfirmTitle: "Зорилго устгах уу?",
deleteConfirmBody: (name: string) => `«${name}» хадгаламжийн зорилго устана.`,
},
amountEntry: {
save: "Хадгалах",
cancel: "Болих",
},
categoryTransactions: {
empty: "Гүйлгээ алга",
filterPlaceholder: "филтер",
},
} as const;

View file

@ -0,0 +1,388 @@
"use client";
import * as React from "react";
import {
TextFieldRoot,
TextFieldInput,
DialogRoot,
DialogBackdrop,
DialogPositioner,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
Skeleton,
} from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton";
import { useCategories } from "@/api/hooks/reads";
import { useCategoryMutations } from "@/api/hooks/mutations";
import type { Category } from "@/api/schemas";
import { profileStrings } from "./strings";
// A small curated glyph set stands in for the iOS `SeedMulticolorIcon` catalog
// (not ported to web) — plain emoji stored verbatim in the `icon` string field.
const ICON_CHOICES = ["🍔", "🚗", "🏠", "💊", "🎮", "🛍️", "💡", "📚", "✈️", "🎁", "💰", "📱", "🐾", "⚽", "☕", "🧾"];
interface CategoryGroup {
name: string;
icon?: string | null;
children: Category[];
}
/** Mirrors `CategoriesModel.load()` in CategoriesView.swift: a depth-0 row
* starts a new group, every following depth>0 row until the next depth-0
* belongs to it. */
function groupCategories(categories: Category[]): CategoryGroup[] {
const groups: CategoryGroup[] = [];
let current: CategoryGroup | null = null;
for (const c of categories) {
if (c.depth === 0) {
current = { name: c.name, icon: c.icon, children: [] };
groups.push(current);
} else if (current) {
current.children.push(c);
}
}
return groups;
}
type SheetState = { mode: "add" } | { mode: "edit"; category: Category } | null;
export interface CategoriesManagerProps {
onBack?: () => void;
}
export function CategoriesManager({ onBack }: CategoriesManagerProps) {
const { data: categories, isLoading } = useCategories();
const { add, update, delete: remove } = useCategoryMutations();
const [sheet, setSheet] = React.useState<SheetState>(null);
const [deleteTarget, setDeleteTarget] = React.useState<string | null>(null);
const groups = React.useMemo(() => groupCategories(categories ?? []), [categories]);
const parentNames = React.useMemo(() => groups.map((g) => g.name), [groups]);
async function handleConfirmDelete() {
if (!deleteTarget) return;
await remove.mutateAsync(deleteTarget);
setDeleteTarget(null);
}
if (sheet) {
return (
<CategoryEditor
parents={parentNames}
initial={sheet.mode === "edit" ? sheet.category : undefined}
onCancel={() => setSheet(null)}
onSave={async (values) => {
if (sheet.mode === "add") {
await add.mutateAsync({
name: values.name,
kind: "expense",
parent: values.parent || undefined,
icon: values.icon,
});
} else {
await update.mutateAsync({
oldName: sheet.category.name,
newName: values.name,
parent: values.parent ? values.parent : null,
icon: values.icon,
});
}
setSheet(null);
}}
/>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{onBack && (
<button
type="button"
onClick={onBack}
aria-label={profileStrings.categories.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
)}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.categories.title}</h1>
<MercuryButton variant="ghost" onClick={() => setSheet({ mode: "add" })}>
+ {profileStrings.categories.add}
</MercuryButton>
</div>
{isLoading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="60px" />
<Skeleton height="60px" />
</div>
)}
{!isLoading && groups.length === 0 && (
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.empty}</p>
)}
{groups.map((group) => (
<div key={group.name} style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<CategoryRow
icon={group.icon}
name={group.name}
heading
onEdit={() => setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })}
onDelete={() => setDeleteTarget(group.name)}
/>
{group.children.length > 0 && (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8, paddingLeft: 12 }}>
{group.children.map((child) => (
<CategoryChip
key={child.name}
category={child}
onEdit={() => setSheet({ mode: "edit", category: child })}
onDelete={() => setDeleteTarget(child.name)}
/>
))}
</div>
)}
</div>
))}
<DialogRoot open={deleteTarget !== null} onOpenChange={(open) => { if (!open) setDeleteTarget(null); }}>
<DialogBackdrop style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)", zIndex: 40 }} />
<DialogPositioner
style={{
position: "fixed",
inset: 0,
zIndex: 41,
display: "flex",
alignItems: "center",
justifyContent: "center",
padding: 16,
}}
>
<DialogContent
style={{
background: "var(--seed-color-bg-layer-floating)",
borderRadius: "var(--seed-radius-r3)",
padding: 20,
maxWidth: 360,
width: "100%",
}}
>
<DialogHeader>
<DialogTitle style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{profileStrings.categories.deleteTitle}
</DialogTitle>
</DialogHeader>
<DialogDescription style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
{deleteTarget ? profileStrings.categories.deleteMessage(deleteTarget) : ""}
</DialogDescription>
<DialogFooter style={{ display: "flex", gap: 12, marginTop: 20 }}>
<MercuryButton variant="secondary" onClick={() => setDeleteTarget(null)} style={{ flex: 1 }}>
{profileStrings.categories.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
onClick={handleConfirmDelete}
loading={remove.isPending}
style={{ flex: 1, background: "var(--seed-color-fg-critical)", color: "#fff" }}
>
{profileStrings.categories.deleteAction}
</MercuryButton>
</DialogFooter>
</DialogContent>
</DialogPositioner>
</DialogRoot>
</div>
);
}
function CategoryRow({
icon,
name,
heading,
onEdit,
onDelete,
}: {
icon?: string | null;
name: string;
heading?: boolean;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span aria-hidden style={{ fontSize: heading ? 20 : 16 }}>{icon || "🏷️"}</span>
<span style={{ fontSize: heading ? 16 : 14, fontWeight: heading ? 600 : 400, flex: 1 }}>{name}</span>
<button
type="button"
onClick={onEdit}
aria-label={`${profileStrings.categories.editAction}: ${name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}
>
{profileStrings.categories.editAction}
</button>
<button
type="button"
onClick={onDelete}
aria-label={`${profileStrings.categories.deleteAction}: ${name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 13, color: "var(--seed-color-fg-critical)" }}
>
{profileStrings.categories.deleteAction}
</button>
</div>
);
}
function CategoryChip({
category,
onEdit,
onDelete,
}: {
category: Category;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "6px 10px",
borderRadius: 999,
background: "var(--seed-color-bg-layer-default)",
}}
>
<span aria-hidden>{category.icon || "🏷️"}</span>
<span style={{ fontSize: 13 }}>{category.name}</span>
<button
type="button"
onClick={onEdit}
aria-label={`${profileStrings.categories.editAction}: ${category.name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}
>
</button>
<button
type="button"
onClick={onDelete}
aria-label={`${profileStrings.categories.deleteAction}: ${category.name}`}
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 12, color: "var(--seed-color-fg-critical)" }}
>
</button>
</div>
);
}
function CategoryEditor({
parents,
initial,
onCancel,
onSave,
}: {
parents: string[];
initial?: Category;
onCancel: () => void;
onSave: (values: { name: string; parent: string; icon: string }) => Promise<void>;
}) {
const [name, setName] = React.useState(initial?.name ?? "");
const [parent, setParent] = React.useState("");
const [icon, setIcon] = React.useState(initial?.icon || ICON_CHOICES[0]);
const [saving, setSaving] = React.useState(false);
const editing = Boolean(initial);
const canSave = name.trim().length > 0 && !saving;
async function handleSave() {
if (!canSave) return;
setSaving(true);
try {
await onSave({ name: name.trim(), parent, icon });
} finally {
setSaving(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onCancel}
aria-label={profileStrings.categories.cancel}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>
{editing ? profileStrings.categories.edit : profileStrings.categories.add}
</h1>
</div>
<TextFieldRoot value={name} onValueChange={setName} name="categoryName">
<TextFieldInput
aria-label={profileStrings.categories.name}
placeholder={profileStrings.categories.namePlaceholder}
autoFocus
/>
</TextFieldRoot>
<div>
<p style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)", marginBottom: 8 }}>
{profileStrings.categories.icon}
</p>
<div style={{ display: "grid", gridTemplateColumns: "repeat(8, 1fr)", gap: 8 }}>
{ICON_CHOICES.map((choice) => (
<button
key={choice}
type="button"
onClick={() => setIcon(choice)}
aria-pressed={icon === choice}
style={{
fontSize: 20,
padding: 8,
borderRadius: "var(--seed-radius-r3)",
border: "none",
cursor: "pointer",
background: icon === choice ? "var(--seed-color-bg-layer-default)" : "transparent",
}}
>
{choice}
</button>
))}
</div>
</div>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.categories.parent}</span>
<select
value={parent}
onChange={(e) => setParent(e.target.value)}
style={{
padding: "10px 12px",
borderRadius: "var(--seed-radius-r3)",
border: "1px solid var(--seed-color-border-default, #e5e7eb)",
}}
>
<option value="">{profileStrings.categories.parentNone}</option>
{parents
.filter((p) => p !== initial?.name)
.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<MercuryButton variant="primary" onClick={handleSave} disabled={!canSave} loading={saving}>
{profileStrings.categories.save}
</MercuryButton>
</div>
);
}

View file

@ -0,0 +1,78 @@
"use client";
import { useConnections } from "@/api/hooks/reads";
import { Skeleton } from "@seed-design/react";
import { profileStrings } from "./strings";
/**
* Read-only connected-banks list (Task 13 scope: NO connect/disconnect here
* that stays a mobile-only action per the brief). Mirrors the bank rows in
* `ProfileView.swift`'s `banksCard`, minus the connect/disconnect chips.
*/
export function ConnectedBanks() {
const { data: connections, isLoading } = useConnections();
return (
<section aria-labelledby="connected-banks-heading">
<h2
id="connected-banks-heading"
style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 12px" }}
>
{profileStrings.banks.title}
</h2>
{isLoading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="44px" />
<Skeleton height="44px" />
</div>
)}
{!isLoading && (!connections || connections.length === 0) && (
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.banks.empty}
</p>
)}
{!isLoading && connections && connections.length > 0 && (
<ul style={{ listStyle: "none", margin: 0, padding: 0, display: "flex", flexDirection: "column", gap: 4 }}>
{connections.map((c) => (
<li
key={c.bank}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "10px 0",
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{c.bank}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{c.username}
</span>
</div>
{c.courierManaged && (
<span
style={{
fontSize: 11,
padding: "4px 8px",
borderRadius: 999,
background: "var(--seed-color-bg-layer-default)",
color: "var(--seed-color-fg-muted, #6b7280)",
}}
>
{profileStrings.banks.courierManaged}
</span>
)}
</li>
))}
</ul>
)}
<p style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)", marginTop: 8 }}>
{profileStrings.banks.manageNote}
</p>
</section>
);
}

View file

@ -0,0 +1,124 @@
"use client";
import * as React from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
import { useMe, useSettings } from "@/api/hooks/reads";
import { profileStrings } from "./strings";
import { SettingsForm } from "./SettingsForm";
import { ConnectedBanks } from "./ConnectedBanks";
/** The Миний (profile) tab hub: account summary, hide-amounts toggle,
* entries into Categories/Subscriptions (their own routes) and Settings
* (rendered inline, no dedicated route), the read-only connected-banks list,
* and logout. Ports `ProfileView.swift`'s layout minus bank connect/disconnect
* (mobile-only per the Task 13 brief) and the not-yet-wired static rows
* (Шинэ мэдээ / Түгээмэл асуултууд / Санал хүсэлт / Үйлчилгээний нөхцөл). */
export function ProfileView() {
const router = useRouter();
const { data: me } = useMe();
const { data: settings } = useSettings();
const [showSettings, setShowSettings] = React.useState(false);
const [loggingOut, setLoggingOut] = React.useState(false);
const emailLocal = me?.email ? me.email.split("@")[0] : "";
const displayName = settings?.holderName || emailLocal || me?.email || "";
async function handleLogout() {
setLoggingOut(true);
try {
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" });
} finally {
router.push("/login");
}
}
if (showSettings) {
return <SettingsForm onBack={() => setShowSettings(false)} />;
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>{profileStrings.header.title}</h1>
<HideAmountsToggle label={profileStrings.display.hideAmounts} />
</div>
<Card>
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{displayName}</span>
{me?.email && (
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.account.handlePrefix}
{emailLocal}
</span>
)}
</div>
</Card>
<Card>
<nav style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<MenuRow label={profileStrings.menu.settings} onClick={() => setShowSettings(true)} />
<MenuLink label={profileStrings.menu.categories} href="/profile/categories" />
<MenuLink label={profileStrings.menu.subscriptions} href="/profile/subscriptions" />
</nav>
</Card>
<Card>
<ConnectedBanks />
</Card>
<MercuryButton variant="secondary" onClick={handleLogout} loading={loggingOut}>
{profileStrings.logout}
</MercuryButton>
</div>
);
}
function MenuRow({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 0",
background: "none",
border: "none",
cursor: "pointer",
font: "inherit",
color: "inherit",
textAlign: "left",
}}
>
<span style={{ fontSize: 14 }}>{label}</span>
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
</span>
</button>
);
}
function MenuLink({ label, href }: { label: string; href: string }) {
return (
<Link
href={href}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "12px 0",
color: "inherit",
textDecoration: "none",
}}
>
<span style={{ fontSize: 14 }}>{label}</span>
<span aria-hidden style={{ color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>
</span>
</Link>
);
}

View file

@ -0,0 +1,46 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { it, expect, vi } from "vitest";
import type { Settings } from "@/api/schemas";
const settingsFixture: Settings = {
holderName: "Бат-Эрдэнэ",
employer: "Меркури ХХК",
salaryKeywords: ["цалин", "salary"],
payDays: [1, 15],
ownAccounts: ["1234567890"],
peerAccounts: ["0987654321"],
hideAmounts: false,
};
vi.mock("@/api/hooks/reads", () => ({
useSettings: () => ({ data: settingsFixture, isLoading: false, isSuccess: true }),
}));
vi.mock("@/api/hooks/mutations", () => ({
useSaveSettings: () => ({
mutateAsync: vi.fn().mockResolvedValue(settingsFixture),
isPending: false,
isError: false,
}),
}));
import { SettingsForm } from "./SettingsForm";
it("renders the holder-name field pre-filled from useSettings", () => {
render(<SettingsForm />);
const input = screen.getByLabelText("Данс эзэмшигчийн нэр") as HTMLInputElement;
expect(input.value).toBe(settingsFixture.holderName);
});
// Flagged in review: SwitchRoot needs its SwitchHiddenInput sibling to be an
// accessible, interactive `role="switch"` at all — SwitchControl/SwitchThumb
// are aria-hidden decoration only (same bug fixed in ds/HideAmountsToggle.tsx).
it("hideAmounts switch is an accessible, toggleable role=switch bound to form state", () => {
render(<SettingsForm />);
const toggle = screen.getByRole("switch", { name: "Үнийн дүн нуух" });
expect(toggle).not.toBeChecked();
fireEvent.click(toggle);
expect(toggle).toBeChecked();
});

View file

@ -0,0 +1,217 @@
"use client";
import * as React from "react";
import {
TextFieldRoot,
TextFieldInput,
SwitchRoot,
SwitchControl,
SwitchThumb,
SwitchLabel,
SwitchHiddenInput,
Skeleton,
} from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton";
import { useSettings } from "@/api/hooks/reads";
import { useSaveSettings } from "@/api/hooks/mutations";
import type { Settings } from "@/api/schemas";
import { profileStrings } from "./strings";
interface FormState {
holderName: string;
employer: string;
salaryKeywords: string;
payDays: string;
ownAccounts: string;
peerAccounts: string;
hideAmounts: boolean;
}
const EMPTY_FORM: FormState = {
holderName: "",
employer: "",
salaryKeywords: "",
payDays: "",
ownAccounts: "",
peerAccounts: "",
hideAmounts: false,
};
function toFormState(settings: Settings | undefined): FormState {
if (!settings) return EMPTY_FORM;
return {
holderName: settings.holderName,
employer: settings.employer,
salaryKeywords: settings.salaryKeywords.join(", "),
payDays: settings.payDays.join(", "),
ownAccounts: settings.ownAccounts.join(", "),
peerAccounts: settings.peerAccounts.join(", "),
hideAmounts: settings.hideAmounts ?? false,
};
}
function splitList(value: string): string[] {
return value
.split(",")
.map((v) => v.trim())
.filter((v) => v.length > 0);
}
function splitNumberList(value: string): number[] {
return splitList(value)
.map((v) => Number(v))
.filter((n) => Number.isFinite(n));
}
export interface SettingsFormProps {
onBack?: () => void;
}
/** Edits the account-level settings (holder name, employer, salary detection
* keywords/pay days, own/peer account lists, hide-amounts) that back the
* backend's auto-categorization not currently exposed anywhere in the iOS
* app, but commissioned for the web app per the Task 13 brief. */
export function SettingsForm({ onBack }: SettingsFormProps) {
const query = useSettings();
const save = useSaveSettings();
const initialized = React.useRef(Boolean(query.data));
const [form, setForm] = React.useState<FormState>(() => toFormState(query.data));
const [savedAt, setSavedAt] = React.useState<number | null>(null);
React.useEffect(() => {
if (!initialized.current && query.data) {
setForm(toFormState(query.data));
initialized.current = true;
}
}, [query.data]);
function field(key: keyof Omit<FormState, "hideAmounts">) {
return {
value: form[key],
onValueChange: (v: string) => setForm((f) => ({ ...f, [key]: v })),
};
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setSavedAt(null);
await save.mutateAsync({
holderName: form.holderName.trim(),
employer: form.employer.trim(),
salaryKeywords: splitList(form.salaryKeywords),
payDays: splitNumberList(form.payDays),
ownAccounts: splitList(form.ownAccounts),
peerAccounts: splitList(form.peerAccounts),
hideAmounts: form.hideAmounts,
});
setSavedAt(Date.now());
}
if (query.isLoading && !query.data) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<Skeleton height="44px" />
<Skeleton height="44px" />
<Skeleton height="44px" />
</div>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{onBack && (
<button
type="button"
onClick={onBack}
aria-label={profileStrings.settings.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
)}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.settings.title}</h1>
</div>
<form onSubmit={handleSubmit} style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<Labeled label={profileStrings.settings.holderName}>
<TextFieldRoot {...field("holderName")} name="holderName">
<TextFieldInput aria-label={profileStrings.settings.holderName} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.employer}>
<TextFieldRoot {...field("employer")} name="employer">
<TextFieldInput aria-label={profileStrings.settings.employer} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.salaryKeywords} hint={profileStrings.settings.salaryKeywordsHint}>
<TextFieldRoot {...field("salaryKeywords")} name="salaryKeywords">
<TextFieldInput aria-label={profileStrings.settings.salaryKeywords} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.payDays} hint={profileStrings.settings.payDaysHint}>
<TextFieldRoot {...field("payDays")} name="payDays">
<TextFieldInput aria-label={profileStrings.settings.payDays} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.ownAccounts} hint={profileStrings.settings.ownAccountsHint}>
<TextFieldRoot {...field("ownAccounts")} name="ownAccounts">
<TextFieldInput aria-label={profileStrings.settings.ownAccounts} />
</TextFieldRoot>
</Labeled>
<Labeled label={profileStrings.settings.peerAccounts} hint={profileStrings.settings.peerAccountsHint}>
<TextFieldRoot {...field("peerAccounts")} name="peerAccounts">
<TextFieldInput aria-label={profileStrings.settings.peerAccounts} />
</TextFieldRoot>
</Labeled>
<SwitchRoot
checked={form.hideAmounts}
onCheckedChange={(v: boolean) => setForm((f) => ({ ...f, hideAmounts: v }))}
>
{/* The actual interactive/accessible element (role="switch",
checked/onChange) lives on the hidden input SwitchControl and
SwitchThumb are purely decorative (aria-hidden). Without this
the switch renders but nothing is clickable or announced to
assistive tech (same bug as ds/HideAmountsToggle.tsx). */}
<SwitchHiddenInput />
<SwitchControl>
<SwitchThumb />
</SwitchControl>
<SwitchLabel>{profileStrings.settings.hideAmounts}</SwitchLabel>
</SwitchRoot>
{save.isError && (
<p role="alert" style={{ fontSize: 13, color: "var(--seed-color-fg-critical)" }}>
{profileStrings.settings.error}
</p>
)}
{savedAt && !save.isPending && (
<p style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{profileStrings.settings.saved}
</p>
)}
<MercuryButton type="submit" variant="primary" loading={save.isPending}>
{profileStrings.settings.save}
</MercuryButton>
</form>
</div>
);
}
function Labeled({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{label}</span>
{children}
{hint && <span style={{ fontSize: 11, color: "var(--seed-color-fg-placeholder, #9ca3af)" }}>{hint}</span>}
</label>
);
}

View file

@ -0,0 +1,261 @@
"use client";
import * as React from "react";
import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react";
import { MercuryButton } from "@/ds/MercuryButton";
import { tugrik } from "@/ds/money";
import { useSubscriptions } from "@/api/hooks/reads";
import { useSubscriptionMutations } from "@/api/hooks/mutations";
import type { Subscription } from "@/api/schemas";
import { profileStrings } from "./strings";
export interface SubscriptionsViewProps {
onBack?: () => void;
}
/** Detected (subscriptions + bills) and manually-added recurring items,
* mirroring the `/subscriptions` response consumed by iOS's
* `ManualSubscriptionView`/`TransactionDetailView`. Detected rows can be
* deactivated (their matchKey stops being force-included); manual rows can
* be added and deleted. There is no "edit manual" hook exposed to this task,
* so manual rows are add/delete only. */
export function SubscriptionsView({ onBack }: SubscriptionsViewProps) {
const { data, isLoading } = useSubscriptions();
const { setSubscription, createManualSub, deleteManualSub } = useSubscriptionMutations();
const [showAdd, setShowAdd] = React.useState(false);
const all = [...(data?.subscriptions ?? []), ...(data?.bills ?? [])];
const detected = all.filter((s) => !s.manual);
const manual = all.filter((s) => s.manual);
async function handleDeactivate(sub: Subscription) {
if (!sub.matchKey) return;
await setSubscription.mutateAsync({ matchKey: sub.matchKey, name: sub.label, active: false });
}
async function handleDeleteManual(sub: Subscription) {
if (sub.id == null) return;
await deleteManualSub.mutateAsync(sub.id);
}
if (showAdd) {
return (
<ManualSubscriptionForm
onCancel={() => setShowAdd(false)}
onSave={async (values) => {
await createManualSub.mutateAsync(values);
setShowAdd(false);
}}
/>
);
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{onBack && (
<button
type="button"
onClick={onBack}
aria-label={profileStrings.subscriptions.back}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
)}
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0, flex: 1 }}>{profileStrings.subscriptions.title}</h1>
<MercuryButton variant="ghost" onClick={() => setShowAdd(true)}>
+ {profileStrings.subscriptions.addManual}
</MercuryButton>
</div>
{isLoading && (
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<Skeleton height="52px" />
<Skeleton height="52px" />
</div>
)}
{!isLoading && all.length === 0 && (
<p style={{ fontSize: 14, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.empty}</p>
)}
{!isLoading && detected.length > 0 && (
<section>
<h2 style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 8px" }}>
{profileStrings.subscriptions.detected}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{detected.map((s) => (
<SubscriptionRow
key={s.matchKey ?? s.label}
sub={s}
actionLabel={profileStrings.subscriptions.deactivate}
onAction={() => handleDeactivate(s)}
pending={setSubscription.isPending}
/>
))}
</div>
</section>
)}
{!isLoading && manual.length > 0 && (
<section>
<h2 style={{ fontSize: 12, color: "var(--seed-color-fg-subtle, #6b7280)", margin: "0 0 8px" }}>
{profileStrings.subscriptions.manual}
</h2>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
{manual.map((s) => (
<SubscriptionRow
key={s.id}
sub={s}
actionLabel={profileStrings.subscriptions.delete}
onAction={() => handleDeleteManual(s)}
pending={deleteManualSub.isPending}
destructive
/>
))}
</div>
</section>
)}
</div>
);
}
function SubscriptionRow({
sub,
actionLabel,
onAction,
pending,
destructive,
}: {
sub: Subscription;
actionLabel: string;
onAction: () => void;
pending?: boolean;
destructive?: boolean;
}) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "8px 0" }}>
<div style={{ display: "flex", flexDirection: "column" }}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{sub.label}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-muted, #6b7280)" }}>
{tugrik(sub.monthly)} / {sub.cadence}
{sub.nextDue ? ` · ${sub.nextDue}` : ""}
</span>
</div>
<button
type="button"
onClick={onAction}
disabled={pending}
aria-label={`${actionLabel}: ${sub.label}`}
style={{
background: "none",
border: "none",
cursor: "pointer",
fontSize: 13,
color: destructive ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-muted, #6b7280)",
}}
>
{actionLabel}
</button>
</div>
);
}
function ManualSubscriptionForm({
onCancel,
onSave,
}: {
onCancel: () => void;
onSave: (values: { name: string; amount: string; category?: string; nextDue?: string }) => Promise<void>;
}) {
const [name, setName] = React.useState("");
const [amount, setAmount] = React.useState("");
const [category, setCategory] = React.useState("");
const [nextDue, setNextDue] = React.useState("");
const [saving, setSaving] = React.useState(false);
const [error, setError] = React.useState<string | undefined>();
const canSave = name.trim().length > 0 && Number(amount) > 0 && !saving;
async function handleSave() {
if (!canSave) return;
setSaving(true);
setError(undefined);
try {
await onSave({
name: name.trim(),
amount,
category: category.trim() || undefined,
nextDue: nextDue || undefined,
});
} catch {
setError(profileStrings.subscriptions.saveError);
} finally {
setSaving(false);
}
}
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={onCancel}
aria-label={profileStrings.subscriptions.cancel}
style={{ background: "none", border: "none", fontSize: 20, cursor: "pointer" }}
>
</button>
<h1 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>{profileStrings.subscriptions.addManual}</h1>
</div>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.name}</span>
<TextFieldRoot value={name} onValueChange={setName} name="subName">
<TextFieldInput aria-label={profileStrings.subscriptions.name} placeholder={profileStrings.subscriptions.namePlaceholder} />
</TextFieldRoot>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.amount}</span>
<TextFieldRoot value={amount} onValueChange={setAmount} name="subAmount">
<TextFieldInput aria-label={profileStrings.subscriptions.amount} inputMode="numeric" placeholder="0" />
</TextFieldRoot>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.category}</span>
<TextFieldRoot value={category} onValueChange={setCategory} name="subCategory">
<TextFieldInput aria-label={profileStrings.subscriptions.category} placeholder={profileStrings.subscriptions.categoryPlaceholder} />
</TextFieldRoot>
</label>
<label style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-muted, #6b7280)" }}>{profileStrings.subscriptions.nextDue}</span>
<input
type="date"
value={nextDue}
onChange={(e) => setNextDue(e.target.value)}
aria-label={profileStrings.subscriptions.nextDue}
style={{
padding: "10px 12px",
borderRadius: "var(--seed-radius-r3)",
border: "1px solid var(--seed-color-border-default, #e5e7eb)",
}}
/>
</label>
{error && (
<p role="alert" style={{ fontSize: 13, color: "var(--seed-color-fg-critical)" }}>
{error}
</p>
)}
<MercuryButton variant="primary" onClick={handleSave} disabled={!canSave} loading={saving}>
{profileStrings.subscriptions.save}
</MercuryButton>
</div>
);
}

View file

@ -0,0 +1,88 @@
// Profile / Categories / Subscriptions / Connections copy, ported from
// ios/Mercury/Features/Home/ProfileView.swift, Categories/CategoriesView.swift,
// Subscriptions/ManualSubscriptionView.swift (Mongolian labels kept verbatim;
// the couple of English fragments in ProfileView.swift — "My Profile",
// "Banks", "Display & Privacy" — are localized here for consistency with the
// rest of the web app's Mongolian copy).
export const profileStrings = {
header: {
title: "Миний профайл",
},
account: {
handlePrefix: "@",
},
menu: {
settings: "Тохиргоо",
categories: "Ангилал",
subscriptions: "Захиалга",
},
display: {
title: "Дэлгэц ба нууцлал",
hideAmounts: "Үнийн дүн нуух",
},
banks: {
title: "Банкууд",
empty: "Холбогдсон банк алга.",
manageNote:
"Банк холбох, салгах үйлдлийг зөвхөн гар утасны аппликейшнээс хийнэ үү.",
courierManaged: "Автомат синк",
},
logout: "Гарах",
settings: {
title: "Тохиргоо",
back: "Миний профайл",
holderName: "Данс эзэмшигчийн нэр",
employer: "Ажил олгогч",
salaryKeywords: "Цалингийн түлхүүр үг",
salaryKeywordsHint: "Таслалаар тусгаарлан бичнэ үү",
payDays: "Цалин өгдөг өдрүүд",
payDaysHint: "Сарын өдрийн дугаар, таслалаар тусгаарлана (жишээ: 1, 15)",
ownAccounts: "Өөрийн дансууд",
ownAccountsHint: "Дансны дугаар, таслалаар тусгаарлана",
peerAccounts: "Найз/хамаатны дансууд",
peerAccountsHint: "Дансны дугаар, таслалаар тусгаарлана",
hideAmounts: "Үнийн дүн нуух",
save: "Хадгалах",
saved: "Хадгалагдлаа",
error: "Хадгалж чадсангүй",
},
categories: {
title: "Категори",
back: "Миний профайл",
add: "Ангилал нэмэх",
edit: "Ангилал засах",
name: "Ангиллын нэр",
namePlaceholder: "Ангиллын нэр",
icon: "Дүрс тэмдэг",
parent: "Эцэг ангилал (заавал биш)",
parentNone: "Эцэг ангилал (заавал биш)",
save: "Хадгалах",
cancel: "Болих",
editAction: "Засах",
deleteAction: "Устгах",
deleteTitle: "Ангилал устгах уу?",
deleteMessage: (name: string) =>
`«${name}» болон түүнд хамаарах гүйлгээнүүд ангилалгүй болно.`,
empty: "Категори алга.",
},
subscriptions: {
title: "Subscriptions",
back: "Миний профайл",
detected: "Илэрсэн",
bills: "Тогтмол төлбөрүүд",
manual: "Гараар нэмсэн",
empty: "Одоогоор subscription алга.",
addManual: "Гараар нэмэх",
name: "Нэр",
namePlaceholder: "Жишээ: Netflix",
amount: "Сарын төлбөр",
category: "Ангилал",
categoryPlaceholder: "Ангилал",
nextDue: "Дараагийн төлөх огноо",
save: "Хадгалах",
cancel: "Болих",
delete: "Устгах",
deactivate: "Идэвхгүй болгох",
saveError: "Хадгалж чадсангүй",
},
} as const;

View file

@ -12,3 +12,31 @@ export const txn = { date: "2026-08-01", amount: "52000", direction: "debit", ca
balanceAfter: "400200", accountId: 1, transfer: false, txnId: 10, note: "", matchKey: "mk1", salary: false }; balanceAfter: "400200", accountId: 1, transfer: false, txnId: 10, note: "", matchKey: "mk1", salary: false };
export const lending = { id: 1, person: "Бат", principal: "100000", lentOn: "2026-07-01", dueOn: "2026-09-01", note: "", export const lending = { id: 1, person: "Бат", principal: "100000", lentOn: "2026-07-01", dueOn: "2026-09-01", note: "",
repaid: "40000", remaining: "60000", status: "partial", overdue: false, txnId: null, repayments: [{ id: 1, amount: "40000", paidOn: "2026-08-01", note: "", txnId: null }] }; repaid: "40000", remaining: "60000", status: "partial", overdue: false, txnId: null, repayments: [{ id: 1, amount: "40000", paidOn: "2026-08-01", note: "", txnId: null }] };
// --- Task 14: fixtures for the remaining read endpoints, so the MSW handler
// set (src/test/handlers.ts) can cover every /api/v1/* GET the app makes. ---
export const analyzeToday = { from: "2026-08-22", to: "2026-08-22", income: "0", expense: "52000",
discretionaryExpense: "52000", net: "-52000", months: null,
expenseCategories: [{ name: "Хоол", count: 1, total: "52000" }],
incomePayees: [], expensePayees: [{ name: "худалдан авалт", count: 1, total: "52000" }] };
export const category = { name: "Хоол", kind: "expense", depth: 0, icon: null };
export const categories = [category];
export const subscription = { label: "Netflix", amount: "12900", monthly: "12900", cadence: "monthly",
nextDue: "2026-09-01", matchKey: "netflix", id: 1, manual: false, category: "Хоол" };
export const subscriptions = [subscription];
export const bills: typeof subscriptions = [];
export const manualAsset = { name: "Toyota Prius", category: "vehicle", value: "25000000",
acquiredValue: "20000000", currency: "MNT", condition: "used", isLiability: false, change: "5000000" };
export const manualAssets = [manualAsset];
export const settings = { holderName: "Бат", employer: "ABC LLC", salaryKeywords: ["цалин"], payDays: [1],
ownAccounts: ["***1"], peerAccounts: [], hideAmounts: false };
export const connection = { bank: "khan", username: "user1", courierManaged: false };
export const connections = [connection];
export const user = { id: 1, email: "test@example.com", createdAt: "2026-01-01T00:00:00Z" };

42
src/test/handlers.ts Normal file
View file

@ -0,0 +1,42 @@
import { http, HttpResponse } from "msw";
import * as fixtures from "./fixtures";
/**
* MSW (v2) request handlers for every `/api/v1/*` read endpoint the app
* calls (see src/api/hooks/reads.ts), returning the fixtures in ./fixtures.ts.
* Reusable by any feature test that needs the whole read surface mocked
* `setupServer(...handlers)` in ./server.ts, or override per-test with
* `server.use(...)` for error/loading-state cases.
*/
export const handlers = [
http.get("/api/v1/networth", () => HttpResponse.json(fixtures.netWorth)),
// Same endpoint backs both the month view (no query) and the "today" view
// (?from=&to=); distinguish on the query string, matching useAnalyzeMonth
// vs useAnalyzeToday in src/api/hooks/reads.ts.
http.get("/api/v1/analyze", ({ request }) => {
const url = new URL(request.url);
const isToday = url.searchParams.has("from") || url.searchParams.has("to");
return HttpResponse.json(isToday ? fixtures.analyzeToday : fixtures.analyzeMonth);
}),
http.get("/api/v1/budget", () => HttpResponse.json(fixtures.budget)),
http.get("/api/v1/transactions", () => HttpResponse.json({ transactions: [fixtures.txn] })),
http.get("/api/v1/categories", () => HttpResponse.json({ categories: fixtures.categories })),
http.get("/api/v1/subscriptions", () =>
HttpResponse.json({ subscriptions: fixtures.subscriptions, bills: fixtures.bills }),
),
http.get("/api/v1/manual-assets", () => HttpResponse.json({ manualAssets: fixtures.manualAssets })),
http.get("/api/v1/lending", () => HttpResponse.json({ entries: [fixtures.lending] })),
http.get("/api/v1/settings", () => HttpResponse.json(fixtures.settings)),
http.get("/api/v1/connections", () => HttpResponse.json({ connections: fixtures.connections })),
http.get("/api/v1/me", () => HttpResponse.json({ user: fixtures.user })),
];

10
src/test/server.ts Normal file
View file

@ -0,0 +1,10 @@
import { setupServer } from "msw/node";
import { handlers } from "./handlers";
/**
* The shared MSW (v2) node server for integration tests. A feature test
* starts/resets/closes it itself (beforeAll/afterEach/afterAll) rather than
* this file doing it globally, so unit tests that don't need network mocking
* pay no cost. See src/features/home/dashboard.msw.test.tsx for the pattern.
*/
export const server = setupServer(...handlers);

View file

@ -1,5 +1,5 @@
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config"; import { configDefaults, defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
@ -16,5 +16,9 @@ export default defineConfig({
setupFiles: ["./vitest.setup.ts"], setupFiles: ["./vitest.setup.ts"],
globals: true, globals: true,
server: { deps: { inline: [/@seed-design/] } }, server: { deps: { inline: [/@seed-design/] } },
// e2e/ holds Playwright specs (own `test()`, own runner) — exclude them
// from Vitest's collection alongside the defaults, or `*.spec.ts` there
// gets picked up by Vitest's default include glob and fails to load.
exclude: [...configDefaults.exclude, "e2e/**"],
}, },
}); });