Compare commits
No commits in common. "6913c1e5ee35e7a294482e9f18693df92ca80c12" and "2555e42f2151b13fd4d4dfbf02dbd6065d6bcf0c" have entirely different histories.
6913c1e5ee
...
2555e42f21
58 changed files with 9 additions and 5426 deletions
|
|
@ -1,101 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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,13 +17,7 @@ async function req(method: string, path: string, body?: unknown): Promise<unknow
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
});
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
// Clear the (now-stale) session cookie before redirecting. Otherwise the
|
if (typeof window !== "undefined") window.location.href = "/login";
|
||||||
// middleware bounces /login → /home on mere cookie presence, and a
|
|
||||||
// server-invalidated-but-not-expired session produces an infinite loop.
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }).catch(() => {});
|
|
||||||
window.location.href = "/login";
|
|
||||||
}
|
|
||||||
throw new ApiError(401, "unauthorized");
|
throw new ApiError(401, "unauthorized");
|
||||||
}
|
}
|
||||||
if (!res.ok) throw new ApiError(res.status, await res.text());
|
if (!res.ok) throw new ApiError(res.status, await res.text());
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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 />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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)} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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)} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
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)} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
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,5 +1,6 @@
|
||||||
import { DashboardView } from "@/features/home/DashboardView";
|
import { t } from "@/i18n/common";
|
||||||
|
|
||||||
|
// Placeholder — Task 9 replaces this with the real dashboard.
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
return <DashboardView />;
|
return <h1 style={{ fontWeight: 700 }}>{t.tabs.home}</h1>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ export default function AppLayout({ children }: { children: ReactNode }) {
|
||||||
<AppQueryProvider>
|
<AppQueryProvider>
|
||||||
<div className="md:flex md:min-h-screen">
|
<div className="md:flex md:min-h-screen">
|
||||||
<AppNav />
|
<AppNav />
|
||||||
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-24 pt-6 md:max-w-4xl md:px-8 md:pb-10 md:pt-10 lg:max-w-6xl lg:px-12">
|
<main className="mx-auto w-full max-w-2xl flex-1 px-4 pb-24 pt-6 md:pb-6 md:pt-8">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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)} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { PlannerView } from "@/features/planner/PlannerView";
|
|
||||||
|
|
||||||
export default function PlannerPage() {
|
|
||||||
return <PlannerView />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
"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")} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { ProfileView } from "@/features/profile/ProfileView";
|
|
||||||
|
|
||||||
export default function ProfilePage() {
|
|
||||||
return <ProfileView />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
"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,9 +6,5 @@
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
font-family: var(--mercury-font);
|
font-family: var(--mercury-font);
|
||||||
/* Canonical Seed setup (matches daangn.com): body carries the neutral
|
|
||||||
foreground + default layer background so all default text is legible.
|
|
||||||
Without an explicit color, default text resolved to white-on-white. */
|
|
||||||
color: var(--seed-color-fg-neutral, #1a1c20);
|
|
||||||
background: var(--seed-color-bg-layer-default, #fff);
|
background: var(--seed-color-bg-layer-default, #fff);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ export const metadata = { title: "Mercury" };
|
||||||
|
|
||||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="mn" data-seed data-seed-color-mode="light-only">
|
<html lang="mn">
|
||||||
<body>{children}</body>
|
<body>{children}</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel, SwitchHiddenInput } from "@seed-design/react";
|
import { SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel } from "@seed-design/react";
|
||||||
import { isHidden, setHidden } from "./money";
|
import { isHidden, setHidden } from "./money";
|
||||||
|
|
||||||
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
|
/** Dispatched on `window` whenever the global hide-amounts flag changes, so
|
||||||
|
|
@ -25,12 +25,6 @@ export function HideAmountsToggle({ label = "Мөнгөн дүн нуух" }: Hi
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwitchRoot checked={checked} onCheckedChange={handleChange}>
|
<SwitchRoot checked={checked} onCheckedChange={handleChange}>
|
||||||
{/* The actual interactive/accessible element (role="switch",
|
|
||||||
checked/onChange) lives on the hidden input — SwitchControl and
|
|
||||||
SwitchThumb are purely decorative (aria-hidden). Without this the
|
|
||||||
switch renders but nothing is clickable or announced to
|
|
||||||
assistive tech. */}
|
|
||||||
<SwitchHiddenInput />
|
|
||||||
<SwitchControl>
|
<SwitchControl>
|
||||||
<SwitchThumb />
|
<SwitchThumb />
|
||||||
</SwitchControl>
|
</SwitchControl>
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,321 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,174 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
@ -1,34 +0,0 @@
|
||||||
"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;
|
|
||||||
}
|
|
||||||
|
|
@ -1,104 +0,0 @@
|
||||||
"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)" }} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,582 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,74 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,260 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,262 +0,0 @@
|
||||||
"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)" }} />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"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 }) {
|
function FormError({ message }: { message?: string }) {
|
||||||
if (!message) return null;
|
if (!message) return null;
|
||||||
return (
|
return (
|
||||||
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)" }}>
|
<p role="alert" className="text-sm" style={{ color: "#d92626" }}>
|
||||||
{message}
|
{message}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,225 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,128 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,103 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,821 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
// 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;
|
|
||||||
|
|
@ -1,388 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,78 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,124 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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();
|
|
||||||
});
|
|
||||||
|
|
@ -1,217 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,261 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,88 +0,0 @@
|
||||||
// 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,31 +12,3 @@ export const txn = { date: "2026-08-01", amount: "52000", direction: "debit", ca
|
||||||
balanceAfter: "400200", accountId: 1, transfer: false, txnId: 10, note: "", matchKey: "mk1", salary: false };
|
balanceAfter: "400200", accountId: 1, transfer: false, txnId: 10, note: "", matchKey: "mk1", salary: false };
|
||||||
export const lending = { id: 1, person: "Бат", principal: "100000", lentOn: "2026-07-01", dueOn: "2026-09-01", note: "",
|
export const lending = { id: 1, person: "Бат", principal: "100000", lentOn: "2026-07-01", dueOn: "2026-09-01", note: "",
|
||||||
repaid: "40000", remaining: "60000", status: "partial", overdue: false, txnId: null, repayments: [{ id: 1, amount: "40000", paidOn: "2026-08-01", note: "", txnId: null }] };
|
repaid: "40000", remaining: "60000", status: "partial", overdue: false, txnId: null, repayments: [{ id: 1, amount: "40000", paidOn: "2026-08-01", note: "", txnId: null }] };
|
||||||
|
|
||||||
// --- Task 14: fixtures for the remaining read endpoints, so the MSW handler
|
|
||||||
// set (src/test/handlers.ts) can cover every /api/v1/* GET the app makes. ---
|
|
||||||
|
|
||||||
export const analyzeToday = { from: "2026-08-22", to: "2026-08-22", income: "0", expense: "52000",
|
|
||||||
discretionaryExpense: "52000", net: "-52000", months: null,
|
|
||||||
expenseCategories: [{ name: "Хоол", count: 1, total: "52000" }],
|
|
||||||
incomePayees: [], expensePayees: [{ name: "худалдан авалт", count: 1, total: "52000" }] };
|
|
||||||
|
|
||||||
export const category = { name: "Хоол", kind: "expense", depth: 0, icon: null };
|
|
||||||
export const categories = [category];
|
|
||||||
|
|
||||||
export const subscription = { label: "Netflix", amount: "12900", monthly: "12900", cadence: "monthly",
|
|
||||||
nextDue: "2026-09-01", matchKey: "netflix", id: 1, manual: false, category: "Хоол" };
|
|
||||||
export const subscriptions = [subscription];
|
|
||||||
export const bills: typeof subscriptions = [];
|
|
||||||
|
|
||||||
export const manualAsset = { name: "Toyota Prius", category: "vehicle", value: "25000000",
|
|
||||||
acquiredValue: "20000000", currency: "MNT", condition: "used", isLiability: false, change: "5000000" };
|
|
||||||
export const manualAssets = [manualAsset];
|
|
||||||
|
|
||||||
export const settings = { holderName: "Бат", employer: "ABC LLC", salaryKeywords: ["цалин"], payDays: [1],
|
|
||||||
ownAccounts: ["***1"], peerAccounts: [], hideAmounts: false };
|
|
||||||
|
|
||||||
export const connection = { bank: "khan", username: "user1", courierManaged: false };
|
|
||||||
export const connections = [connection];
|
|
||||||
|
|
||||||
export const user = { id: 1, email: "test@example.com", createdAt: "2026-01-01T00:00:00Z" };
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
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 })),
|
|
||||||
];
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
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 { fileURLToPath } from "node:url";
|
||||||
import { configDefaults, defineConfig } from "vitest/config";
|
import { defineConfig } from "vitest/config";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
|
@ -16,9 +16,5 @@ export default defineConfig({
|
||||||
setupFiles: ["./vitest.setup.ts"],
|
setupFiles: ["./vitest.setup.ts"],
|
||||||
globals: true,
|
globals: true,
|
||||||
server: { deps: { inline: [/@seed-design/] } },
|
server: { deps: { inline: [/@seed-design/] } },
|
||||||
// e2e/ holds Playwright specs (own `test()`, own runner) — exclude them
|
|
||||||
// from Vitest's collection alongside the defaults, or `*.spec.ts` there
|
|
||||||
// gets picked up by Vitest's default include glob and fails to load.
|
|
||||||
exclude: [...configDefaults.exclude, "e2e/**"],
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue