Compare commits

..

6 commits

Author SHA1 Message Date
245ef6ea03 merge feat/aux: feature build-out 2026-08-22 23:02:25 +08:00
2b56d94a9f merge feat/lend: feature build-out 2026-08-22 23:02:25 +08:00
2637eb48a7 feat(web): txn-detail subscription/convert-to-lending + lending transaction-linking 2026-08-22 23:02:01 +08:00
e478ca5826 feat(web): transactions month nav + category filters + categorize-review queue
Adds a month navigator and category filter chips to the Тооцоо list, plus a
categorize-review flow at /accounting/review for bulk-assigning categories to
uncategorized merchants (grouped by matchKey, biggest spend first).
2026-08-22 23:00:09 +08:00
7753168b11 feat(web): password-reset + email-verify pages + planner spend-breakdown 2026-08-22 22:59:15 +08:00
5eff0f661f feat(web): use real Seed icon catalog (@seed-design/react-icon) matching iOS iconography 2026-08-22 22:28:41 +08:00
28 changed files with 1448 additions and 300 deletions

View file

@ -6,6 +6,7 @@
"dependencies": {
"@seed-design/css": "^2.5.0",
"@seed-design/react": "^2.3.0",
"@seed-design/react-icon": "^0.7.4",
"@seed-design/tailwind4-theme": "^2.3.0",
"@tanstack/react-query": "^5.59.0",
"next": "^15.1.0",
@ -351,6 +352,8 @@
"@seed-design/react-floating": ["@seed-design/react-floating@1.0.1", "", { "dependencies": { "@floating-ui/react": "^0.27.0", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ijlrVyRndiPp4x71cjTq6nT9qOMc/xPM5zSLp5+DM86k5KQ3FB0UEmfoZE/mGrspFZilr/KXP3ctf9CblXqcnQ=="],
"@seed-design/react-icon": ["@seed-design/react-icon@0.7.4", "", { "peerDependencies": { "react": "17.x || 18.x || 19.x" } }, "sha512-jjfKw3mvIYsTlgdxb4pjWTpT+uW8RZns/gcfhOjWuegZEXuZyAy4kPpiNbeNdnbrNoLWTCt8Rtt6UCGr/Zo/EQ=="],
"@seed-design/react-image": ["@seed-design/react-image@1.1.0", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-use-callback-ref": "^1.1.1", "@radix-ui/react-use-layout-effect": "^1.1.1", "@seed-design/dom-utils": "^2.0.1", "@seed-design/react-primitive": "^2.0.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-ZJBr0rW/xIzCcgkSpdGMv9uNEgnaYIfsOrp4YWOtj/ZSK+FSE6o5JPiC5FO0kbpPMVske91AOK92z9a6tAeSWg=="],
"@seed-design/react-menu": ["@seed-design/react-menu@2.0.2", "", { "dependencies": { "@floating-ui/react": "^0.27.0", "@radix-ui/react-compose-refs": "^1.1.2", "@radix-ui/react-focus-scope": "^1.1.8", "@seed-design/dom-utils": "^2.0.1", "@seed-design/react-dismissible-layer": "^1.0.2", "@seed-design/react-primitive": "^2.0.1", "@seed-design/react-use-controllable-state": "^2.0.1" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }, "sha512-d6hHwY03qCxA6qs664Tv6S9fBiz6zyEu/tDkNYZ4RMbp7JxtoJ8n4Q/SONlBwVWPeddiZdLP6uSe4YRzfyDMWg=="],

View file

@ -12,13 +12,14 @@
"e2e": "playwright test"
},
"dependencies": {
"@seed-design/css": "^2.5.0",
"@seed-design/react": "^2.3.0",
"@seed-design/react-icon": "^0.7.4",
"@seed-design/tailwind4-theme": "^2.3.0",
"@tanstack/react-query": "^5.59.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@seed-design/react": "^2.3.0",
"@seed-design/css": "^2.5.0",
"@seed-design/tailwind4-theme": "^2.3.0",
"@tanstack/react-query": "^5.59.0",
"zod": "^3.23.8"
},
"devDependencies": {

View file

@ -0,0 +1,6 @@
import { CategorizeReview } from "@/features/accounting/CategorizeReview";
// Ports ios/Mercury/Features/Categories/CategorizeReviewView.swift.
export default function CategorizeReviewPage() {
return <CategorizeReview />;
}

View file

@ -0,0 +1,24 @@
"use client";
import { Suspense } from "react";
import { ResetForm } from "@/features/auth/ResetForm";
import { AuthHeader } from "@/features/auth/AuthHeader";
// The password-reset landing: reached from the emailed link (?token=...).
// Wrapped in Suspense because ResetForm reads useSearchParams, which Next.js
// requires to sit below a Suspense boundary.
export default function ResetPasswordPage() {
return (
<>
<AuthHeader />
<div style={{ flex: 1, minHeight: 40 }} />
<Suspense fallback={null}>
<ResetForm />
</Suspense>
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -0,0 +1,24 @@
"use client";
import { Suspense } from "react";
import { VerifyStatus } from "@/features/auth/VerifyStatus";
import { AuthHeader } from "@/features/auth/AuthHeader";
// The email-verify landing: reached from the emailed link (?token=...).
// Wrapped in Suspense because VerifyStatus reads useSearchParams, which
// Next.js requires to sit below a Suspense boundary.
export default function VerifyEmailPage() {
return (
<>
<AuthHeader />
<div style={{ flex: 1, minHeight: 40 }} />
<Suspense fallback={null}>
<VerifyStatus />
</Suspense>
<div style={{ paddingBottom: 60 }} />
</>
);
}

View file

@ -2,7 +2,7 @@
import * as React from "react";
import Link from "next/link";
import { Icon } from "@seed-design/react";
import { Icon, type IconName } from "./icons";
import { t } from "@/i18n/common";
export type TabKey = "home" | "accounting" | "planner" | "assets" | "profile";
@ -15,47 +15,12 @@ const HREF: Record<TabKey, string> = {
profile: "/profile",
};
// `@seed-design/react`'s `Icon` takes a raw `svg` node rather than a named
// icon catalog (there's no bundled icon-name set in the installed
// @seed-design/react/css versions — verified under node_modules), so these
// are small hand-drawn stand-ins mirroring the iOS tab glyphs
// (houseFill / horizline3VerticalFill / checkmarkCalendarFill / cardFill /
// personFill from HomeTabBar.swift). Swap for the real Seed icon set once
// it's available in this app.
// Seed's `<Icon svg={...} />` uses a Radix `Slot` internally, which clones
// its size/color props onto a *single* element — so each entry here must be
// one root `<svg>` node, not a fragment of bare paths.
function tabSvg(children: React.ReactNode): React.ReactNode {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
{children}
</svg>
);
}
const TAB_ICON: Record<TabKey, React.ReactNode> = {
home: tabSvg(<path d="M3 10.5 12 3l9 7.5V20a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" />),
accounting: tabSvg(
<>
<rect x="3" y="5" width="18" height="3" rx="1" />
<rect x="3" y="10.5" width="18" height="3" rx="1" />
<rect x="3" y="16" width="18" height="3" rx="1" />
</>,
),
planner: tabSvg(
<>
<rect x="3" y="4" width="18" height="17" rx="2" />
<path d="M3 9h18" />
<path d="m8 14 2.5 2.5L16 11" />
</>,
),
assets: tabSvg(<rect x="2.5" y="5" width="19" height="14" rx="2" />),
profile: tabSvg(
<>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c1.5-4 5-6 8-6s6.5 2 8 6" />
</>,
),
const TAB_ICON: Record<TabKey, IconName> = {
home: "home",
accounting: "list",
planner: "calendar-check",
assets: "card",
profile: "user",
};
const TAB_ORDER: TabKey[] = ["home", "accounting", "planner", "assets", "profile"];
@ -82,7 +47,7 @@ export function TabBar({ active }: TabBarProps) {
className="flex flex-1 flex-col items-center gap-1 p-1 md:flex-none md:flex-row md:justify-start md:gap-3 md:rounded-xl md:px-3 md:py-2"
style={{ color: isActive ? "var(--seed-color-fg-neutral)" : "var(--seed-color-fg-placeholder)" }}
>
<Icon svg={TAB_ICON[key]} size="24px" />
<Icon name={TAB_ICON[key]} size={24} />
<span className="text-xs md:text-sm">{t.tabs[key]}</span>
</Link>
);

View file

@ -1,8 +1,10 @@
import type { SVGProps } from "react";
import type { CSSProperties, ComponentType } from "react";
import * as Seed from "@seed-design/react-icon";
// Clean line-icon set (stroke, currentColor) — a dependency-free stand-in for
// the Seed multicolor catalog, which has no web build. 24x24, 1.75 stroke,
// round caps/joins. Color via `color` / currentColor; size via `size`.
// Real Seed icon catalog (same iconography as the iOS app's SeedIcon), mapped
// from our semantic keys to Seed's filled variants. Keeping this indirection
// means every `<Icon name="…" />` call site renders authentic Seed icons and
// we can retune the mapping in one place.
export type IconName =
| "utensils"
@ -38,174 +40,60 @@ export type IconName =
| "hand-coins"
| "bank";
const PATHS: Record<IconName, React.ReactNode> = {
utensils: (
<>
<path d="M4 3v6a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V3M6 11v10" />
<path d="M17 3c-1.5 0-3 1.5-3 4.5S15.5 12 17 12v9" />
</>
),
cart: (
<>
<circle cx="9" cy="20" r="1.4" />
<circle cx="18" cy="20" r="1.4" />
<path d="M2 3h2.2l2 12.4a1.5 1.5 0 0 0 1.5 1.2h9.1a1.5 1.5 0 0 0 1.5-1.2L20.5 7H5.2" />
</>
),
coffee: (
<>
<path d="M4 8h13v5a5 5 0 0 1-5 5H9a5 5 0 0 1-5-5V8Z" />
<path d="M17 9h2.5a2.5 2.5 0 0 1 0 5H17" />
<path d="M8 2c-.6.8-.6 1.7 0 2.5M12 2c-.6.8-.6 1.7 0 2.5" />
</>
),
car: (
<>
<path d="M5 12l1.5-4.5A2 2 0 0 1 8.4 6h7.2a2 2 0 0 1 1.9 1.5L19 12" />
<path d="M3 12h18v4a1 1 0 0 1-1 1h-1.5M5.5 17H4a1 1 0 0 1-1-1v-4" />
<path d="M6.5 17v1.5M17.5 17v1.5" />
<circle cx="7" cy="14.5" r=".6" /><circle cx="17" cy="14.5" r=".6" />
</>
),
card: (
<>
<rect x="2.5" y="5" width="19" height="14" rx="2.5" />
<path d="M2.5 9.5h19" />
</>
),
shield: <path d="M12 3l7 2.5v5.5c0 4.4-3 7.6-7 9-4-1.4-7-4.6-7-9V5.5L12 3Z" />,
phone: (
<>
<rect x="6.5" y="2.5" width="11" height="19" rx="2.5" />
<path d="M11 18.5h2" />
</>
),
sparkles: (
<>
<path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3Z" />
<path d="M18 14l.8 2.2L21 17l-2.2.8L18 20l-.8-2.2L15 17l2.2-.8L18 14Z" />
</>
),
bag: (
<>
<path d="M5.5 8h13l-.9 11a2 2 0 0 1-2 1.8H8.4a2 2 0 0 1-2-1.8L5.5 8Z" />
<path d="M9 8V6.5a3 3 0 0 1 6 0V8" />
</>
),
monitor: (
<>
<rect x="3" y="4" width="18" height="12" rx="2" />
<path d="M9 20h6M12 16v4" />
</>
),
gamepad: (
<>
<path d="M7 9h10a4 4 0 0 1 4 4v.5a3.5 3.5 0 0 1-6.3 2.1L14 15h-4l-.7.6A3.5 3.5 0 0 1 3 13.5V13a4 4 0 0 1 4-4Z" />
<path d="M7.5 12v2M6.5 13h2M15.5 12.5h.01M17.5 14.5h.01" />
</>
),
dumbbell: (
<>
<path d="M3 9v6M6 7v10M18 7v10M21 9v6M6 12h12" />
</>
),
scissors: (
<>
<circle cx="6" cy="6" r="2.2" /><circle cx="6" cy="18" r="2.2" />
<path d="M8 7.5 20 18M8 16.5 20 6" />
</>
),
repeat: (
<>
<path d="M3 12V9a3 3 0 0 1 3-3h11M14 3l3 3-3 3" />
<path d="M21 12v3a3 3 0 0 1-3 3H7M10 21l-3-3 3-3" />
</>
),
wallet: (
<>
<path d="M3.5 7.5A2 2 0 0 1 5.5 5.5h11a1.5 1.5 0 0 1 0 3H4" />
<rect x="3" y="7.5" width="18" height="11.5" rx="2.5" />
<circle cx="16.5" cy="13.5" r="1.1" />
</>
),
receipt: (
<>
<path d="M6 3h12v18l-2-1.3-2 1.3-2-1.3-2 1.3-2-1.3L6 21V3Z" />
<path d="M9 8h6M9 12h6" />
</>
),
wrench: <path d="M14.5 6.5a3.5 3.5 0 0 0 4.4 4.4l-8.9 8.9a2 2 0 0 1-2.8-2.8l8.9-8.9a3.5 3.5 0 0 0-1.6-1.6Z" />,
home: (
<>
<path d="M4 11 12 4l8 7" />
<path d="M6 10v9a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-9" />
</>
),
list: <path d="M4 7h16M4 12h16M4 17h16" />,
"calendar-check": (
<>
<rect x="3.5" y="5" width="17" height="16" rx="2.5" />
<path d="M3.5 9.5h17M8 3v4M16 3v4M8.5 15l2.2 2.2L15.5 12" />
</>
),
layers: (
<>
<path d="M12 3 3 8l9 5 9-5-9-5Z" />
<path d="M3 13l9 5 9-5M3 18l9 5 9-5" opacity=".5" />
</>
),
user: (
<>
<circle cx="12" cy="8" r="3.5" />
<path d="M5 20a7 7 0 0 1 14 0" />
</>
),
plus: <path d="M12 5v14M5 12h14" />,
"chevron-right": <path d="m9 5 7 7-7 7" />,
"chevron-left": <path d="m15 5-7 7 7 7" />,
eye: (
<>
<path d="M2.5 12S6 5.5 12 5.5 21.5 12 21.5 12 18 18.5 12 18.5 2.5 12 2.5 12Z" />
<circle cx="12" cy="12" r="2.6" />
</>
),
"eye-off": (
<>
<path d="M4 4l16 16" />
<path d="M9.5 9.6A2.6 2.6 0 0 0 12 14.5a2.6 2.6 0 0 0 2.4-1.6M6.5 6.8C4 8.4 2.5 12 2.5 12s3.5 6.5 9.5 6.5c1.5 0 2.8-.4 4-1M16.5 15.2C19 13.6 21.5 12 21.5 12s-3-5.5-8-6.4" />
</>
),
"arrow-up-right": <path d="M7 17 17 7M9 7h8v8" />,
"arrow-down-left": <path d="M17 7 7 17M15 17H7V9" />,
trending: <path d="m3 16 5-5 4 4 8-8M15 7h6v6" />,
"hand-coins": (
<>
<circle cx="8" cy="6" r="3" />
<path d="M13 9h4.5a2 2 0 0 1 0 4H14M3 20l3-3h6a2 2 0 0 0 2-2" />
</>
),
bank: (
<>
<path d="M3 9.5 12 4l9 5.5M4.5 9.5V18M19.5 9.5V18M8 10v6M12 10v6M16 10v6M3 20h18" />
</>
),
type SeedIcon = ComponentType<{ size?: number; color?: string; style?: CSSProperties; "aria-hidden"?: boolean }>;
const S = Seed as unknown as Record<string, SeedIcon>;
const pick = (...names: string[]): SeedIcon => {
for (const n of names) if (S[n]) return S[n];
return S.IconQuestionCheckFill ?? S.IconBillFill;
};
export function Icon({ name, size = 22, ...rest }: { name: IconName; size?: number } & Omit<SVGProps<SVGSVGElement>, "name">) {
return (
<svg
width={size}
height={size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.75}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
{...rest}
>
{PATHS[name]}
</svg>
);
const MAP: Record<IconName, SeedIcon> = {
utensils: pick("IconRestaurantFill"),
cart: pick("IconCartFill"),
coffee: pick("IconCafeFill", "IconRestaurantFill"),
car: pick("IconCarFill"),
card: pick("IconPaymentFill", "IconListCardFill"),
shield: pick("IconLockFill"),
phone: pick("IconMobileFill"),
sparkles: pick("IconReviewStarFill"),
bag: pick("IconShoppingBagFill", "IconCartFill"),
monitor: pick("IconLaptopFill", "IconMonitorFill"),
gamepad: pick("IconPlayFill"),
dumbbell: pick("IconHeartFill"),
scissors: pick("IconReviewStarFill"),
repeat: pick("IconRetryFill"),
wallet: pick("IconMoneyWonFill", "IconPriceWonFill"),
receipt: pick("IconBillFill"),
wrench: pick("IconSettingFill", "IconToolboxFill"),
home: pick("IconHouseFill"),
list: pick("IconMenuFill", "IconListFill"),
"calendar-check": pick("IconCalendarFill"),
layers: pick("IconListCardFill"),
user: pick("IconProfileFill"),
plus: pick("IconAddFill"),
"chevron-right": pick("IconChevronRightFill"),
"chevron-left": pick("IconChevronLeftFill"),
eye: pick("IconViewFill", "IconChartFill"),
"eye-off": pick("IconViewOffFill", "IconChartFill"),
"arrow-up-right": pick("IconArrowUpwardFill", "IconArrowFill"),
"arrow-down-left": pick("IconArrowDownwardFill", "IconArrowFill"),
trending: pick("IconChartFill"),
"hand-coins": pick("IconMoneyWonFill", "IconPriceWonFill"),
bank: pick("IconPaymentFill", "IconListCardFill"),
};
export function Icon({
name,
size = 22,
color = "currentColor",
style,
}: {
name: IconName;
size?: number;
color?: string;
style?: CSSProperties;
}) {
const C = MAP[name];
return <C size={size} color={color} style={style} aria-hidden />;
}

View file

@ -0,0 +1,176 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useCategories, useTransactions, todayLocalDate } from "@/api/hooks/reads";
import { useCategorize } from "@/api/hooks/mutations";
import type { Txn } from "@/api/schemas";
import { Card, EmptyState, IconChip, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
import { useHiddenAmounts } from "./useHiddenAmounts";
interface ReviewItem {
/** Groups by `matchKey` (falling back to `title`) the same key the
* categorize endpoint applies the rule to. */
matchKey: string;
merchant: string;
count: number;
direction: "income" | "expense";
total: number;
}
function threeMonthsAgo(base: Date = new Date()): Date {
return new Date(base.getFullYear(), base.getMonth() - 3, base.getDate());
}
/** Groups uncategorized transactions by matchKey, biggest total spend first
* a handful of taps then covers most of the uncategorized money instead of
* burning through many trivial merchants. Ports the grouping in
* `CategorizeReviewModel.load()`. */
function buildQueue(txns: Txn[]): ReviewItem[] {
const groups = new Map<string, ReviewItem>();
for (const t of txns) {
if (t.category) continue; // defensive — the fetch already scopes to Uncategorized
const key = t.matchKey || t.title;
if (!key) continue;
const amount = parseFloat(t.amount) || 0;
const existing = groups.get(key);
if (existing) {
existing.count += 1;
existing.total += amount;
} else {
groups.set(key, {
matchKey: key,
merchant: t.title || key,
count: 1,
direction: t.direction === "income" ? "income" : "expense",
total: amount,
});
}
}
return Array.from(groups.values()).sort((a, b) => b.total - a.total);
}
/**
* Full-screen categorize-review flow at `/accounting/review` (ports
* `CategorizeReviewView.swift`): groups the last three months' uncategorized
* transactions by merchant, biggest spend first, and asks the user to assign
* or skip a category one merchant at a time.
*/
export function CategorizeReview() {
const router = useRouter();
const { from, to } = useMemo(() => {
const now = new Date();
return { from: todayLocalDate(threeMonthsAgo(now)), to: todayLocalDate(now) };
}, []);
const { data, isLoading } = useTransactions({ from, to, category: "Uncategorized", limit: 500 });
const { data: categories = [] } = useCategories();
const categorize = useCategorize();
const hidden = useHiddenAmounts();
// The queue is seeded once from the fetch, then mutated locally (skip
// removes, assign removes on success) — re-deriving it from `data` on every
// background refetch (categorize invalidates the transactions cache) would
// otherwise re-insert items the user already handled in this session.
const [queue, setQueue] = useState<ReviewItem[] | null>(null);
useEffect(() => {
if (data && queue === null) setQueue(buildQueue(data));
}, [data, queue]);
const mainCategories = useMemo(() => categories.filter((c) => c.depth === 1), [categories]);
function close() {
router.push("/accounting");
}
function skip(item: ReviewItem) {
setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q));
}
function assign(item: ReviewItem, category: string) {
categorize.mutate(
{ matchKey: item.matchKey, category, kind: item.direction },
{ onSuccess: () => setQueue((q) => (q ? q.filter((i) => i.matchKey !== item.matchKey) : q)) },
);
}
const current = queue?.[0];
return (
<div style={{ display: "flex", flexDirection: "column", gap: 20 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<h1 style={{ fontSize: 18, fontWeight: 700, margin: 0 }}>{s.review.title}</h1>
<button
type="button"
onClick={close}
style={{ all: "unset", cursor: "pointer", fontSize: 14, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}
>
{s.review.later}
</button>
</div>
{isLoading && queue === null ? (
<Card style={{ minHeight: 320 }} />
) : current ? (
<>
<p style={{ margin: 0, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.remaining(queue!.length)}
</p>
<Card style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8, padding: "24px 16px" }}>
<IconChip icon="cart" tint="var(--seed-color-bg-neutral-subtle, #eef0f2)" fg="var(--seed-color-fg-neutral)" size={56} />
<span style={{ fontSize: 16, fontWeight: 700, textAlign: "center" }}>{current.merchant}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.review.transactionCount(current.count)} · {hidden ? MASKED : tugrikRaw(current.total)}
</span>
</Card>
<p style={{ margin: 0, fontSize: 14, fontWeight: 700 }}>{s.review.question}</p>
<div style={{ display: "flex", flexWrap: "wrap", gap: 10 }}>
{mainCategories.map((cat) => {
const style = categoryStyle(cat.name, cat.kind === "income");
return (
<button
key={cat.name}
type="button"
onClick={() => assign(current, cat.name)}
disabled={categorize.isPending}
style={{
all: "unset",
cursor: categorize.isPending ? "default" : "pointer",
display: "flex",
alignItems: "center",
gap: 6,
padding: "10px 14px",
borderRadius: 999,
fontSize: 14,
opacity: categorize.isPending ? 0.6 : 1,
background: "var(--seed-color-bg-neutral-subtle, #eef0f2)",
}}
>
<Icon name={style.icon} size={16} />
{style.name}
</button>
);
})}
</div>
<MercuryButton variant="secondary" onClick={() => skip(current)}>
{s.review.skip}
</MercuryButton>
</>
) : (
<>
<EmptyState icon="calendar-check" title={s.review.done} />
<MercuryButton variant="primary" onClick={close}>
{s.review.close}
</MercuryButton>
</>
)}
</div>
);
}

View file

@ -0,0 +1,89 @@
"use client";
import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { accountingStrings as s } from "./strings";
export interface CategoryChipOption {
/** Raw backend category name (as stored on `Txn.category`). */
name: string;
/** Direction of a representative transaction in that category decides
* the fallback style for an unrecognized category name. */
income: boolean;
}
export interface CategoryChipsProps {
categories: CategoryChipOption[];
/** `null` = "Бүгд" (all, no filter). */
selected: string | null;
onSelect: (category: string | null) => void;
}
/** Horizontal, scrollable category filter row above the Тооцоо list: "Бүгд"
* plus every category present in the loaded month, tinted with
* `categoryStyle` and filled when active. Tap to filter the visible rows. */
export function CategoryChips({ categories, selected, onSelect }: CategoryChipsProps) {
if (categories.length === 0) return null;
return (
<div style={{ display: "flex", gap: 8, overflowX: "auto", paddingBottom: 2 }}>
<Chip label={s.list.allCategories} active={selected === null} onClick={() => onSelect(null)} />
{categories.map((c) => {
const style = categoryStyle(c.name, c.income);
return (
<Chip
key={c.name}
label={style.name}
icon={style.icon}
active={selected === c.name}
activeTint={style.tint}
activeFg={style.fg}
onClick={() => onSelect(c.name)}
/>
);
})}
</div>
);
}
function Chip({
label,
icon,
active,
activeTint,
activeFg,
onClick,
}: {
label: string;
icon?: ReturnType<typeof categoryStyle>["icon"];
active: boolean;
activeTint?: string;
activeFg?: string;
onClick: () => void;
}) {
const tint = active ? (activeTint ?? "var(--seed-color-fg-neutral)") : "var(--seed-color-bg-neutral-subtle, #eef0f2)";
const fg = active ? (activeFg ?? "var(--seed-color-bg-layer-floating, #fff)") : "var(--seed-color-fg-neutral)";
return (
<button
type="button"
onClick={onClick}
style={{
all: "unset",
cursor: "pointer",
display: "flex",
alignItems: "center",
gap: 6,
flexShrink: 0,
height: 32,
padding: "0 12px",
borderRadius: 16,
fontSize: 14,
fontWeight: active ? 700 : 500,
color: fg,
background: tint,
}}
>
{icon ? <Icon name={icon} size={15} /> : null}
{label}
</button>
);
}

View file

@ -0,0 +1,42 @@
"use client";
import type { CSSProperties } from "react";
import { Icon } from "@/ds/icons";
const navButtonStyle: CSSProperties = {
all: "unset",
cursor: "pointer",
width: 32,
height: 32,
flexShrink: 0,
borderRadius: 10,
display: "grid",
placeItems: "center",
color: "var(--seed-color-fg-neutral)",
};
export interface MonthNavProps {
/** e.g. "2026 оны 8-р сар" (see `monthLabel`). */
label: string;
onPrev: () => void;
onNext: () => void;
prevLabel?: string;
nextLabel?: string;
}
/** [month] the month navigator above the Тооцоо list. Ports the
* `monthRow` control from `TransactionsView.swift` (narrowed to just the
* month stepper, no week/month panel toggle). */
export function MonthNav({ label, onPrev, onNext, prevLabel = "Өмнөх сар", nextLabel = "Дараагийн сар" }: MonthNavProps) {
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: 4 }}>
<button type="button" onClick={onPrev} aria-label={prevLabel} style={navButtonStyle}>
<Icon name="chevron-left" size={16} />
</button>
<span style={{ fontSize: 17, fontWeight: 700, minWidth: 150, textAlign: "center" }}>{label}</span>
<button type="button" onClick={onNext} aria-label={nextLabel} style={navButtonStyle}>
<Icon name="chevron-right" size={16} />
</button>
</div>
);
}

View file

@ -16,9 +16,21 @@ import {
Skeleton,
TextFieldRoot,
TextFieldTextarea,
TextFieldInput,
SwitchRoot,
SwitchControl,
SwitchThumb,
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetBody,
BottomSheetFooter,
} from "@seed-design/react";
import { useTransactions, useCategories } from "@/api/hooks/reads";
import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations";
import { useTransactions, useCategories, useSubscriptions } from "@/api/hooks/reads";
import { useCategorize, useRenameTxn, useSetNote, useSubscriptionMutations, useLendingMutations } from "@/api/hooks/mutations";
import { Card, MercuryButton, NameEdit } from "@/ds";
import { MASKED, tugrikRaw } from "@/ds/money";
import { accountingStrings as s } from "./strings";
@ -103,9 +115,12 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const router = useRouter();
const { data: transactions, isLoading } = useTransactions();
const { data: categories = [] } = useCategories();
const { data: subscriptions } = useSubscriptions();
const categorize = useCategorize();
const renameTxn = useRenameTxn();
const setNoteMutation = useSetNote();
const subscriptionMutations = useSubscriptionMutations();
const lendingMutations = useLendingMutations();
const hiddenAmounts = useHiddenAmounts();
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
@ -117,6 +132,7 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
const [displayTitle, setDisplayTitle] = useState<string | null>(null);
const [noteOverride, setNoteOverride] = useState<string | null>(null);
const [subscriptionOverride, setSubscriptionOverride] = useState<boolean | null>(null);
const [pickerOpen, setPickerOpen] = useState(false);
const [renaming, setRenaming] = useState(false);
@ -124,13 +140,16 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const [confirmOpen, setConfirmOpen] = useState(false);
const [editingNote, setEditingNote] = useState(false);
const [noteDraft, setNoteDraft] = useState("");
const [convertOpen, setConvertOpen] = useState(false);
useEffect(() => {
setAssignedCategory(null);
setDisplayTitle(null);
setNoteOverride(null);
setSubscriptionOverride(null);
setRenaming(false);
setEditingNote(false);
setConvertOpen(false);
}, [id]);
if (isLoading && !txn) {
@ -158,10 +177,23 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
const title = displayTitle ?? txn.title;
const note = noteOverride ?? txn.note ?? "";
const canNote = txn.txnId != null && txn.txnId > 0;
// A settled row has a stable txnId; pending holds (nil/0) can't be linked
// to a lending entry or the subscription-detection reconciliation below.
const hasStableTxnId = txn.txnId != null && txn.txnId > 0;
const amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : ""}${tugrikRaw(txn.amount)}`;
// Whether this merchant is already marked as a subscription override, so the
// toggle opens in the right state (mirrors iOS's `.task` reconciliation
// against `api.recurring()`), keyed on the same match key categorize/rename
// already use for this merchant.
const subscriptionKey = (txn.matchKey ?? txn.title).toLowerCase().trim();
const detectedSubscription = (subscriptions?.subscriptions ?? []).some(
(sub) => (sub.matchKey ?? sub.label.toLowerCase()) === subscriptionKey,
);
const isSubscription = subscriptionOverride ?? detectedSubscription;
async function confirmRename() {
if (!pendingName) return;
const name = pendingName;
@ -188,6 +220,33 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
}
}
/** Toggle this merchant's subscription override (force-add / force-remove),
* mirroring iOS's `setSubscription`. */
async function toggleSubscription(next: boolean) {
setSubscriptionOverride(next);
try {
await subscriptionMutations.setSubscription.mutateAsync({
matchKey: txn!.matchKey ?? txn!.title,
active: next,
});
} catch {
// Best-effort — leave the optimistic toggle in place.
}
}
/** Create a lending entry from this expense, linked to the transaction when
* it has a stable id, then navigate to the new entry's detail page. */
async function convertToLending(values: { person: string; amount: string; lentOn: string }) {
const created = await lendingMutations.create.mutateAsync({
person: values.person,
amount: values.amount,
lentOn: values.lentOn,
txnId: hasStableTxnId ? (txn!.txnId as number) : undefined,
});
setConvertOpen(false);
router.push(created ? `/assets/lending/${created.id}` : "/assets");
}
if (renaming) {
return (
<div style={{ paddingTop: 24 }}>
@ -237,11 +296,7 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
{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}
/>
<DetailRow label={s.detail.type} value={income ? s.detail.income : s.detail.expense} />
{canNote &&
(editingNote ? (
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
@ -271,15 +326,39 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
label={s.detail.note}
value={note || s.detail.noteAdd}
chevron
last
onClick={() => {
setNoteDraft(note);
setEditingNote(true);
}}
/>
))}
<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)" }}>
{s.detailActions.subscriptionLabel}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{isSubscription ? s.detailActions.subscriptionActive : s.detailActions.subscriptionInactive}
</span>
<SwitchRoot
checked={isSubscription}
onCheckedChange={toggleSubscription}
disabled={subscriptionMutations.setSubscription.isPending}
>
<SwitchControl>
<SwitchThumb />
</SwitchControl>
</SwitchRoot>
</div>
</div>
</Card>
{!income && (
<MercuryButton variant="secondary" onClick={() => setConvertOpen(true)}>
{s.detailActions.convertToLending}
</MercuryButton>
)}
<CategorizeSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
@ -313,10 +392,93 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
</DialogContent>
</DialogPositioner>
</DialogRoot>
{convertOpen && (
<ConvertToLendingSheet
initialPerson={title}
initialAmount={txn.amount}
initialLentOn={txn.date.slice(0, 10)}
saving={lendingMutations.create.isPending}
onClose={() => setConvertOpen(false)}
onSave={convertToLending}
/>
)}
</div>
);
}
/**
* Small pre-filled form (Card style="Зээл болгох") that turns an expense into a
* lending entry ports the pre-fill in `TransactionDetailView.swift`'s
* `showMarkAsLending` (person/amount/date seeded via `LendingAutofill`),
* simplified to just those three editable fields per this task's scope.
*/
function ConvertToLendingSheet({
initialPerson,
initialAmount,
initialLentOn,
saving,
onClose,
onSave,
}: {
initialPerson: string;
initialAmount: string;
initialLentOn: string;
saving: boolean;
onClose: () => void;
onSave: (values: { person: string; amount: string; lentOn: string }) => void | Promise<void>;
}) {
const [person, setPerson] = useState(initialPerson);
const [amount, setAmount] = useState(initialAmount);
const [lentOn, setLentOn] = useState(initialLentOn);
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
return (
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader>
<BottomSheetTitle>{s.detailActions.lendingSheetTitle}</BottomSheetTitle>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 14 }}>
<TextFieldRoot value={person} onValueChange={setPerson} name="convert-person">
<TextFieldInput placeholder={s.detailActions.person} aria-label={s.detailActions.person} autoFocus />
</TextFieldRoot>
<TextFieldRoot value={amount} onValueChange={setAmount} name="convert-amount">
<TextFieldInput
type="number"
inputMode="numeric"
placeholder={s.detailActions.amount}
aria-label={s.detailActions.amount}
/>
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.detailActions.lentOn}
<input type="date" value={lentOn} onChange={(e) => setLentOn(e.target.value)} style={{ padding: 8, borderRadius: 8 }} />
</label>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 8 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
{s.detailActions.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() => onSave({ person: person.trim(), amount, lentOn })}
>
{s.detailActions.save}
</MercuryButton>
</BottomSheetFooter>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}
function BackButton({ onClick }: { onClick: () => void }) {
return (
<button

View file

@ -27,6 +27,12 @@ const txns: Txn[] = [
vi.mock("@/api/hooks/reads", () => ({
useTransactions: () => ({ data: txns, isLoading: false }),
todayLocalDate: (d: Date = new Date()) => {
const year = d.getFullYear();
const month = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
},
}));
import { TransactionList } from "./TransactionList";

View file

@ -1,14 +1,16 @@
"use client";
import Link from "next/link";
import { useMemo } from "react";
import { useMemo, useState } 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 { Card, EmptyState, HideAmountsToggle, IconChip, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons";
import { MASKED, tugrikRaw } from "@/ds/money";
import { CategoryChips, type CategoryChipOption } from "./CategoryChips";
import { MonthNav } from "./MonthNav";
import { monthLabel, monthRange } from "./monthRange";
import { accountingStrings as s } from "./strings";
import { txnRouteId } from "./txnRoute";
import { useHiddenAmounts } from "./useHiddenAmounts";
@ -92,21 +94,11 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<span
aria-hidden
style={{
width: 40,
height: 40,
flexShrink: 0,
borderRadius: 13,
display: "grid",
placeItems: "center",
color: isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg,
background: isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint,
}}
>
<Icon name={isTransfer ? "repeat" : cat.icon} size={20} />
</span>
<IconChip
icon={isTransfer ? "repeat" : cat.icon}
fg={isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg}
tint={isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint}
/>
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title || cat.name}
@ -121,19 +113,42 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
);
}
/** Distinct categories present in `txns`, most-frequent first the source
* for the category filter chip row. */
function categoriesIn(txns: Txn[]): CategoryChipOption[] {
const counts = new Map<string, { count: number; income: boolean }>();
for (const txn of txns) {
if (!txn.category) continue;
const existing = counts.get(txn.category);
if (existing) existing.count += 1;
else counts.set(txn.category, { count: 1, income: txn.direction === "income" });
}
return Array.from(counts.entries())
.sort((a, b) => b[1].count - a[1].count)
.map(([name, v]) => ({ name, income: v.income }));
}
/**
* 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
* The Тооцоо list: a month navigator, income/expense totals for the loaded
* month, category filter chips, and the transaction rows grouped by day.
* Mirrors `TransactionsView.ledgerTab` / `TransactionsModel` (narrowed to
* this task's scope): 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 [monthOffset, setMonthOffset] = useState(0);
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const { from, to } = useMemo(() => monthRange(monthOffset), [monthOffset]);
const { data, isLoading } = useTransactions({ from, to });
const all = useMemo(() => data ?? [], [data]);
const hidden = useHiddenAmounts();
function changeMonth(next: number) {
setMonthOffset(next);
setSelectedCategory(null);
}
const totals = useMemo(() => {
let income = 0;
let expense = 0;
@ -145,33 +160,60 @@ export function TransactionList() {
return { income, expense };
}, [all]);
const categoryOptions = useMemo(() => categoriesIn(all), [all]);
const hasUncategorized = useMemo(
() => all.some((txn) => !txn.category && (txn.matchKey || txn.title)),
[all],
);
const visible = useMemo(
() =>
all
.filter((txn) => txn.salary !== true)
.filter((txn) => selectedCategory === null || txn.category === selectedCategory)
.slice()
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
[all],
[all, selectedCategory],
);
const groups = useMemo(() => groupByDay(visible), [visible]);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
<HideAmountsToggle label={s.list.hideToggle} />
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
{hasUncategorized ? (
<Link href="/accounting/review" style={{ textDecoration: "none" }}>
<MercuryButton variant="secondary" size="small">
{s.list.categorizeCta}
</MercuryButton>
</Link>
) : null}
<HideAmountsToggle label={s.list.hideToggle} />
</div>
</div>
<MonthNav
label={monthLabel(monthOffset)}
onPrev={() => changeMonth(monthOffset - 1)}
onNext={() => changeMonth(monthOffset + 1)}
prevLabel={s.list.prevMonth}
nextLabel={s.list.nextMonth}
/>
<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>
<CategoryChips categories={categoryOptions} selected={selectedCategory} onSelect={setSelectedCategory} />
{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>
<EmptyState icon="calendar-check" title={s.list.empty} hint={s.list.emptyHint} compact />
</Card>
) : (
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>

View file

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { monthLabel, monthRange } from "./monthRange";
describe("monthRange", () => {
it("returns the first and last day of the base month at offset 0", () => {
expect(monthRange(0, new Date(2026, 7, 15))).toEqual({ from: "2026-08-01", to: "2026-08-31" });
});
it("steps back a month, crossing a year boundary", () => {
expect(monthRange(-1, new Date(2026, 0, 10))).toEqual({ from: "2025-12-01", to: "2025-12-31" });
});
it("steps forward a month, crossing a year boundary", () => {
expect(monthRange(1, new Date(2025, 11, 20))).toEqual({ from: "2026-01-01", to: "2026-01-31" });
});
it("handles a short month (February, non-leap year)", () => {
expect(monthRange(0, new Date(2026, 1, 1))).toEqual({ from: "2026-02-01", to: "2026-02-28" });
});
it("handles a leap-year February", () => {
expect(monthRange(0, new Date(2028, 1, 1))).toEqual({ from: "2028-02-01", to: "2028-02-29" });
});
});
describe("monthLabel", () => {
it("formats as '<year> оны <month>-р сар'", () => {
expect(monthLabel(0, new Date(2026, 7, 15))).toBe("2026 оны 8-р сар");
});
it("rolls the year when stepping across January", () => {
expect(monthLabel(-1, new Date(2026, 0, 5))).toBe("2025 оны 12-р сар");
expect(monthLabel(1, new Date(2025, 11, 5))).toBe("2026 оны 1-р сар");
});
});

View file

@ -0,0 +1,22 @@
import { todayLocalDate } from "@/api/hooks/reads";
/**
* Local-calendar [from, to] bounds (`YYYY-MM-DD`, matching `?from=&to=`) for
* the month `offset` months from `base` 0 = `base`'s own month, -1 = the
* month before, +1 = the month after. Powers the Тооцоо month navigator.
*/
export function monthRange(offset: number, base: Date = new Date()): { from: string; to: string } {
const year = base.getFullYear();
const month = base.getMonth() + offset;
const first = new Date(year, month, 1);
const last = new Date(year, month + 1, 0); // day 0 of next month = this month's last day
return { from: todayLocalDate(first), to: todayLocalDate(last) };
}
/** "2026 оны 8-р сар" for the month `offset` months from `base`. */
export function monthLabel(offset: number, base: Date = new Date()): string {
const year = base.getFullYear();
const month = base.getMonth() + offset;
const d = new Date(year, month, 1);
return `${d.getFullYear()} оны ${d.getMonth() + 1}-р сар`;
}

View file

@ -10,7 +10,22 @@ export const accountingStrings = {
income: "Орлого",
expense: "Зарлага",
empty: "Гүйлгээ алга",
emptyHint: "Энэ сард гүйлгээ бүртгэгдээгүй байна.",
transferTag: "Шилжүүлэг",
allCategories: "Бүгд",
categorizeCta: "Ангилах",
prevMonth: "Өмнөх сар",
nextMonth: "Дараагийн сар",
},
review: {
title: "Ангилалжуулах",
later: "Дараа нь",
remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`,
question: "Аль ангилалд хамаарах вэ?",
skip: "Алгасах",
done: "Бүгд ангилагдлаа",
close: "Хаах",
transactionCount: (n: number) => `${n} гүйлгээ`,
},
detail: {
total: "Нийт",
@ -41,4 +56,19 @@ export const accountingStrings = {
cancel: "Болих",
select: "Сонгох",
},
// New actions on the transaction detail (subscription toggle + convert-to-
// lending), ported from `TransactionDetailView.swift`'s `subscriptionControls`
// and "Зээл болгох" button / `LendingAddView` pre-fill flow.
detailActions: {
subscriptionLabel: "Захиалга болгох",
subscriptionActive: "Идэвхтэй",
subscriptionInactive: "Идэвхгүй",
convertToLending: "Зээл болгох",
lendingSheetTitle: "Зээлд шилжүүлэх",
person: "Хэнд өгсөн",
amount: "Дүн",
lentOn: "Өгсөн огноо",
save: "Хадгалах",
cancel: "Болих",
},
} as const;

View file

@ -19,10 +19,12 @@ import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
import { Card, HideAmountsToggle, MercuryButton, IconChip, SectionHeader, EmptyState, Icon, type IconName } from "@/ds";
import { tugrik, tugrikRaw } from "@/ds/money";
import type { Account, ManualAsset, Lending } from "@/api/schemas";
import type { Account, ManualAsset, Lending, Txn } from "@/api/schemas";
import { assetsStrings as s } from "./strings";
import { useHideAmountsTick } from "./useHideAmountsTick";
import { ConfirmDialog } from "./ConfirmDialog";
import { TransactionPickerSheet } from "./TransactionPickerSheet";
import { linkedTxnIdsOf } from "./lendingLinks";
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
const rowStyle: React.CSSProperties = {
@ -79,6 +81,7 @@ export function AssetsView() {
const accounts: Account[] = netWorth.data?.accounts ?? [];
const assets: ManualAsset[] = manualAssets.data ?? [];
const loans: Lending[] = lending.data ?? [];
const linkedTxnIds = linkedTxnIdsOf(loans);
return (
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
@ -177,6 +180,7 @@ export function AssetsView() {
{addLoanOpen && (
<AddLoanSheet
linkedTxnIds={linkedTxnIds}
onClose={() => setAddLoanOpen(false)}
saving={lendingMutations.create.isPending}
onSave={async (values) => {
@ -392,8 +396,13 @@ function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
<IconChip icon="hand-coins" {...LENDING_TINT} />
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
<span style={titleStyle}>{loan.person}</span>
<span style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
{statusLabel(loan)}
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12 }}>
<span style={{ color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
{statusLabel(loan)}
</span>
{loan.txnId != null && loan.txnId !== 0 && (
<span style={{ color: "var(--seed-color-fg-neutral-subtle)" }}>{s.lending.linkedBadge}</span>
)}
</span>
</div>
</Link>
@ -525,6 +534,7 @@ interface NewLoanValues {
lentOn: string;
dueOn?: string;
note?: string;
txnId?: number;
}
function todayISO(): string {
@ -532,10 +542,12 @@ function todayISO(): string {
}
function AddLoanSheet({
linkedTxnIds,
onClose,
onSave,
saving,
}: {
linkedTxnIds: Set<number>;
onClose: () => void;
onSave: (values: NewLoanValues) => void | Promise<void>;
saving: boolean;
@ -545,10 +557,13 @@ function AddLoanSheet({
const [lentOn, setLentOn] = React.useState(todayISO());
const [dueOn, setDueOn] = React.useState("");
const [note, setNote] = React.useState("");
const [linkedTxnId, setLinkedTxnId] = React.useState<number | undefined>(undefined);
const [pickerOpen, setPickerOpen] = React.useState(false);
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
return (
<>
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
@ -579,6 +594,16 @@ function AddLoanSheet({
<TextFieldRoot value={note} onValueChange={setNote} name="loan-note">
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.linkTxn}
<button
type="button"
onClick={() => setPickerOpen(true)}
style={{ padding: 8, borderRadius: 8, textAlign: "left", border: "1px solid var(--seed-color-border-neutral, #e5e5e5)", background: "none", cursor: "pointer" }}
>
{linkedTxnId ? s.lending.linkedBadge : "—"}
</button>
</label>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
@ -596,6 +621,7 @@ function AddLoanSheet({
lentOn,
dueOn: dueOn || undefined,
note: note.trim() || undefined,
txnId: linkedTxnId,
})
}
>
@ -605,5 +631,20 @@ function AddLoanSheet({
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
<TransactionPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
direction="expense"
linkedTxnIds={linkedTxnIds}
onPick={(txn) => {
setLinkedTxnId(txn.txnId ?? undefined);
setAmount(txn.amount);
setLentOn(txn.date.slice(0, 10));
if (!person.trim()) setPerson(txn.title);
setPickerOpen(false);
}}
/>
</>
);
}

View file

@ -23,6 +23,8 @@ import { tugrikRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings";
import { ConfirmDialog } from "./ConfirmDialog";
import { DetailHeader } from "./DetailHeader";
import { TransactionPickerSheet } from "./TransactionPickerSheet";
import { linkedTxnIdsOf } from "./lendingLinks";
type LendingRepayment = Lending["repayments"][number];
@ -57,7 +59,9 @@ export function LendingDetail({ id }: LendingDetailProps) {
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);
const loans = lending.data ?? [];
const loan: Lending | undefined = loans.find((l) => l.id === id);
const linkedTxnIds = linkedTxnIdsOf(loans);
if (lending.isLoading && !loan) {
return (
@ -95,19 +99,22 @@ export function LendingDetail({ id }: LendingDetailProps) {
<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 style={{ display: "flex", justifyContent: "center", alignItems: "center", gap: 8, marginTop: 10 }}>
<div
style={{
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>
{loan.txnId != null && loan.txnId !== 0 && (
<span style={{ fontSize: 12, ...mutedStyle }}>{s.lending.linkedBadge}</span>
)}
</div>
</Card>
@ -127,7 +134,12 @@ export function LendingDetail({ id }: LendingDetailProps) {
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip icon="receipt" tint="#E1E5EA" fg="#42505F" size={36} />
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 15, fontWeight: 700 }}>{tugrikRaw(r.amount)}</div>
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
<span style={{ fontSize: 15, fontWeight: 700 }}>{tugrikRaw(r.amount)}</span>
{r.txnId != null && r.txnId !== 0 && (
<span style={{ fontSize: 11, ...mutedStyle }}>{s.lending.linkedBadge}</span>
)}
</div>
<div style={{ fontSize: 12, ...mutedStyle }}>
{r.paidOn}
{r.note ? ` · ${r.note}` : ""}
@ -160,6 +172,7 @@ export function LendingDetail({ id }: LendingDetailProps) {
{addRepaymentOpen && (
<AddRepaymentSheet
linkedTxnIds={linkedTxnIds}
onClose={() => setAddRepaymentOpen(false)}
saving={mutations.addRepayment.isPending}
onSave={async (values) => {
@ -195,21 +208,26 @@ export function LendingDetail({ id }: LendingDetailProps) {
}
function AddRepaymentSheet({
linkedTxnIds,
onClose,
onSave,
saving,
}: {
linkedTxnIds: Set<number>;
onClose: () => void;
onSave: (values: { amount: string; paidOn: string; note?: string }) => void | Promise<void>;
onSave: (values: { amount: string; paidOn: string; note?: string; txnId?: number }) => void | Promise<void>;
saving: boolean;
}) {
const [amount, setAmount] = React.useState("");
const [paidOn, setPaidOn] = React.useState(todayISO());
const [note, setNote] = React.useState("");
const [linkedTxnId, setLinkedTxnId] = React.useState<number | undefined>(undefined);
const [pickerOpen, setPickerOpen] = React.useState(false);
const canSave = Number(amount) > 0 && !saving;
return (
<>
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
@ -234,6 +252,16 @@ function AddRepaymentSheet({
<TextFieldRoot value={note} onValueChange={setNote} name="repayment-note">
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
</TextFieldRoot>
<label style={{ display: "flex", flexDirection: "column", gap: 4, fontSize: 13 }}>
{s.lending.fields.linkTxn}
<button
type="button"
onClick={() => setPickerOpen(true)}
style={{ padding: 8, borderRadius: 8, textAlign: "left", border: "1px solid var(--seed-color-border-neutral, #e5e5e5)", background: "none", cursor: "pointer" }}
>
{linkedTxnId ? s.lending.linkedBadge : "—"}
</button>
</label>
</BottomSheetBody>
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
@ -244,7 +272,7 @@ function AddRepaymentSheet({
style={{ flex: 1 }}
disabled={!canSave}
loading={saving}
onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined })}
onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined, txnId: linkedTxnId })}
>
{s.common.save}
</MercuryButton>
@ -252,5 +280,19 @@ function AddRepaymentSheet({
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
<TransactionPickerSheet
open={pickerOpen}
onOpenChange={setPickerOpen}
direction="income"
linkedTxnIds={linkedTxnIds}
onPick={(txn) => {
setLinkedTxnId(txn.txnId ?? undefined);
setAmount(txn.amount);
setPaidOn(txn.date.slice(0, 10));
setPickerOpen(false);
}}
/>
</>
);
}

View file

@ -0,0 +1,138 @@
"use client";
import * as React from "react";
import {
BottomSheetRoot,
BottomSheetBackdrop,
BottomSheetPositioner,
BottomSheetContent,
BottomSheetHeader,
BottomSheetTitle,
BottomSheetCloseButton,
BottomSheetBody,
TextFieldRoot,
TextFieldInput,
ListRoot,
ListItem,
Icon,
} from "@seed-design/react";
import { useTransactions, todayLocalDate } from "@/api/hooks/reads";
import type { Txn } from "@/api/schemas";
import { tugrikRaw } from "@/ds/money";
import { assetsStrings as s } from "./strings";
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>
);
export interface TransactionPickerSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** "expense" for a new loan, "income" for a repayment matches
* `TransactionPickerView`'s direction filter on iOS. */
direction: "income" | "expense";
/** Transaction ids already linked to some loan/repayment, so already-linked
* rows can carry a "Холбоотой" hint (mirrors `LendingModel.linkedTxnIds`). */
linkedTxnIds?: Set<number>;
onPick: (txn: Txn) => void;
}
/**
* A searchable bottom sheet of the user's transactions, filtered to one
* direction, for linking a lending entry or repayment to the real transaction
* that created it. Ports `TransactionPickerView.swift` + `LendingAutofill.swift`
* (the filtering/candidate logic lives inline below, small enough not to need
* its own module).
*/
export function TransactionPickerSheet({ open, onOpenChange, direction, linkedTxnIds, onPick }: TransactionPickerSheetProps) {
const [search, setSearch] = React.useState("");
// Pull ~1 year so older loans/repayments stay linkable, matching iOS.
const from = React.useMemo(() => {
const d = new Date();
d.setFullYear(d.getFullYear() - 1);
return todayLocalDate(d);
}, []);
const to = React.useMemo(() => todayLocalDate(), []);
const { data: transactions, isLoading } = useTransactions({ direction, from, to, limit: 500 });
const shown = React.useMemo(() => {
const q = search.trim().toLowerCase();
return (transactions ?? []).filter((t) => {
// Pending holds (no stable txnId) can't be linked.
if (t.txnId == null || t.txnId === 0) return false;
if (!q) return true;
return t.title.toLowerCase().includes(q) || t.amount.toLowerCase().includes(q);
});
}, [transactions, search]);
return (
<BottomSheetRoot open={open} onOpenChange={onOpenChange}>
<BottomSheetBackdrop />
<BottomSheetPositioner>
<BottomSheetContent>
<BottomSheetHeader style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
<BottomSheetTitle>{s.lending.picker.title}</BottomSheetTitle>
<BottomSheetCloseButton aria-label={s.common.cancel}>
<Icon svg={closeSvg} size="16px" />
</BottomSheetCloseButton>
</BottomSheetHeader>
<BottomSheetBody style={{ display: "flex", flexDirection: "column", gap: 10, maxHeight: "70vh", overflowY: "auto" }}>
<TextFieldRoot value={search} onValueChange={setSearch} name="txn-picker-search">
<TextFieldInput placeholder={s.lending.picker.search} aria-label={s.lending.picker.search} autoFocus />
</TextFieldRoot>
{isLoading ? (
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-subtle)" }}></p>
) : shown.length === 0 ? (
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-subtle)" }}>{s.lending.picker.empty}</p>
) : (
<ListRoot>
{shown.map((txn) => {
const linked = txn.txnId != null && linkedTxnIds?.has(txn.txnId);
return (
<ListItem key={`${txn.txnId}-${txn.date}`} style={{ padding: 0 }}>
<button
type="button"
onClick={() => onPick(txn)}
style={{
display: "flex",
width: "100%",
alignItems: "center",
justifyContent: "space-between",
gap: 12,
background: "none",
border: "none",
textAlign: "left",
cursor: "pointer",
padding: "10px 4px",
font: "inherit",
color: "inherit",
}}
>
<div style={{ minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{txn.title}
</div>
<div style={{ fontSize: 12, color: "var(--seed-color-fg-neutral-subtle)" }}>{txn.date.slice(0, 10)}</div>
</div>
<div style={{ textAlign: "right", flexShrink: 0 }}>
<div style={{ fontSize: 14, fontWeight: 600 }}>{tugrikRaw(txn.amount)}</div>
{linked && (
<div style={{ fontSize: 11, color: "var(--seed-color-fg-neutral-subtle)" }}>{s.lending.linkedBadge}</div>
)}
</div>
</button>
</ListItem>
);
})}
</ListRoot>
)}
</BottomSheetBody>
</BottomSheetContent>
</BottomSheetPositioner>
</BottomSheetRoot>
);
}

View file

@ -0,0 +1,17 @@
import type { Lending } from "@/api/schemas";
/**
* Transaction ids already linked to some loan or repayment (for the picker's
* "already linked" hint). Drops the 0/nil "unlinked" sentinel. Ports
* `LendingModel.linkedTxnIds` (iOS) pure, no networking.
*/
export function linkedTxnIdsOf(loans: Lending[]): Set<number> {
const ids = new Set<number>();
for (const l of loans) {
if (l.txnId != null && l.txnId !== 0) ids.add(l.txnId);
for (const r of l.repayments) {
if (r.txnId != null && r.txnId !== 0) ids.add(r.txnId);
}
}
return ids;
}

View file

@ -82,7 +82,9 @@ export const assetsStrings = {
lentOn: "Өгсөн огноо",
dueOn: "Төлөх огноо",
note: "Тэмдэглэл",
linkTxn: "Гүйлгээ холбох",
},
linkedBadge: "Холбоотой",
repayments: {
title: "Төлөлтийн түүх",
empty: "Төлөлт бүртгэгдээгүй",
@ -90,6 +92,11 @@ export const assetsStrings = {
deleteTitle: "Төлөлт устгах уу?",
deleteDescription: "Энэ төлөлтийг түүхээс хасна.",
},
picker: {
title: "Гүйлгээ сонгох",
search: "Хайх",
empty: "Гүйлгээ олдсонгүй",
},
},
accountDetail: {
balance: "Үлдэгдэл",

View file

@ -0,0 +1,106 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
import { MercuryButton } from "../../ds/MercuryButton";
import { authStrings } from "./strings";
import { submitReset } from "./actions";
/** Set-new-password form for the emailed reset link (`/reset?token=...`).
* Ported in spirit from ForgotPasswordView.swift's field/CTA styling there is
* no iOS screen for this step since the reset itself only happens on web (see
* that file's header comment). Same 8-char minimum as AuthForm's register step. */
export function ResetForm() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [password, setPassword] = React.useState("");
const [confirmPassword, setConfirmPassword] = React.useState("");
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
const [done, setDone] = React.useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (isSubmitting) return;
setErrorMessage(undefined);
if (!token) {
setErrorMessage(authStrings.reset.missingToken);
return;
}
if (password.length < 8) {
setErrorMessage(authStrings.errors.passwordTooShort);
return;
}
if (password !== confirmPassword) {
setErrorMessage(authStrings.errors.passwordMismatch);
return;
}
setIsSubmitting(true);
try {
const result = await submitReset(token, password);
if (result.ok) {
setDone(true);
} else {
setErrorMessage(result.error ?? authStrings.errors.unknown);
}
} finally {
setIsSubmitting(false);
}
}
if (done) {
return (
<div className="flex flex-col gap-5" style={{ textAlign: "center" }}>
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.reset.success}
</p>
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
{authStrings.reset.loginLink}
</MercuryButton>
</div>
);
}
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.reset.subtitle}
</p>
<div className="flex flex-col gap-3">
<TextFieldRoot value={password} onValueChange={setPassword} name="password">
<TextFieldInput
type="password"
autoComplete="new-password"
placeholder={authStrings.reset.password}
aria-label={authStrings.reset.password}
/>
</TextFieldRoot>
<TextFieldRoot value={confirmPassword} onValueChange={setConfirmPassword} name="confirmPassword">
<TextFieldInput
type="password"
autoComplete="new-password"
placeholder={authStrings.reset.confirmPassword}
aria-label={authStrings.reset.confirmPassword}
/>
</TextFieldRoot>
</div>
{errorMessage && (
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
{errorMessage}
</p>
)}
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
{authStrings.reset.submit}
</MercuryButton>
</form>
);
}

View file

@ -0,0 +1,69 @@
"use client";
import * as React from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { MercuryButton } from "../../ds/MercuryButton";
import { authStrings } from "./strings";
import { submitVerify } from "./actions";
type State = "verifying" | "success" | "error";
/** Email-verify landing (`/verify?token=...`): posts the token once on mount
* and shows a verifying/success/failure state. The backend's email-verification
* feature may be disabled server-side (Config.EmailVerificationEnabled), in
* which case the route 404s and this renders the generic error message
* still a graceful, non-broken screen rather than a blocked build. */
export function VerifyStatus() {
const router = useRouter();
const searchParams = useSearchParams();
const token = searchParams.get("token") ?? "";
const [state, setState] = React.useState<State>("verifying");
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
const attempted = React.useRef(false);
React.useEffect(() => {
if (attempted.current) return;
attempted.current = true;
if (!token) {
setErrorMessage(authStrings.verify.missingToken);
setState("error");
return;
}
submitVerify(token).then((result) => {
if (result.ok) {
setState("success");
} else {
setErrorMessage(result.error ?? authStrings.errors.unknown);
setState("error");
}
});
}, [token]);
return (
<div className="flex flex-col items-center gap-5" style={{ textAlign: "center" }}>
{state === "verifying" && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.verify.verifying}
</p>
)}
{state === "success" && (
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
{authStrings.verify.success}
</p>
)}
{state === "error" && (
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
{errorMessage}
</p>
)}
{state !== "verifying" && (
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
{authStrings.verify.loginLink}
</MercuryButton>
)}
</div>
);
}

View file

@ -15,6 +15,7 @@ const ERROR_MESSAGE_BY_CODE: Record<string, string> = {
email_taken: authStrings.errors.emailTaken,
invalid_code: authStrings.errors.invalidCode,
invalid_challenge: authStrings.errors.invalidCode,
invalid_token: authStrings.errors.invalidToken,
};
function messageForCode(code: string | undefined): string {
@ -23,7 +24,7 @@ function messageForCode(code: string | undefined): string {
}
async function postAuth(
path: "login" | "register" | "forgot" | "2fa",
path: "login" | "register" | "forgot" | "2fa" | "reset" | "verify",
body: unknown,
): Promise<AuthActionResult> {
let res: Response;
@ -73,3 +74,20 @@ export function submit2FA(
export function submitForgot(email: string): Promise<AuthActionResult> {
return postAuth("forgot", { email });
}
// Backend contract (internal/transport/api/password_handlers.go
// handleResetPassword): { token, newPassword } — the response body carries no
// user/session, just { status: "ok" }, so `postAuth`'s ok/error handling is
// all callers need.
export function submitReset(
token: string,
password: string,
): Promise<AuthActionResult> {
return postAuth("reset", { token, newPassword: password });
}
// Backend contract (internal/transport/api/verify_handlers.go
// handleVerifyEmail): { token } → { status: "verified" } on success.
export function submitVerify(token: string): Promise<AuthActionResult> {
return postAuth("verify", { token });
}

View file

@ -28,6 +28,26 @@ export const authStrings = {
submit: "Сэргээх холбоос авах",
sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.",
},
// /reset — reached from the emailed link (?token=...). No iOS counterpart:
// ForgotPasswordView.swift is request-only, the actual change happens here
// on web (see that file's header comment).
reset: {
subtitle: "Шинэ нууц үгээ хоёр удаа оруулна уу.",
password: "Шинэ нууц үг",
confirmPassword: "Нууц үг давтах",
submit: "Нууц үг шинэчлэх",
success: "Нууц үг амжилттай шинэчлэгдлээ. Шинэ нууц үгээрээ нэвтэрнэ үү.",
loginLink: "Нэвтрэх хуудас руу очих",
missingToken: "Холбоос буруу байна. Имэйлээр ирсэн холбоосоор дахин орно уу.",
},
// /verify — reached from the emailed link (?token=...). Also no iOS
// counterpart; verification is a web-only flow today.
verify: {
verifying: "Имэйл хаягийг баталгаажуулж байна…",
success: "Имэйл хаяг амжилттай баталгаажлаа.",
loginLink: "Нэвтрэх хуудас руу очих",
missingToken: "Баталгаажуулах холбоос буруу байна.",
},
twoFactor: {
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
code: "Баталгаажуулах код",
@ -40,6 +60,7 @@ export const authStrings = {
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
emailTaken: "Энэ имэйл бүртгэлтэй байна.",
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
invalidToken: "Холбоосны хугацаа дууссан эсвэл буруу байна.",
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
},

View file

@ -28,6 +28,7 @@ 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 { SpendBreakdown } from "./SpendBreakdown";
import { plannerStrings as s } from "./strings";
type Horizon = "day" | "week" | "month";
@ -82,6 +83,7 @@ export function PlannerView() {
const goalMutations = useSavingsGoalMutations();
const [horizon, setHorizon] = React.useState<Horizon>("day");
const [breakdownOpen, setBreakdownOpen] = React.useState(false);
const accounts: Account[] = netWorth?.accounts ?? [];
@ -167,6 +169,15 @@ export function PlannerView() {
limit={overallLimit}
loading={isLoading}
onSave={(v) => saveOverall(horizon, v)}
onOpenBreakdown={() => setBreakdownOpen(true)}
/>
<SpendBreakdown
open={breakdownOpen}
onOpenChange={setBreakdownOpen}
horizonLabel={OVERALL_LABEL[horizon]}
total={overallSpent}
rows={rows}
/>
<CategoryList
@ -306,12 +317,14 @@ function OverallCard({
limit,
loading,
onSave,
onOpenBreakdown,
}: {
horizon: Horizon;
spent: number;
limit: number;
loading: boolean;
onSave: (value: number) => void;
onOpenBreakdown: () => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
@ -352,31 +365,54 @@ function OverallCard({
</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", justifyContent: "space-between" }}
>
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
<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, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)}
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
</span>
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-end">
<button
type="button"
onClick={onOpenBreakdown}
className="flex items-center gap-1"
style={{
background: "none",
border: "none",
padding: "2px 0",
cursor: "pointer",
color: "#fff",
opacity: 0.85,
fontSize: 12,
fontWeight: 600,
}}
>
<Icon name="trending" size={13} />
{s.overall.breakdown}
</button>
</div>
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
</button>
<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", justifyContent: "space-between" }}
>
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
<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, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)}
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
</span>
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
</div>
</div>
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
</button>
</div>
)}
</div>
);
@ -590,7 +626,7 @@ function AmountField({ label, value, onChange }: { label: string; value: string;
);
}
function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
export 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

View file

@ -0,0 +1,130 @@
"use client";
import Link from "next/link";
import {
ContentDialogRoot,
ContentDialogBackdrop,
ContentDialogPositioner,
ContentDialogContent,
ContentDialogHeader,
ContentDialogTitle,
ContentDialogBody,
ContentDialogFooter,
} from "@seed-design/react";
import { IconChip, EmptyState, MercuryButton } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle";
import { tugrik } from "@/ds/money";
import { ProgressBar } from "./PlannerView";
import { plannerStrings as s } from "./strings";
export interface SpendBreakdownRow {
category: string;
spent: string;
limit: string;
}
function dec(v: string | undefined | null): number {
return parseFloat(v ?? "0") || 0;
}
export interface SpendBreakdownProps {
open: boolean;
onOpenChange: (open: boolean) => void;
horizonLabel: string;
total: number;
rows: SpendBreakdownRow[];
}
/** Opened from the overall-spend card's "Задаргаа" affordance: how the
* horizon's total spend is composed, category by category, biggest first
* "how did this number get so big?". Ports SpendBreakdownView.swift; each row
* links to the same `/planner/[category]` transactions page the category-limit
* list already uses. */
export function SpendBreakdown({ open, onOpenChange, horizonLabel, total, rows }: SpendBreakdownProps) {
const spent = rows
.filter((r) => dec(r.spent) > 0)
.slice()
.sort((a, b) => dec(b.spent) - dec(a.spent));
return (
<ContentDialogRoot open={open} onOpenChange={onOpenChange}>
<ContentDialogBackdrop />
<ContentDialogPositioner>
<ContentDialogContent style={{ maxWidth: 420, width: "100%" }}>
<ContentDialogHeader>
<ContentDialogTitle>{s.spendBreakdown.title(horizonLabel)}</ContentDialogTitle>
</ContentDialogHeader>
<ContentDialogBody>
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--seed-color-fg-placeholder)" }}>
{tugrik(total)}
</span>
{spent.length === 0 ? (
<EmptyState icon="layers" title={s.spendBreakdown.empty} compact />
) : (
<ul className="flex flex-col gap-3">
{spent.map((row) => {
const style = categoryStyle(row.category);
const value = dec(row.spent);
const percent = total > 0 ? Math.min(100, (value / total) * 100) : 0;
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
<li key={row.category}>
<Link
href={`/planner/${encodeURIComponent(row.category)}`}
onClick={() => onOpenChange(false)}
className="flex items-center gap-3"
style={{ color: "inherit", textDecoration: "none" }}
>
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} />
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
<div className="flex items-center justify-between gap-2">
<span
style={{
fontWeight: 700,
fontSize: 14,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{style.name}
</span>
<span style={{ fontWeight: 700, fontSize: 13, flexShrink: 0 }}>{tugrik(value)}</span>
</div>
<div className="flex items-center gap-2">
<div style={{ flex: 1 }}>
<ProgressBar percent={percent} tone="brand" />
</div>
<span
style={{
fontSize: 11,
width: 32,
textAlign: "right",
flexShrink: 0,
color: "var(--seed-color-fg-placeholder)",
}}
>
{pct}%
</span>
</div>
</div>
</Link>
</li>
);
})}
</ul>
)}
</div>
</ContentDialogBody>
<ContentDialogFooter>
<MercuryButton variant="secondary" onClick={() => onOpenChange(false)}>
{s.spendBreakdown.close}
</MercuryButton>
</ContentDialogFooter>
</ContentDialogContent>
</ContentDialogPositioner>
</ContentDialogRoot>
);
}

View file

@ -25,6 +25,14 @@ export const plannerStrings = {
},
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
unsetHint: "Лимит тохируулаагүй",
breakdown: "Задаргаа",
},
// Spend-breakdown dialog, ported from SpendBreakdownView.swift: how a
// horizon's spend total is composed, category by category.
spendBreakdown: {
title: (horizonLabel: string) => `${horizonLabel} — задаргаа`,
empty: "Гүйлгээ алга",
close: "Хаах",
},
categories: {
title: "Ангиллын лимит",