Compare commits
14 commits
2555e42f21
...
6913c1e5ee
| Author | SHA1 | Date | |
|---|---|---|---|
| 6913c1e5ee | |||
| d94e961d97 | |||
| b02674aa62 | |||
| 6e84d99d46 | |||
| 95729de201 | |||
| 3014385e9b | |||
| beb1915bd8 | |||
| c8fd8eecc0 | |||
| c52a2d731d | |||
| 0b2a786791 | |||
| 24642be543 | |||
| 922d2b54ef | |||
| f9195c5ffa | |||
| 762b572d30 |
58 changed files with 5426 additions and 9 deletions
101
e2e/smoke.spec.ts
Normal file
101
e2e/smoke.spec.ts
Normal 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
29
playwright.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -17,7 +17,13 @@ async function req(method: string, path: string, body?: unknown): Promise<unknow
|
|||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
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");
|
||||
}
|
||||
if (!res.ok) throw new ApiError(res.status, await res.text());
|
||||
|
|
|
|||
11
src/app/(app)/accounting/[id]/page.tsx
Normal file
11
src/app/(app)/accounting/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
7
src/app/(app)/accounting/page.tsx
Normal file
7
src/app/(app)/accounting/page.tsx
Normal 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 />;
|
||||
}
|
||||
6
src/app/(app)/assets/account/[id]/page.tsx
Normal file
6
src/app/(app)/assets/account/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { AccountDetail } from "@/features/assets/AccountDetail";
|
||||
|
||||
export default async function AccountDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <AccountDetail accountId={Number(id)} />;
|
||||
}
|
||||
6
src/app/(app)/assets/lending/[id]/page.tsx
Normal file
6
src/app/(app)/assets/lending/[id]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { LendingDetail } from "@/features/assets/LendingDetail";
|
||||
|
||||
export default async function LendingDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <LendingDetail id={Number(id)} />;
|
||||
}
|
||||
6
src/app/(app)/assets/manual/[name]/page.tsx
Normal file
6
src/app/(app)/assets/manual/[name]/page.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { ManualAssetDetail } from "@/features/assets/ManualAssetDetail";
|
||||
|
||||
export default async function ManualAssetPage({ params }: { params: Promise<{ name: string }> }) {
|
||||
const { name } = await params;
|
||||
return <ManualAssetDetail name={decodeURIComponent(name)} />;
|
||||
}
|
||||
7
src/app/(app)/assets/page.tsx
Normal file
7
src/app/(app)/assets/page.tsx
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { AssetsView } from "@/features/assets/AssetsView";
|
||||
|
||||
// Ports ios/Mercury/Features/Assets/AssetsView.swift's Хөрөнгө tab: net worth
|
||||
// header, read-only bank accounts, manual (physical) assets, and lending.
|
||||
export default function AssetsPage() {
|
||||
return <AssetsView />;
|
||||
}
|
||||
|
|
@ -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() {
|
||||
return <h1 style={{ fontWeight: 700 }}>{t.tabs.home}</h1>;
|
||||
return <DashboardView />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
|||
<AppQueryProvider>
|
||||
<div className="md:flex md:min-h-screen">
|
||||
<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}
|
||||
</main>
|
||||
</div>
|
||||
|
|
|
|||
10
src/app/(app)/planner/[category]/page.tsx
Normal file
10
src/app/(app)/planner/[category]/page.tsx
Normal 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)} />;
|
||||
}
|
||||
5
src/app/(app)/planner/page.tsx
Normal file
5
src/app/(app)/planner/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { PlannerView } from "@/features/planner/PlannerView";
|
||||
|
||||
export default function PlannerPage() {
|
||||
return <PlannerView />;
|
||||
}
|
||||
9
src/app/(app)/profile/categories/page.tsx
Normal file
9
src/app/(app)/profile/categories/page.tsx
Normal 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")} />;
|
||||
}
|
||||
5
src/app/(app)/profile/page.tsx
Normal file
5
src/app/(app)/profile/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { ProfileView } from "@/features/profile/ProfileView";
|
||||
|
||||
export default function ProfilePage() {
|
||||
return <ProfileView />;
|
||||
}
|
||||
9
src/app/(app)/profile/subscriptions/page.tsx
Normal file
9
src/app/(app)/profile/subscriptions/page.tsx
Normal 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")} />;
|
||||
}
|
||||
|
|
@ -6,5 +6,9 @@
|
|||
|
||||
html, body {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ export const metadata = { title: "Mercury" };
|
|||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="mn">
|
||||
<html lang="mn" data-seed data-seed-color-mode="light-only">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
35
src/ds/HideAmountsToggle.test.tsx
Normal file
35
src/ds/HideAmountsToggle.test.tsx
Normal 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();
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
|
||||
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";
|
||||
|
||||
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
|
||||
|
|
@ -25,6 +25,12 @@ export function HideAmountsToggle({ label = "Мөнгөн дүн нуух" }: Hi
|
|||
|
||||
return (
|
||||
<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>
|
||||
<SwitchThumb />
|
||||
</SwitchControl>
|
||||
|
|
|
|||
97
src/features/accounting/CategorizeSheet.tsx
Normal file
97
src/features/accounting/CategorizeSheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
321
src/features/accounting/TransactionDetail.tsx
Normal file
321
src/features/accounting/TransactionDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/features/accounting/TransactionList.test.tsx
Normal file
52
src/features/accounting/TransactionList.test.tsx
Normal 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();
|
||||
});
|
||||
174
src/features/accounting/TransactionList.tsx
Normal file
174
src/features/accounting/TransactionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/features/accounting/strings.ts
Normal file
44
src/features/accounting/strings.ts
Normal 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;
|
||||
18
src/features/accounting/txnRoute.ts
Normal file
18
src/features/accounting/txnRoute.ts
Normal 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);
|
||||
}
|
||||
34
src/features/accounting/useHiddenAmounts.ts
Normal file
34
src/features/accounting/useHiddenAmounts.ts
Normal 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;
|
||||
}
|
||||
104
src/features/assets/AccountDetail.tsx
Normal file
104
src/features/assets/AccountDetail.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useNetWorth, useTransactions } from "@/api/hooks/reads";
|
||||
import { Card } from "@/ds";
|
||||
import { tugrikRaw } from "@/ds/money";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
|
||||
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
||||
|
||||
export interface AccountDetailProps {
|
||||
accountId: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-only account detail — no bank-server actions live here (that's iOS's
|
||||
* `AccountDetailView`, gated behind a live bank connection this web app
|
||||
* doesn't drive yet). Shows the balance from `/networth` plus this
|
||||
* account's recent transactions.
|
||||
*/
|
||||
export function AccountDetail({ accountId }: AccountDetailProps) {
|
||||
const netWorth = useNetWorth();
|
||||
const transactions = useTransactions({ account: accountId, limit: 30 });
|
||||
|
||||
const account = (netWorth.data?.accounts ?? []).find((a) => a.accountId === accountId);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<BackLink />
|
||||
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{account?.bank ?? s.accountDetail.bank}</h1>
|
||||
</header>
|
||||
|
||||
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>{account?.accountNumber}</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(account?.balance ?? "0")}</div>
|
||||
</Card>
|
||||
|
||||
<Card style={{ padding: "6px 20px" }}>
|
||||
<DetailRow label={s.accountDetail.bank} value={account?.bank ?? "—"} />
|
||||
<Divider />
|
||||
<DetailRow label={s.accountDetail.accountNumber} value={account?.accountNumber ?? "—"} />
|
||||
<Divider />
|
||||
<DetailRow label={s.accountDetail.currency} value={account?.currency ?? "—"} />
|
||||
</Card>
|
||||
|
||||
<section>
|
||||
<h3 style={{ margin: "0 0 10px", fontSize: 14, fontWeight: 700 }}>{s.accountDetail.recentTransactions}</h3>
|
||||
{transactions.isLoading ? (
|
||||
<p style={{ fontSize: 13, ...mutedStyle }}>…</p>
|
||||
) : (transactions.data ?? []).length === 0 ? (
|
||||
<p style={{ fontSize: 13, ...mutedStyle }}>{s.accountDetail.noTransactions}</p>
|
||||
) : (
|
||||
<Card style={{ padding: 0 }}>
|
||||
{(transactions.data ?? []).map((txn, i) => {
|
||||
const income = txn.direction === "income";
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{i > 0 && <Divider />}
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 16px" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{txn.title || txn.category}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>{txn.date.slice(0, 10)}</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
|
||||
}}
|
||||
>
|
||||
{income ? "+" : "−"}
|
||||
{tugrikRaw(txn.amount)}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
|
||||
← {s.common.back}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
|
||||
<span style={mutedStyle}>{label}</span>
|
||||
<span style={{ fontWeight: 600 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
|
||||
}
|
||||
52
src/features/assets/AssetsView.test.tsx
Normal file
52
src/features/assets/AssetsView.test.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { AssetsView } from "./AssetsView";
|
||||
import { tugrik } from "@/ds/money";
|
||||
|
||||
const mutationStub = () => ({ mutateAsync: vi.fn(), isPending: false });
|
||||
|
||||
vi.mock("@/api/hooks/reads", () => ({
|
||||
useNetWorth: () => ({
|
||||
data: { total: "1250000", assets: "1500000", liabilities: "250000", accounts: [] },
|
||||
isLoading: false,
|
||||
}),
|
||||
useManualAssets: () => ({
|
||||
data: [
|
||||
{
|
||||
name: "Toyota Prius",
|
||||
category: "car",
|
||||
value: "45000000",
|
||||
acquiredValue: "40000000",
|
||||
currency: "MNT",
|
||||
condition: "used",
|
||||
isLiability: false,
|
||||
change: "5000000",
|
||||
},
|
||||
],
|
||||
isLoading: false,
|
||||
}),
|
||||
useLending: () => ({ data: [], isLoading: false }),
|
||||
}));
|
||||
|
||||
vi.mock("@/api/hooks/mutations", () => ({
|
||||
useManualAssetMutations: () => ({
|
||||
add: mutationStub(),
|
||||
delete: mutationStub(),
|
||||
revalue: mutationStub(),
|
||||
}),
|
||||
useLendingMutations: () => ({
|
||||
create: mutationStub(),
|
||||
delete: mutationStub(),
|
||||
addRepayment: mutationStub(),
|
||||
deleteRepayment: mutationStub(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("AssetsView", () => {
|
||||
it("renders the net worth total and the manual asset row", () => {
|
||||
render(<AssetsView />);
|
||||
|
||||
expect(screen.getByText(tugrik("1250000"))).toBeInTheDocument();
|
||||
expect(screen.getByText("Toyota Prius")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
582
src/features/assets/AssetsView.tsx
Normal file
582
src/features/assets/AssetsView.tsx
Normal file
|
|
@ -0,0 +1,582 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import Link from "next/link";
|
||||
import {
|
||||
BottomSheetRoot,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetPositioner,
|
||||
BottomSheetContent,
|
||||
BottomSheetHeader,
|
||||
BottomSheetTitle,
|
||||
BottomSheetBody,
|
||||
BottomSheetFooter,
|
||||
TextFieldRoot,
|
||||
TextFieldInput,
|
||||
Skeleton,
|
||||
} from "@seed-design/react";
|
||||
import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
|
||||
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
|
||||
import { Card, HideAmountsToggle, MercuryButton } from "@/ds";
|
||||
import { tugrik, tugrikRaw } from "@/ds/money";
|
||||
import type { Account, ManualAsset, Lending } from "@/api/schemas";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
import { useHideAmountsTick } from "./useHideAmountsTick";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
||||
const rowStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 12,
|
||||
padding: "14px 16px",
|
||||
};
|
||||
|
||||
export function AssetsView() {
|
||||
// Re-renders when the global hide-amounts flag flips (tugrik()/tugrikRaw()
|
||||
// read it from localStorage synchronously, so this is how the numbers on
|
||||
// this page react to the header toggle without a reload).
|
||||
useHideAmountsTick();
|
||||
|
||||
const netWorth = useNetWorth();
|
||||
const manualAssets = useManualAssets();
|
||||
const lending = useLending();
|
||||
const assetMutations = useManualAssetMutations();
|
||||
const lendingMutations = useLendingMutations();
|
||||
|
||||
const [addAssetOpen, setAddAssetOpen] = React.useState(false);
|
||||
const [addLoanOpen, setAddLoanOpen] = React.useState(false);
|
||||
const [deleteAssetName, setDeleteAssetName] = React.useState<string | null>(null);
|
||||
const [revalueAssetName, setRevalueAssetName] = React.useState<string | null>(null);
|
||||
const [deleteLoan, setDeleteLoan] = React.useState<Lending | null>(null);
|
||||
|
||||
const accounts: Account[] = netWorth.data?.accounts ?? [];
|
||||
const assets: ManualAsset[] = manualAssets.data ?? [];
|
||||
const loans: Lending[] = lending.data ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
||||
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<h1 style={{ margin: 0, fontSize: 20, fontWeight: 700 }}>{s.header.title}</h1>
|
||||
<HideAmountsToggle />
|
||||
</header>
|
||||
|
||||
<NetWorthCard netWorth={netWorth.data} loading={netWorth.isLoading} />
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SectionTitle>{s.accounts.title}</SectionTitle>
|
||||
{netWorth.isLoading ? (
|
||||
<SkeletonRows count={2} />
|
||||
) : accounts.length === 0 ? (
|
||||
<EmptyText>{s.accounts.empty}</EmptyText>
|
||||
) : (
|
||||
<Card style={{ padding: 0 }}>
|
||||
{accounts.map((account, i) => (
|
||||
<React.Fragment key={account.accountId}>
|
||||
{i > 0 && <Divider />}
|
||||
<Link
|
||||
href={`/assets/account/${account.accountId}`}
|
||||
style={{ ...rowStyle, color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{account.bank}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>{account.accountNumber}</div>
|
||||
</div>
|
||||
<div style={{ fontWeight: 700 }}>{tugrik(account.balance)}</div>
|
||||
</Link>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SectionHeaderRow title={s.manualAssets.title} onAdd={() => setAddAssetOpen(true)} addLabel={s.manualAssets.add} />
|
||||
{manualAssets.isLoading ? (
|
||||
<SkeletonRows count={3} />
|
||||
) : assets.length === 0 ? (
|
||||
<EmptyBlock title={s.manualAssets.emptyTitle} subtitle={s.manualAssets.emptySubtitle} />
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{assets.map((asset) => (
|
||||
<ManualAssetRow
|
||||
key={asset.name}
|
||||
asset={asset}
|
||||
revaluing={assetMutations.revalue.isPending && revalueAssetName === asset.name}
|
||||
onRevalue={() => setRevalueAssetName(asset.name)}
|
||||
onDelete={() => setDeleteAssetName(asset.name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||
<SectionHeaderRow title={s.lending.title} onAdd={() => setAddLoanOpen(true)} addLabel={s.lending.add} />
|
||||
{lending.isLoading ? (
|
||||
<SkeletonRows count={2} />
|
||||
) : loans.length === 0 ? (
|
||||
<EmptyText>{s.lending.empty}</EmptyText>
|
||||
) : (
|
||||
<Card style={{ padding: 0 }}>
|
||||
{loans.map((loan, i) => (
|
||||
<React.Fragment key={loan.id}>
|
||||
{i > 0 && <Divider />}
|
||||
<LoanRow loan={loan} onDelete={() => setDeleteLoan(loan)} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Card>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{addAssetOpen && (
|
||||
<AddManualAssetSheet
|
||||
onClose={() => setAddAssetOpen(false)}
|
||||
saving={assetMutations.add.isPending}
|
||||
onSave={async (values) => {
|
||||
await assetMutations.add.mutateAsync(values);
|
||||
setAddAssetOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{addLoanOpen && (
|
||||
<AddLoanSheet
|
||||
onClose={() => setAddLoanOpen(false)}
|
||||
saving={lendingMutations.create.isPending}
|
||||
onSave={async (values) => {
|
||||
await lendingMutations.create.mutateAsync(values);
|
||||
setAddLoanOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteAssetName !== null}
|
||||
title={s.manualAssets.deleteTitle}
|
||||
description={deleteAssetName ? s.manualAssets.deleteDescription(deleteAssetName) : undefined}
|
||||
busy={assetMutations.delete.isPending}
|
||||
onCancel={() => setDeleteAssetName(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleteAssetName) return;
|
||||
await assetMutations.delete.mutateAsync(deleteAssetName);
|
||||
setDeleteAssetName(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={revalueAssetName !== null}
|
||||
title={s.manualAssets.revalueTitle}
|
||||
description={s.manualAssets.revalueDescription}
|
||||
confirmLabel={s.manualAssets.revalue}
|
||||
destructive={false}
|
||||
busy={assetMutations.revalue.isPending}
|
||||
onCancel={() => setRevalueAssetName(null)}
|
||||
onConfirm={async () => {
|
||||
const name = revalueAssetName;
|
||||
if (!name) return;
|
||||
setRevalueAssetName(null);
|
||||
await assetMutations.revalue.mutateAsync(name);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={deleteLoan !== null}
|
||||
title={s.lending.deleteTitle}
|
||||
description={deleteLoan ? s.lending.deleteDescription(deleteLoan.person) : undefined}
|
||||
busy={lendingMutations.delete.isPending}
|
||||
onCancel={() => setDeleteLoan(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deleteLoan) return;
|
||||
await lendingMutations.delete.mutateAsync(deleteLoan.id);
|
||||
setDeleteLoan(null);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Net worth header -------------------------------------------------------
|
||||
|
||||
function NetWorthCard({ netWorth, loading }: { netWorth?: { total: string; assets: string; liabilities: string }; loading: boolean }) {
|
||||
return (
|
||||
<Card style={{ padding: "24px 20px", background: "var(--mercury-balance-card)" }}>
|
||||
{loading ? (
|
||||
<Skeleton height="40px" />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ fontSize: 13, opacity: 0.75 }}>{s.netWorth.total}</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 6 }}>{tugrik(netWorth?.total ?? "0")}</div>
|
||||
<div style={{ display: "flex", gap: 20, marginTop: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.assets}</div>
|
||||
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.assets ?? "0")}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>{s.netWorth.liabilities}</div>
|
||||
<div style={{ fontWeight: 600 }}>{tugrik(netWorth?.liabilities ?? "0")}</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Small shared bits -------------------------------------------------------
|
||||
|
||||
function SectionTitle({ children }: { children: React.ReactNode }) {
|
||||
return <h2 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{children}</h2>;
|
||||
}
|
||||
|
||||
function SectionHeaderRow({ title, onAdd, addLabel }: { title: string; onAdd: () => void; addLabel: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<SectionTitle>{title}</SectionTitle>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
aria-label={addLabel}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 22, lineHeight: 1, padding: 4 }}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyText({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p style={{ ...mutedStyle, fontSize: 13, padding: "12px 4px", margin: 0 }}>{children}</p>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBlock({ title, subtitle }: { title: string; subtitle: string }) {
|
||||
return (
|
||||
<div style={{ textAlign: "center", padding: "40px 20px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
<div style={{ fontWeight: 600 }}>{title}</div>
|
||||
<div style={{ fontSize: 13, ...mutedStyle }}>{subtitle}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkeletonRows({ count }: { count: number }) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<Skeleton key={i} height="64px" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
|
||||
}
|
||||
|
||||
// --- Manual asset row ---------------------------------------------------
|
||||
|
||||
function ManualAssetRow({
|
||||
asset,
|
||||
revaluing,
|
||||
onRevalue,
|
||||
onDelete,
|
||||
}: {
|
||||
asset: ManualAsset;
|
||||
revaluing: boolean;
|
||||
onRevalue: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const change = Number(asset.change || "0");
|
||||
const positive = change >= 0;
|
||||
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
|
||||
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
|
||||
|
||||
return (
|
||||
<Card style={{ padding: 0 }}>
|
||||
<div style={rowStyle}>
|
||||
<Link href={`/assets/manual/${encodeURIComponent(asset.name)}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
|
||||
<div style={{ fontWeight: 700 }}>{asset.name}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>
|
||||
{categoryLabel} · {conditionLabel}
|
||||
</div>
|
||||
</Link>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontWeight: 700 }}>{tugrikRaw(asset.value)}</div>
|
||||
<div style={{ fontSize: 11, ...mutedStyle }}>
|
||||
{s.manualAssets.acquiredPrefix}: {tugrikRaw(asset.acquiredValue)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
|
||||
}}
|
||||
>
|
||||
{positive ? "+" : "−"}
|
||||
{tugrikRaw(Math.abs(change))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, padding: "0 16px 12px" }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRevalue}
|
||||
disabled={revaluing}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
|
||||
borderRadius: 8,
|
||||
background: "none",
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
cursor: revaluing ? "default" : "pointer",
|
||||
}}
|
||||
>
|
||||
{revaluing ? "…" : s.manualAssets.revalue}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
style={{
|
||||
border: "1px solid var(--seed-color-border-neutral, #e5e5e5)",
|
||||
borderRadius: 8,
|
||||
background: "none",
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
color: "var(--seed-color-fg-critical)",
|
||||
cursor: "pointer",
|
||||
}}
|
||||
>
|
||||
{s.manualAssets.delete}
|
||||
</button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Lending row ---------------------------------------------------------
|
||||
|
||||
function statusLabel(loan: Lending): string {
|
||||
if (loan.overdue) return s.lending.statusOverdue;
|
||||
switch (loan.status) {
|
||||
case "repaid":
|
||||
return s.lending.statusPaid;
|
||||
case "partial":
|
||||
return s.lending.statusPartial;
|
||||
default:
|
||||
return s.lending.statusUnpaid;
|
||||
}
|
||||
}
|
||||
|
||||
function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
|
||||
return (
|
||||
<div style={rowStyle}>
|
||||
<Link href={`/assets/lending/${loan.id}`} style={{ color: "inherit", textDecoration: "none", flex: 1 }}>
|
||||
<div style={{ fontWeight: 600 }}>{loan.person}</div>
|
||||
<div style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
|
||||
{statusLabel(loan)}
|
||||
</div>
|
||||
</Link>
|
||||
<div style={{ textAlign: "right" }}>
|
||||
<div style={{ fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>/ {tugrikRaw(loan.principal)}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDelete}
|
||||
aria-label={s.lending.delete}
|
||||
style={{ background: "none", border: "none", color: "var(--seed-color-fg-neutral-subtle)", cursor: "pointer", fontSize: 16 }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Add manual asset sheet ------------------------------------------------
|
||||
|
||||
interface NewAssetValues {
|
||||
name: string;
|
||||
category: string;
|
||||
value: string;
|
||||
acquiredValue: string;
|
||||
condition: string;
|
||||
}
|
||||
|
||||
function AddManualAssetSheet({
|
||||
onClose,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (values: NewAssetValues) => void | Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [name, setName] = React.useState("");
|
||||
const [category, setCategory] = React.useState("car");
|
||||
const [condition, setCondition] = React.useState("used");
|
||||
const [price, setPrice] = React.useState("");
|
||||
|
||||
const canSave = name.trim().length > 0 && Number(price) > 0 && !saving;
|
||||
|
||||
return (
|
||||
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
|
||||
<BottomSheetBackdrop />
|
||||
<BottomSheetPositioner>
|
||||
<BottomSheetContent>
|
||||
<BottomSheetHeader>
|
||||
<BottomSheetTitle>{s.manualAssets.add}</BottomSheetTitle>
|
||||
</BottomSheetHeader>
|
||||
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<TextFieldRoot value={name} onValueChange={setName} name="asset-name">
|
||||
<TextFieldInput placeholder={s.manualAssets.fields.name} aria-label={s.manualAssets.fields.name} autoFocus />
|
||||
</TextFieldRoot>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
|
||||
{s.manualAssets.fields.category}
|
||||
<select value={category} onChange={(e) => setCategory(e.target.value)} style={{ padding: 8, borderRadius: 8 }}>
|
||||
{Object.entries(s.manualAssets.categories).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<TextFieldRoot value={price} onValueChange={setPrice} name="asset-price">
|
||||
<TextFieldInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={s.manualAssets.fields.price}
|
||||
aria-label={s.manualAssets.fields.price}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
|
||||
{s.manualAssets.fields.condition}
|
||||
<select value={condition} onChange={(e) => setCondition(e.target.value)} style={{ padding: 8, borderRadius: 8 }}>
|
||||
{Object.entries(s.manualAssets.conditions).map(([key, label]) => (
|
||||
<option key={key} value={key}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</BottomSheetBody>
|
||||
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
|
||||
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
|
||||
{s.common.cancel}
|
||||
</MercuryButton>
|
||||
<MercuryButton
|
||||
variant="primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!canSave}
|
||||
loading={saving}
|
||||
onClick={() =>
|
||||
onSave({
|
||||
name: name.trim(),
|
||||
category,
|
||||
value: price,
|
||||
acquiredValue: price,
|
||||
condition,
|
||||
})
|
||||
}
|
||||
>
|
||||
{s.common.save}
|
||||
</MercuryButton>
|
||||
</BottomSheetFooter>
|
||||
</BottomSheetContent>
|
||||
</BottomSheetPositioner>
|
||||
</BottomSheetRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Add loan sheet ---------------------------------------------------------
|
||||
|
||||
interface NewLoanValues {
|
||||
person: string;
|
||||
amount: string;
|
||||
lentOn: string;
|
||||
dueOn?: string;
|
||||
note?: string;
|
||||
}
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function AddLoanSheet({
|
||||
onClose,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (values: NewLoanValues) => void | Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [person, setPerson] = React.useState("");
|
||||
const [amount, setAmount] = React.useState("");
|
||||
const [lentOn, setLentOn] = React.useState(todayISO());
|
||||
const [dueOn, setDueOn] = React.useState("");
|
||||
const [note, setNote] = React.useState("");
|
||||
|
||||
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
|
||||
|
||||
return (
|
||||
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
|
||||
<BottomSheetBackdrop />
|
||||
<BottomSheetPositioner>
|
||||
<BottomSheetContent>
|
||||
<BottomSheetHeader>
|
||||
<BottomSheetTitle>{s.lending.add}</BottomSheetTitle>
|
||||
</BottomSheetHeader>
|
||||
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<TextFieldRoot value={person} onValueChange={setPerson} name="loan-person">
|
||||
<TextFieldInput placeholder={s.lending.fields.person} aria-label={s.lending.fields.person} autoFocus />
|
||||
</TextFieldRoot>
|
||||
<TextFieldRoot value={amount} onValueChange={setAmount} name="loan-amount">
|
||||
<TextFieldInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={s.lending.fields.amount}
|
||||
aria-label={s.lending.fields.amount}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
|
||||
{s.lending.fields.lentOn}
|
||||
<input type="date" value={lentOn} onChange={(e) => setLentOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
|
||||
</label>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
|
||||
{s.lending.fields.dueOn}
|
||||
<input type="date" value={dueOn} onChange={(e) => setDueOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
|
||||
</label>
|
||||
<TextFieldRoot value={note} onValueChange={setNote} name="loan-note">
|
||||
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
|
||||
</TextFieldRoot>
|
||||
</BottomSheetBody>
|
||||
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
|
||||
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
|
||||
{s.common.cancel}
|
||||
</MercuryButton>
|
||||
<MercuryButton
|
||||
variant="primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!canSave}
|
||||
loading={saving}
|
||||
onClick={() =>
|
||||
onSave({
|
||||
person: person.trim(),
|
||||
amount,
|
||||
lentOn,
|
||||
dueOn: dueOn || undefined,
|
||||
note: note.trim() || undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
{s.common.save}
|
||||
</MercuryButton>
|
||||
</BottomSheetFooter>
|
||||
</BottomSheetContent>
|
||||
</BottomSheetPositioner>
|
||||
</BottomSheetRoot>
|
||||
);
|
||||
}
|
||||
74
src/features/assets/ConfirmDialog.tsx
Normal file
74
src/features/assets/ConfirmDialog.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"use client";
|
||||
|
||||
import {
|
||||
DialogRoot,
|
||||
DialogBackdrop,
|
||||
DialogPositioner,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@seed-design/react";
|
||||
import { MercuryButton } from "@/ds";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
|
||||
export interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description?: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
busy?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A confirmation dialog built on Seed's `Dialog` primitives — used for every
|
||||
* destructive/irreversible action in the assets + lending feature (delete,
|
||||
* revalue) instead of a native `window.confirm`, per Mercury's UI
|
||||
* conventions (mirrors iOS's `seedDialog`).
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
confirmLabel = s.common.delete,
|
||||
destructive = true,
|
||||
busy = false,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<DialogRoot
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogBackdrop />
|
||||
<DialogPositioner>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
{description && <DialogDescription>{description}</DialogDescription>}
|
||||
<DialogFooter style={{ display: "flex", gap: 12, paddingTop: 16 }}>
|
||||
<MercuryButton variant="secondary" onClick={onCancel} style={{ flex: 1 }} disabled={busy}>
|
||||
{s.common.cancel}
|
||||
</MercuryButton>
|
||||
<MercuryButton
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
loading={busy}
|
||||
style={{ flex: 1, ...(destructive ? { background: "var(--seed-color-fg-critical)", color: "#fff" } : {}) }}
|
||||
>
|
||||
{confirmLabel}
|
||||
</MercuryButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</DialogPositioner>
|
||||
</DialogRoot>
|
||||
);
|
||||
}
|
||||
260
src/features/assets/LendingDetail.tsx
Normal file
260
src/features/assets/LendingDetail.tsx
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
BottomSheetRoot,
|
||||
BottomSheetBackdrop,
|
||||
BottomSheetPositioner,
|
||||
BottomSheetContent,
|
||||
BottomSheetHeader,
|
||||
BottomSheetTitle,
|
||||
BottomSheetBody,
|
||||
BottomSheetFooter,
|
||||
TextFieldRoot,
|
||||
TextFieldInput,
|
||||
Skeleton,
|
||||
} from "@seed-design/react";
|
||||
import { useLending } from "@/api/hooks/reads";
|
||||
import { useLendingMutations } from "@/api/hooks/mutations";
|
||||
import type { Lending } from "@/api/schemas";
|
||||
import { Card, MercuryButton } from "@/ds";
|
||||
import { tugrikRaw } from "@/ds/money";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
type LendingRepayment = Lending["repayments"][number];
|
||||
|
||||
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function statusLabel(loan: Lending): string {
|
||||
if (loan.overdue) return s.lending.statusOverdue;
|
||||
switch (loan.status) {
|
||||
case "repaid":
|
||||
return s.lending.statusPaid;
|
||||
case "partial":
|
||||
return s.lending.statusPartial;
|
||||
default:
|
||||
return s.lending.statusUnpaid;
|
||||
}
|
||||
}
|
||||
|
||||
export interface LendingDetailProps {
|
||||
id: number;
|
||||
}
|
||||
|
||||
export function LendingDetail({ id }: LendingDetailProps) {
|
||||
const router = useRouter();
|
||||
const lending = useLending();
|
||||
const mutations = useLendingMutations();
|
||||
|
||||
const [addRepaymentOpen, setAddRepaymentOpen] = React.useState(false);
|
||||
const [deletingRepayment, setDeletingRepayment] = React.useState<LendingRepayment | null>(null);
|
||||
const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false);
|
||||
|
||||
const loan: Lending | undefined = (lending.data ?? []).find((l) => l.id === id);
|
||||
|
||||
if (lending.isLoading && !loan) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Skeleton height="40px" />
|
||||
<Skeleton height="140px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!loan) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<BackLink />
|
||||
<p style={mutedStyle}>{s.lending.empty}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function runDeleteEntry() {
|
||||
setConfirmDeleteEntry(false);
|
||||
await mutations.delete.mutateAsync(id);
|
||||
router.push("/assets");
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<BackLink />
|
||||
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{loan.person}</h1>
|
||||
</header>
|
||||
|
||||
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
|
||||
<div style={{ fontSize: 32, fontWeight: 700 }}>{tugrikRaw(loan.remaining)}</div>
|
||||
<div style={{ marginTop: 6, ...mutedStyle }}>
|
||||
{s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "inline-block",
|
||||
marginTop: 10,
|
||||
padding: "5px 12px",
|
||||
borderRadius: 999,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
background: "var(--seed-color-bg-layer-floating)",
|
||||
color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)",
|
||||
}}
|
||||
>
|
||||
{statusLabel(loan)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div style={{ display: "flex", justifyContent: "space-between", marginBottom: 12 }}>
|
||||
<h3 style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.lending.repayments.title}</h3>
|
||||
<strong style={{ color: "var(--seed-color-fg-positive)" }}>{tugrikRaw(loan.repaid)}</strong>
|
||||
</div>
|
||||
{loan.repayments.length === 0 ? (
|
||||
<p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.lending.repayments.empty}</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
{loan.repayments.map((r, i) => (
|
||||
<React.Fragment key={r.id}>
|
||||
{i > 0 && <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "10px 0" }}>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{tugrikRaw(r.amount)}</div>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>
|
||||
{r.paidOn}
|
||||
{r.note ? ` · ${r.note}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeletingRepayment(r)}
|
||||
aria-label={s.lending.repayments.deleteTitle}
|
||||
style={{ background: "none", border: "none", cursor: "pointer", fontSize: 16, color: "var(--seed-color-fg-neutral-subtle)" }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<MercuryButton variant="primary" onClick={() => setAddRepaymentOpen(true)}>
|
||||
{s.lending.repayments.add}
|
||||
</MercuryButton>
|
||||
<MercuryButton variant="ghost" onClick={() => setConfirmDeleteEntry(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
|
||||
{s.lending.delete}
|
||||
</MercuryButton>
|
||||
</div>
|
||||
|
||||
{addRepaymentOpen && (
|
||||
<AddRepaymentSheet
|
||||
onClose={() => setAddRepaymentOpen(false)}
|
||||
saving={mutations.addRepayment.isPending}
|
||||
onSave={async (values) => {
|
||||
await mutations.addRepayment.mutateAsync({ id, ...values });
|
||||
setAddRepaymentOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={deletingRepayment !== null}
|
||||
title={s.lending.repayments.deleteTitle}
|
||||
description={s.lending.repayments.deleteDescription}
|
||||
busy={mutations.deleteRepayment.isPending}
|
||||
onCancel={() => setDeletingRepayment(null)}
|
||||
onConfirm={async () => {
|
||||
if (!deletingRepayment) return;
|
||||
await mutations.deleteRepayment.mutateAsync({ id, repaymentId: deletingRepayment.id });
|
||||
setDeletingRepayment(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmDeleteEntry}
|
||||
title={s.lending.deleteTitle}
|
||||
description={s.lending.deleteDescription(loan.person)}
|
||||
busy={mutations.delete.isPending}
|
||||
onCancel={() => setConfirmDeleteEntry(false)}
|
||||
onConfirm={runDeleteEntry}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
|
||||
← {s.common.back}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function AddRepaymentSheet({
|
||||
onClose,
|
||||
onSave,
|
||||
saving,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onSave: (values: { amount: string; paidOn: string; note?: string }) => void | Promise<void>;
|
||||
saving: boolean;
|
||||
}) {
|
||||
const [amount, setAmount] = React.useState("");
|
||||
const [paidOn, setPaidOn] = React.useState(todayISO());
|
||||
const [note, setNote] = React.useState("");
|
||||
|
||||
const canSave = Number(amount) > 0 && !saving;
|
||||
|
||||
return (
|
||||
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
|
||||
<BottomSheetBackdrop />
|
||||
<BottomSheetPositioner>
|
||||
<BottomSheetContent>
|
||||
<BottomSheetHeader>
|
||||
<BottomSheetTitle>{s.lending.repayments.add}</BottomSheetTitle>
|
||||
</BottomSheetHeader>
|
||||
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
|
||||
<TextFieldRoot value={amount} onValueChange={setAmount} name="repayment-amount">
|
||||
<TextFieldInput
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
placeholder={s.lending.fields.amount}
|
||||
aria-label={s.lending.fields.amount}
|
||||
autoFocus
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
|
||||
{s.lending.fields.lentOn}
|
||||
<input type="date" value={paidOn} onChange={(e) => setPaidOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
|
||||
</label>
|
||||
<TextFieldRoot value={note} onValueChange={setNote} name="repayment-note">
|
||||
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
|
||||
</TextFieldRoot>
|
||||
</BottomSheetBody>
|
||||
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
|
||||
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
|
||||
{s.common.cancel}
|
||||
</MercuryButton>
|
||||
<MercuryButton
|
||||
variant="primary"
|
||||
style={{ flex: 1 }}
|
||||
disabled={!canSave}
|
||||
loading={saving}
|
||||
onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined })}
|
||||
>
|
||||
{s.common.save}
|
||||
</MercuryButton>
|
||||
</BottomSheetFooter>
|
||||
</BottomSheetContent>
|
||||
</BottomSheetPositioner>
|
||||
</BottomSheetRoot>
|
||||
);
|
||||
}
|
||||
262
src/features/assets/ManualAssetDetail.tsx
Normal file
262
src/features/assets/ManualAssetDetail.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
import { Skeleton } from "@seed-design/react";
|
||||
import { apiGet } from "@/api/client";
|
||||
import { useManualAssets } from "@/api/hooks/reads";
|
||||
import { useManualAssetMutations } from "@/api/hooks/mutations";
|
||||
import { AssetPointSchema, AssetListingSchema, type ManualAsset, type RevalueResult } from "@/api/schemas";
|
||||
import { Card, MercuryButton } from "@/ds";
|
||||
import { tugrikRaw, tugrikShortRaw } from "@/ds/money";
|
||||
import { assetsStrings as s } from "./strings";
|
||||
import { ValueHistoryChart } from "./ValueHistoryChart";
|
||||
import { ConfirmDialog } from "./ConfirmDialog";
|
||||
|
||||
// --- Local reads (no hook exists yet for asset history/listings; these mirror
|
||||
// the shape of reads.ts's other hooks and hit the same endpoints iOS uses:
|
||||
// GET /manual-assets/{name}/history and /listings — see MercuryAPI.swift). ---
|
||||
|
||||
const HistoryResponseSchema = z.object({ history: z.array(AssetPointSchema) });
|
||||
const ListingsResponseSchema = z.object({ listings: z.array(AssetListingSchema) });
|
||||
|
||||
function useAssetHistory(name: string) {
|
||||
return useQuery({
|
||||
queryKey: ["assetHistory", name],
|
||||
queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/history`, HistoryResponseSchema)).history,
|
||||
});
|
||||
}
|
||||
|
||||
function useAssetListings(name: string) {
|
||||
return useQuery({
|
||||
queryKey: ["assetListings", name],
|
||||
queryFn: async () => (await apiGet(`/manual-assets/${encodeURIComponent(name)}/listings`, ListingsResponseSchema)).listings,
|
||||
});
|
||||
}
|
||||
|
||||
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
||||
|
||||
export interface ManualAssetDetailProps {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function ManualAssetDetail({ name }: ManualAssetDetailProps) {
|
||||
const router = useRouter();
|
||||
const manualAssets = useManualAssets();
|
||||
const history = useAssetHistory(name);
|
||||
const listings = useAssetListings(name);
|
||||
const mutations = useManualAssetMutations();
|
||||
|
||||
const [confirmRevalue, setConfirmRevalue] = React.useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = React.useState(false);
|
||||
const [lastFinding, setLastFinding] = React.useState<RevalueResult | null>(null);
|
||||
const [revalueFailed, setRevalueFailed] = React.useState(false);
|
||||
|
||||
const asset: ManualAsset | undefined = (manualAssets.data ?? []).find((a) => a.name === name);
|
||||
|
||||
async function runRevalue() {
|
||||
setConfirmRevalue(false);
|
||||
setRevalueFailed(false);
|
||||
const result = await mutations.revalue.mutateAsync(name);
|
||||
setLastFinding(result ?? null);
|
||||
setRevalueFailed(!result);
|
||||
await Promise.all([history.refetch(), listings.refetch()]);
|
||||
}
|
||||
|
||||
async function runDelete() {
|
||||
setConfirmDelete(false);
|
||||
await mutations.delete.mutateAsync(name);
|
||||
router.push("/assets");
|
||||
}
|
||||
|
||||
if (manualAssets.isLoading && !asset) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<Skeleton height="40px" />
|
||||
<Skeleton height="160px" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!asset) {
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||
<BackLink />
|
||||
<p style={mutedStyle}>{name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const value = Number(asset.value || "0");
|
||||
const acquired = Number(asset.acquiredValue || "0");
|
||||
const change = Number(asset.change || "0");
|
||||
const positive = change >= 0;
|
||||
const percent = acquired > 0 ? (change / acquired) * 100 : null;
|
||||
|
||||
const points = (history.data ?? [])
|
||||
.map((p) => ({ date: new Date(p.recordedAt), value: Number(p.value) }))
|
||||
.filter((p) => !Number.isNaN(p.date.getTime()) && !Number.isNaN(p.value))
|
||||
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||
|
||||
const fetchPoints = (history.data ?? [])
|
||||
.filter((p) => p.kind === "fetch")
|
||||
.slice()
|
||||
.sort((a, b) => (a.recordedAt < b.recordedAt ? 1 : -1));
|
||||
|
||||
const listingRows = listings.data ?? [];
|
||||
const categoryLabel = s.manualAssets.categories[asset.category] ?? asset.category;
|
||||
const conditionLabel = s.manualAssets.conditions[asset.condition] ?? asset.condition;
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||
<header style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
||||
<BackLink />
|
||||
<h1 style={{ margin: 0, fontSize: 18, fontWeight: 700, flex: 1 }}>{asset.name}</h1>
|
||||
</header>
|
||||
|
||||
<Card style={{ textAlign: "center", padding: "24px 20px" }}>
|
||||
<div style={{ fontSize: 12, ...mutedStyle }}>{categoryLabel} · {conditionLabel}</div>
|
||||
<div style={{ fontSize: 32, fontWeight: 700, marginTop: 8 }}>{tugrikRaw(value)}</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 6,
|
||||
fontWeight: 600,
|
||||
color: positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
|
||||
}}
|
||||
>
|
||||
{positive ? "+" : "−"}
|
||||
{tugrikRaw(Math.abs(change))}
|
||||
{percent !== null && ` (${positive ? "+" : "−"}${Math.abs(percent).toFixed(1)}%)`}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{lastFinding && (
|
||||
<Card style={{ background: "color-mix(in srgb, var(--seed-color-fg-positive) 10%, transparent)" }}>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
{lastFinding.source} — {lastFinding.count} {s.assetDetail.findingSuffix}
|
||||
</div>
|
||||
<div style={{ fontSize: 13, marginTop: 4 }}>
|
||||
{tugrikShortRaw(lastFinding.low)}–{tugrikShortRaw(lastFinding.high)} · {s.assetDetail.avg}{" "}
|
||||
<strong>{tugrikShortRaw(lastFinding.value)}</strong>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
{revalueFailed && !lastFinding && (
|
||||
<Card>
|
||||
<div style={{ fontWeight: 600 }}>{s.assetDetail.notFound}</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.chartTitle}</h3>
|
||||
{history.isLoading ? <Skeleton height="160px" /> : <ValueHistoryChart points={points} positive={positive} />}
|
||||
</Card>
|
||||
|
||||
{!history.isLoading && (
|
||||
<Card>
|
||||
<h3 style={{ margin: "0 0 12px", fontSize: 14, fontWeight: 700 }}>{s.assetDetail.research}</h3>
|
||||
{fetchPoints.length > 0 && (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 10, marginBottom: 12 }}>
|
||||
{fetchPoints.map((p, i) => (
|
||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", fontSize: 13 }}>
|
||||
<span>{p.recordedAt.slice(0, 10)}</span>
|
||||
<span style={{ ...mutedStyle }}>
|
||||
{p.low && p.high ? `${tugrikShortRaw(p.low)}–${tugrikShortRaw(p.high)} · ` : ""}
|
||||
{p.count ?? 0} зар
|
||||
</span>
|
||||
<strong>{tugrikShortRaw(p.value)}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<h4 style={{ margin: "0 0 8px", fontSize: 13, fontWeight: 700 }}>{s.assetDetail.listings(listingRows.length)}</h4>
|
||||
{listingRows.length === 0 ? (
|
||||
<p style={{ fontSize: 12, ...mutedStyle, margin: 0 }}>{s.assetDetail.listingsEmpty}</p>
|
||||
) : (
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{listingRows.slice(0, 15).map((l, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={l.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{ display: "flex", justifyContent: "space-between", gap: 10, color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<span style={{ fontSize: 13, flex: 1 }}>{l.title}</span>
|
||||
<strong style={{ fontSize: 13, whiteSpace: "nowrap" }}>{tugrikShortRaw(l.price)}</strong>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card style={{ padding: "6px 20px" }}>
|
||||
<DetailRow label={s.assetDetail.acquiredValue} value={tugrikRaw(acquired)} />
|
||||
<Divider />
|
||||
<DetailRow label={s.assetDetail.currentValue} value={tugrikRaw(value)} />
|
||||
<Divider />
|
||||
<DetailRow
|
||||
label={s.assetDetail.change}
|
||||
value={`${positive ? "+" : "−"}${tugrikRaw(Math.abs(change))}`}
|
||||
color={positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)"}
|
||||
/>
|
||||
<Divider />
|
||||
<DetailRow label={s.assetDetail.category} value={categoryLabel} />
|
||||
<Divider />
|
||||
<DetailRow label={s.assetDetail.condition} value={conditionLabel} />
|
||||
</Card>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<MercuryButton variant="primary" onClick={() => setConfirmRevalue(true)} disabled={mutations.revalue.isPending}>
|
||||
{s.manualAssets.revalue}
|
||||
</MercuryButton>
|
||||
<MercuryButton variant="ghost" onClick={() => setConfirmDelete(true)} style={{ color: "var(--seed-color-fg-critical)" }}>
|
||||
{s.manualAssets.delete}
|
||||
</MercuryButton>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={confirmRevalue}
|
||||
title={s.manualAssets.revalueTitle}
|
||||
description={s.manualAssets.revalueDescription}
|
||||
confirmLabel={s.manualAssets.revalue}
|
||||
destructive={false}
|
||||
busy={mutations.revalue.isPending}
|
||||
onCancel={() => setConfirmRevalue(false)}
|
||||
onConfirm={runRevalue}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
title={s.manualAssets.deleteTitle}
|
||||
description={s.manualAssets.deleteDescription(asset.name)}
|
||||
busy={mutations.delete.isPending}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
onConfirm={runDelete}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink() {
|
||||
return (
|
||||
<a href="/assets" style={{ textDecoration: "none", color: "inherit", fontWeight: 600 }}>
|
||||
← {s.common.back}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value, color }: { label: string; value: string; color?: string }) {
|
||||
return (
|
||||
<div style={{ display: "flex", justifyContent: "space-between", padding: "14px 0" }}>
|
||||
<span style={mutedStyle}>{label}</span>
|
||||
<span style={{ fontWeight: 600, color }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Divider() {
|
||||
return <div style={{ height: 1, background: "var(--seed-color-border-neutral, #e5e5e5)" }} />;
|
||||
}
|
||||
84
src/features/assets/ValueHistoryChart.tsx
Normal file
84
src/features/assets/ValueHistoryChart.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { assetsStrings as s } from "./strings";
|
||||
|
||||
export interface ValueHistoryPoint {
|
||||
date: Date;
|
||||
value: number;
|
||||
}
|
||||
|
||||
export interface ValueHistoryChartProps {
|
||||
points: ValueHistoryPoint[];
|
||||
positive: boolean;
|
||||
}
|
||||
|
||||
const WIDTH = 320;
|
||||
const HEIGHT = 160;
|
||||
const PAD_X = 6;
|
||||
const PAD_Y = 12;
|
||||
|
||||
/**
|
||||
* A minimal, dependency-light line chart (inline SVG, no charting library)
|
||||
* mirroring the value-over-time chart in
|
||||
* ios/Mercury/Features/Assets/AssetDetailView.swift (`chart` / `chartCard`):
|
||||
* a catmull-rom-ish straight-segment line through ascending (date, value)
|
||||
* points, y-padded ~8%, with a dot per point. Below 2 points it falls back
|
||||
* to the same empty-state copy as iOS.
|
||||
*/
|
||||
export function ValueHistoryChart({ points, positive }: ValueHistoryChartProps) {
|
||||
if (points.length < 2) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: HEIGHT,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
gap: 6,
|
||||
textAlign: "center",
|
||||
}}
|
||||
>
|
||||
<p style={{ margin: 0, fontWeight: 600 }}>{s.assetDetail.chartEmptyTitle}</p>
|
||||
<p style={{ margin: 0, fontSize: 12, color: "var(--seed-color-fg-neutral-subtle)" }}>
|
||||
{s.assetDetail.chartEmptySubtitle}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const values = points.map((p) => p.value);
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
const pad = Math.max((hi - lo) * 0.08, hi * 0.02, 1);
|
||||
const yMin = lo - pad;
|
||||
const yMax = hi + pad;
|
||||
|
||||
const minTime = points[0].date.getTime();
|
||||
const maxTime = points[points.length - 1].date.getTime();
|
||||
const timeSpan = Math.max(maxTime - minTime, 1);
|
||||
|
||||
const xFor = (t: number) => PAD_X + ((t - minTime) / timeSpan) * (WIDTH - PAD_X * 2);
|
||||
const yFor = (v: number) => HEIGHT - PAD_Y - ((v - yMin) / (yMax - yMin)) * (HEIGHT - PAD_Y * 2);
|
||||
|
||||
const linePath = points
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"} ${xFor(p.date.getTime()).toFixed(1)} ${yFor(p.value).toFixed(1)}`)
|
||||
.join(" ");
|
||||
|
||||
const lineColor = positive ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)";
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
||||
width="100%"
|
||||
height={HEIGHT}
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
aria-label={s.assetDetail.chartTitle}
|
||||
style={{ display: "block", overflow: "visible" }}
|
||||
>
|
||||
<path d={linePath} fill="none" stroke={lineColor} strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" />
|
||||
{points.map((p, i) => (
|
||||
<circle key={i} cx={xFor(p.date.getTime())} cy={yFor(p.value)} r={3} fill="var(--mercury-brand-yellow)" />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
106
src/features/assets/strings.ts
Normal file
106
src/features/assets/strings.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
// Assets + Lending feature copy, ported from
|
||||
// ios/Mercury/Features/Assets/*.swift and ios/Mercury/Features/Lending/*.swift.
|
||||
// The net-worth header has no direct iOS analogue (there it only backs the
|
||||
// Home balance card) — labels there use standard Mongolian finance terms.
|
||||
export const assetsStrings = {
|
||||
header: {
|
||||
title: "Хөрөнгө",
|
||||
},
|
||||
netWorth: {
|
||||
total: "Цэвэр хөрөнгө",
|
||||
assets: "Хөрөнгө",
|
||||
liabilities: "Өр төлбөр",
|
||||
},
|
||||
accounts: {
|
||||
title: "Миний дансууд",
|
||||
empty: "Холбосон данс алга",
|
||||
},
|
||||
manualAssets: {
|
||||
title: "Хөрөнгийн жагсаалт",
|
||||
totalLabel: "Нийт хөрөнгийн үнэлгээ",
|
||||
add: "Хөрөнгө нэмэх",
|
||||
edit: "Хөрөнгө засах",
|
||||
emptyTitle: "Хөрөнгө бүртгээгүй байна",
|
||||
emptySubtitle: "Машин, утас зэрэг хөрөнгөө нэмэхийн тулд + дарна уу",
|
||||
acquiredPrefix: "Авсан",
|
||||
revalue: "Зах зээлийн үнэ шинэчлэх",
|
||||
revalueTitle: "Зах зээлийн үнэ шинэчлэх үү?",
|
||||
revalueDescription: "Зар хайж, хамгийн сүүлийн зах зээлийн үнийг тооцно.",
|
||||
delete: "Устгах",
|
||||
deleteTitle: "Хөрөнгө устгах уу?",
|
||||
deleteDescription: (name: string) => `«${name}» жагсаалтаас хасагдана.`,
|
||||
fields: {
|
||||
name: "Хөрөнгийн нэр",
|
||||
category: "Ангилал",
|
||||
condition: "Байдал",
|
||||
price: "Авсан үнэ",
|
||||
},
|
||||
categories: {
|
||||
car: "Машин",
|
||||
electronics: "Электрон",
|
||||
property: "Үл хөдлөх",
|
||||
other: "Бус",
|
||||
} as Record<string, string>,
|
||||
conditions: {
|
||||
new: "Шинэ",
|
||||
used: "Хуучин",
|
||||
} as Record<string, string>,
|
||||
},
|
||||
assetDetail: {
|
||||
acquiredValue: "Авсан үнэ",
|
||||
currentValue: "Одоогийн үнэ",
|
||||
change: "Өөрчлөлт",
|
||||
category: "Ангилал",
|
||||
condition: "Нөхцөл",
|
||||
chartTitle: "Үнийн түүх",
|
||||
chartEmptyTitle: "Үнийн түүх хомс",
|
||||
chartEmptySubtitle: "Зах зээлийн үнэ шинэчлэх бүрт нэг цэг нэмэгдэнэ",
|
||||
research: "Зах зээлийн судалгаа",
|
||||
listings: (count: number) => `Зар (${count})`,
|
||||
listingsEmpty: "Зар олдоогүй — үнэ шинэчилнэ үү",
|
||||
findingSuffix: "зар олдлоо",
|
||||
avg: "дунджаар",
|
||||
notFound: "Зах зээлийн үнэ олдсонгүй",
|
||||
},
|
||||
lending: {
|
||||
title: "Найзуудад өгсөн зээл",
|
||||
empty: "Зээл бүртгэгдээгүй байна",
|
||||
add: "Шинэ зээл",
|
||||
total: "нийт",
|
||||
statusPaid: "Төлсөн",
|
||||
statusPartial: "Хэсэгчлэн төлсөн",
|
||||
statusUnpaid: "Төлөөгүй",
|
||||
statusOverdue: "Хугацаа хэтэрсэн",
|
||||
deleteTitle: "Зээл устгах уу?",
|
||||
deleteDescription: (person: string) => `«${person}»-д өгсөн зээл устана.`,
|
||||
delete: "Зээл устгах",
|
||||
fields: {
|
||||
person: "Хэнд өгсөн",
|
||||
amount: "Дүн",
|
||||
lentOn: "Өгсөн огноо",
|
||||
dueOn: "Төлөх огноо",
|
||||
note: "Тэмдэглэл",
|
||||
},
|
||||
repayments: {
|
||||
title: "Төлөлтийн түүх",
|
||||
empty: "Төлөлт бүртгэгдээгүй",
|
||||
add: "Төлөлт нэмэх",
|
||||
deleteTitle: "Төлөлт устгах уу?",
|
||||
deleteDescription: "Энэ төлөлтийг түүхээс хасна.",
|
||||
},
|
||||
},
|
||||
accountDetail: {
|
||||
balance: "Үлдэгдэл",
|
||||
accountNumber: "Дансны дугаар",
|
||||
bank: "Банк",
|
||||
currency: "Валют",
|
||||
recentTransactions: "Сүүлийн гүйлгээ",
|
||||
noTransactions: "Гүйлгээ алга",
|
||||
},
|
||||
common: {
|
||||
save: "Хадгалах",
|
||||
cancel: "Болих",
|
||||
delete: "Устгах",
|
||||
back: "Буцах",
|
||||
},
|
||||
} as const;
|
||||
24
src/features/assets/useHideAmountsTick.ts
Normal file
24
src/features/assets/useHideAmountsTick.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { HIDE_AMOUNTS_EVENT } from "@/ds";
|
||||
|
||||
/**
|
||||
* `tugrik()` / `tugrikShort()` (web/src/ds/money.ts) read the global
|
||||
* hide-amounts flag from localStorage synchronously, so a component that
|
||||
* calls them needs a reason to re-render when `HideAmountsToggle` flips it.
|
||||
* Call this in any component that formats money with those helpers.
|
||||
*/
|
||||
export function useHideAmountsTick(): number {
|
||||
const [tick, setTick] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
function onChange() {
|
||||
setTick((t) => t + 1);
|
||||
}
|
||||
window.addEventListener(HIDE_AMOUNTS_EVENT, onChange);
|
||||
return () => window.removeEventListener(HIDE_AMOUNTS_EVENT, onChange);
|
||||
}, []);
|
||||
|
||||
return tick;
|
||||
}
|
||||
|
|
@ -247,7 +247,7 @@ export function AuthForm({
|
|||
function FormError({ message }: { message?: string }) {
|
||||
if (!message) return null;
|
||||
return (
|
||||
<p role="alert" className="text-sm" style={{ color: "#d92626" }}>
|
||||
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
|
||||
{message}
|
||||
</p>
|
||||
);
|
||||
|
|
|
|||
225
src/features/home/DashboardView.tsx
Normal file
225
src/features/home/DashboardView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/features/home/buildHome.test.ts
Normal file
97
src/features/home/buildHome.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
128
src/features/home/buildHome.ts
Normal file
128
src/features/home/buildHome.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
46
src/features/home/dashboard.msw.test.tsx
Normal file
46
src/features/home/dashboard.msw.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
25
src/features/home/sample.ts
Normal file
25
src/features/home/sample.ts
Normal 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,
|
||||
};
|
||||
16
src/features/home/strings.ts
Normal file
16
src/features/home/strings.ts
Normal 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;
|
||||
103
src/features/planner/CategoryTransactions.tsx
Normal file
103
src/features/planner/CategoryTransactions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/features/planner/PlannerView.test.tsx
Normal file
55
src/features/planner/PlannerView.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
821
src/features/planner/PlannerView.tsx
Normal file
821
src/features/planner/PlannerView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
src/features/planner/strings.ts
Normal file
66
src/features/planner/strings.ts
Normal 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;
|
||||
388
src/features/profile/CategoriesManager.tsx
Normal file
388
src/features/profile/CategoriesManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
78
src/features/profile/ConnectedBanks.tsx
Normal file
78
src/features/profile/ConnectedBanks.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
124
src/features/profile/ProfileView.tsx
Normal file
124
src/features/profile/ProfileView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
46
src/features/profile/SettingsForm.test.tsx
Normal file
46
src/features/profile/SettingsForm.test.tsx
Normal 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();
|
||||
});
|
||||
217
src/features/profile/SettingsForm.tsx
Normal file
217
src/features/profile/SettingsForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
261
src/features/profile/SubscriptionsView.tsx
Normal file
261
src/features/profile/SubscriptionsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
src/features/profile/strings.ts
Normal file
88
src/features/profile/strings.ts
Normal 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;
|
||||
|
|
@ -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 };
|
||||
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 }] };
|
||||
|
||||
// --- 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
42
src/test/handlers.ts
Normal 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
10
src/test/server.ts
Normal 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);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "vitest/config";
|
||||
import { configDefaults, defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
|
|
@ -16,5 +16,9 @@ export default defineConfig({
|
|||
setupFiles: ["./vitest.setup.ts"],
|
||||
globals: true,
|
||||
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/**"],
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue