Compare commits
No commits in common. "245ef6ea037ffacf191d2b8b4e12f7b2e8ef53d6" and "0010b7b5fa9cd8628e1ddf0995d517a725f05cf4" have entirely different histories.
245ef6ea03
...
0010b7b5fa
28 changed files with 300 additions and 1448 deletions
3
bun.lock
3
bun.lock
|
|
@ -6,7 +6,6 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@seed-design/css": "^2.5.0",
|
"@seed-design/css": "^2.5.0",
|
||||||
"@seed-design/react": "^2.3.0",
|
"@seed-design/react": "^2.3.0",
|
||||||
"@seed-design/react-icon": "^0.7.4",
|
|
||||||
"@seed-design/tailwind4-theme": "^2.3.0",
|
"@seed-design/tailwind4-theme": "^2.3.0",
|
||||||
"@tanstack/react-query": "^5.59.0",
|
"@tanstack/react-query": "^5.59.0",
|
||||||
"next": "^15.1.0",
|
"next": "^15.1.0",
|
||||||
|
|
@ -352,8 +351,6 @@
|
||||||
|
|
||||||
"@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-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-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=="],
|
"@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=="],
|
||||||
|
|
|
||||||
|
|
@ -12,14 +12,13 @@
|
||||||
"e2e": "playwright test"
|
"e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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",
|
"next": "^15.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^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"
|
"zod": "^3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
import { CategorizeReview } from "@/features/accounting/CategorizeReview";
|
|
||||||
|
|
||||||
// Ports ios/Mercury/Features/Categories/CategorizeReviewView.swift.
|
|
||||||
export default function CategorizeReviewPage() {
|
|
||||||
return <CategorizeReview />;
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"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 }} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
"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 }} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Icon, type IconName } from "./icons";
|
import { Icon } from "@seed-design/react";
|
||||||
import { t } from "@/i18n/common";
|
import { t } from "@/i18n/common";
|
||||||
|
|
||||||
export type TabKey = "home" | "accounting" | "planner" | "assets" | "profile";
|
export type TabKey = "home" | "accounting" | "planner" | "assets" | "profile";
|
||||||
|
|
@ -15,12 +15,47 @@ const HREF: Record<TabKey, string> = {
|
||||||
profile: "/profile",
|
profile: "/profile",
|
||||||
};
|
};
|
||||||
|
|
||||||
const TAB_ICON: Record<TabKey, IconName> = {
|
// `@seed-design/react`'s `Icon` takes a raw `svg` node rather than a named
|
||||||
home: "home",
|
// icon catalog (there's no bundled icon-name set in the installed
|
||||||
accounting: "list",
|
// @seed-design/react/css versions — verified under node_modules), so these
|
||||||
planner: "calendar-check",
|
// are small hand-drawn stand-ins mirroring the iOS tab glyphs
|
||||||
assets: "card",
|
// (houseFill / horizline3VerticalFill / checkmarkCalendarFill / cardFill /
|
||||||
profile: "user",
|
// 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_ORDER: TabKey[] = ["home", "accounting", "planner", "assets", "profile"];
|
const TAB_ORDER: TabKey[] = ["home", "accounting", "planner", "assets", "profile"];
|
||||||
|
|
@ -47,7 +82,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"
|
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)" }}
|
style={{ color: isActive ? "var(--seed-color-fg-neutral)" : "var(--seed-color-fg-placeholder)" }}
|
||||||
>
|
>
|
||||||
<Icon name={TAB_ICON[key]} size={24} />
|
<Icon svg={TAB_ICON[key]} size="24px" />
|
||||||
<span className="text-xs md:text-sm">{t.tabs[key]}</span>
|
<span className="text-xs md:text-sm">{t.tabs[key]}</span>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
232
src/ds/icons.tsx
232
src/ds/icons.tsx
|
|
@ -1,10 +1,8 @@
|
||||||
import type { CSSProperties, ComponentType } from "react";
|
import type { SVGProps } from "react";
|
||||||
import * as Seed from "@seed-design/react-icon";
|
|
||||||
|
|
||||||
// Real Seed icon catalog (same iconography as the iOS app's SeedIcon), mapped
|
// Clean line-icon set (stroke, currentColor) — a dependency-free stand-in for
|
||||||
// from our semantic keys to Seed's filled variants. Keeping this indirection
|
// the Seed multicolor catalog, which has no web build. 24x24, 1.75 stroke,
|
||||||
// means every `<Icon name="…" />` call site renders authentic Seed icons and
|
// round caps/joins. Color via `color` / currentColor; size via `size`.
|
||||||
// we can retune the mapping in one place.
|
|
||||||
|
|
||||||
export type IconName =
|
export type IconName =
|
||||||
| "utensils"
|
| "utensils"
|
||||||
|
|
@ -40,60 +38,174 @@ export type IconName =
|
||||||
| "hand-coins"
|
| "hand-coins"
|
||||||
| "bank";
|
| "bank";
|
||||||
|
|
||||||
type SeedIcon = ComponentType<{ size?: number; color?: string; style?: CSSProperties; "aria-hidden"?: boolean }>;
|
const PATHS: Record<IconName, React.ReactNode> = {
|
||||||
|
utensils: (
|
||||||
const S = Seed as unknown as Record<string, SeedIcon>;
|
<>
|
||||||
const pick = (...names: string[]): SeedIcon => {
|
<path d="M4 3v6a2 2 0 0 0 2 2h0a2 2 0 0 0 2-2V3M6 11v10" />
|
||||||
for (const n of names) if (S[n]) return S[n];
|
<path d="M17 3c-1.5 0-3 1.5-3 4.5S15.5 12 17 12v9" />
|
||||||
return S.IconQuestionCheckFill ?? S.IconBillFill;
|
</>
|
||||||
|
),
|
||||||
|
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" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const MAP: Record<IconName, SeedIcon> = {
|
export function Icon({ name, size = 22, ...rest }: { name: IconName; size?: number } & Omit<SVGProps<SVGSVGElement>, "name">) {
|
||||||
utensils: pick("IconRestaurantFill"),
|
return (
|
||||||
cart: pick("IconCartFill"),
|
<svg
|
||||||
coffee: pick("IconCafeFill", "IconRestaurantFill"),
|
width={size}
|
||||||
car: pick("IconCarFill"),
|
height={size}
|
||||||
card: pick("IconPaymentFill", "IconListCardFill"),
|
viewBox="0 0 24 24"
|
||||||
shield: pick("IconLockFill"),
|
fill="none"
|
||||||
phone: pick("IconMobileFill"),
|
stroke="currentColor"
|
||||||
sparkles: pick("IconReviewStarFill"),
|
strokeWidth={1.75}
|
||||||
bag: pick("IconShoppingBagFill", "IconCartFill"),
|
strokeLinecap="round"
|
||||||
monitor: pick("IconLaptopFill", "IconMonitorFill"),
|
strokeLinejoin="round"
|
||||||
gamepad: pick("IconPlayFill"),
|
aria-hidden
|
||||||
dumbbell: pick("IconHeartFill"),
|
{...rest}
|
||||||
scissors: pick("IconReviewStarFill"),
|
>
|
||||||
repeat: pick("IconRetryFill"),
|
{PATHS[name]}
|
||||||
wallet: pick("IconMoneyWonFill", "IconPriceWonFill"),
|
</svg>
|
||||||
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 />;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,176 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,89 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -16,21 +16,9 @@ import {
|
||||||
Skeleton,
|
Skeleton,
|
||||||
TextFieldRoot,
|
TextFieldRoot,
|
||||||
TextFieldTextarea,
|
TextFieldTextarea,
|
||||||
TextFieldInput,
|
|
||||||
SwitchRoot,
|
|
||||||
SwitchControl,
|
|
||||||
SwitchThumb,
|
|
||||||
BottomSheetRoot,
|
|
||||||
BottomSheetBackdrop,
|
|
||||||
BottomSheetPositioner,
|
|
||||||
BottomSheetContent,
|
|
||||||
BottomSheetHeader,
|
|
||||||
BottomSheetTitle,
|
|
||||||
BottomSheetBody,
|
|
||||||
BottomSheetFooter,
|
|
||||||
} from "@seed-design/react";
|
} from "@seed-design/react";
|
||||||
import { useTransactions, useCategories, useSubscriptions } from "@/api/hooks/reads";
|
import { useTransactions, useCategories } from "@/api/hooks/reads";
|
||||||
import { useCategorize, useRenameTxn, useSetNote, useSubscriptionMutations, useLendingMutations } from "@/api/hooks/mutations";
|
import { useCategorize, useRenameTxn, useSetNote } from "@/api/hooks/mutations";
|
||||||
import { Card, MercuryButton, NameEdit } from "@/ds";
|
import { Card, MercuryButton, NameEdit } from "@/ds";
|
||||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
import { MASKED, tugrikRaw } from "@/ds/money";
|
||||||
import { accountingStrings as s } from "./strings";
|
import { accountingStrings as s } from "./strings";
|
||||||
|
|
@ -115,12 +103,9 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { data: transactions, isLoading } = useTransactions();
|
const { data: transactions, isLoading } = useTransactions();
|
||||||
const { data: categories = [] } = useCategories();
|
const { data: categories = [] } = useCategories();
|
||||||
const { data: subscriptions } = useSubscriptions();
|
|
||||||
const categorize = useCategorize();
|
const categorize = useCategorize();
|
||||||
const renameTxn = useRenameTxn();
|
const renameTxn = useRenameTxn();
|
||||||
const setNoteMutation = useSetNote();
|
const setNoteMutation = useSetNote();
|
||||||
const subscriptionMutations = useSubscriptionMutations();
|
|
||||||
const lendingMutations = useLendingMutations();
|
|
||||||
const hiddenAmounts = useHiddenAmounts();
|
const hiddenAmounts = useHiddenAmounts();
|
||||||
|
|
||||||
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
|
const txn = useMemo(() => findTxnByRouteId(transactions ?? [], id), [transactions, id]);
|
||||||
|
|
@ -132,7 +117,6 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
|
const [assignedCategory, setAssignedCategory] = useState<string | null>(null);
|
||||||
const [displayTitle, setDisplayTitle] = useState<string | null>(null);
|
const [displayTitle, setDisplayTitle] = useState<string | null>(null);
|
||||||
const [noteOverride, setNoteOverride] = useState<string | null>(null);
|
const [noteOverride, setNoteOverride] = useState<string | null>(null);
|
||||||
const [subscriptionOverride, setSubscriptionOverride] = useState<boolean | null>(null);
|
|
||||||
|
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
const [renaming, setRenaming] = useState(false);
|
const [renaming, setRenaming] = useState(false);
|
||||||
|
|
@ -140,16 +124,13 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [editingNote, setEditingNote] = useState(false);
|
const [editingNote, setEditingNote] = useState(false);
|
||||||
const [noteDraft, setNoteDraft] = useState("");
|
const [noteDraft, setNoteDraft] = useState("");
|
||||||
const [convertOpen, setConvertOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAssignedCategory(null);
|
setAssignedCategory(null);
|
||||||
setDisplayTitle(null);
|
setDisplayTitle(null);
|
||||||
setNoteOverride(null);
|
setNoteOverride(null);
|
||||||
setSubscriptionOverride(null);
|
|
||||||
setRenaming(false);
|
setRenaming(false);
|
||||||
setEditingNote(false);
|
setEditingNote(false);
|
||||||
setConvertOpen(false);
|
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
if (isLoading && !txn) {
|
if (isLoading && !txn) {
|
||||||
|
|
@ -177,23 +158,10 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
const title = displayTitle ?? txn.title;
|
const title = displayTitle ?? txn.title;
|
||||||
const note = noteOverride ?? txn.note ?? "";
|
const note = noteOverride ?? txn.note ?? "";
|
||||||
const canNote = txn.txnId != null && txn.txnId > 0;
|
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 amountRaw = hiddenAmounts ? MASKED : tugrikRaw(txn.amount);
|
||||||
const signedAmount = hiddenAmounts ? MASKED : `${income ? "+" : "−"}${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() {
|
async function confirmRename() {
|
||||||
if (!pendingName) return;
|
if (!pendingName) return;
|
||||||
const name = pendingName;
|
const name = pendingName;
|
||||||
|
|
@ -220,33 +188,6 @@ 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) {
|
if (renaming) {
|
||||||
return (
|
return (
|
||||||
<div style={{ paddingTop: 24 }}>
|
<div style={{ paddingTop: 24 }}>
|
||||||
|
|
@ -296,7 +237,11 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
{txn.balanceAfter != null && (
|
{txn.balanceAfter != null && (
|
||||||
<DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} />
|
<DetailRow label={s.detail.balance} value={hiddenAmounts ? MASKED : tugrikRaw(txn.balanceAfter)} />
|
||||||
)}
|
)}
|
||||||
<DetailRow label={s.detail.type} value={income ? s.detail.income : s.detail.expense} />
|
<DetailRow
|
||||||
|
label={s.detail.type}
|
||||||
|
value={income ? s.detail.income : s.detail.expense}
|
||||||
|
last={!canNote}
|
||||||
|
/>
|
||||||
{canNote &&
|
{canNote &&
|
||||||
(editingNote ? (
|
(editingNote ? (
|
||||||
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ padding: 12, display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
|
@ -326,39 +271,15 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
label={s.detail.note}
|
label={s.detail.note}
|
||||||
value={note || s.detail.noteAdd}
|
value={note || s.detail.noteAdd}
|
||||||
chevron
|
chevron
|
||||||
|
last
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setNoteDraft(note);
|
setNoteDraft(note);
|
||||||
setEditingNote(true);
|
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>
|
</Card>
|
||||||
|
|
||||||
{!income && (
|
|
||||||
<MercuryButton variant="secondary" onClick={() => setConvertOpen(true)}>
|
|
||||||
{s.detailActions.convertToLending}
|
|
||||||
</MercuryButton>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<CategorizeSheet
|
<CategorizeSheet
|
||||||
open={pickerOpen}
|
open={pickerOpen}
|
||||||
onOpenChange={setPickerOpen}
|
onOpenChange={setPickerOpen}
|
||||||
|
|
@ -392,93 +313,10 @@ export function TransactionDetail({ id }: TransactionDetailProps) {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</DialogPositioner>
|
</DialogPositioner>
|
||||||
</DialogRoot>
|
</DialogRoot>
|
||||||
|
|
||||||
{convertOpen && (
|
|
||||||
<ConvertToLendingSheet
|
|
||||||
initialPerson={title}
|
|
||||||
initialAmount={txn.amount}
|
|
||||||
initialLentOn={txn.date.slice(0, 10)}
|
|
||||||
saving={lendingMutations.create.isPending}
|
|
||||||
onClose={() => setConvertOpen(false)}
|
|
||||||
onSave={convertToLending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</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 }) {
|
function BackButton({ onClick }: { onClick: () => void }) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|
|
||||||
|
|
@ -27,12 +27,6 @@ const txns: Txn[] = [
|
||||||
|
|
||||||
vi.mock("@/api/hooks/reads", () => ({
|
vi.mock("@/api/hooks/reads", () => ({
|
||||||
useTransactions: () => ({ data: txns, isLoading: false }),
|
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";
|
import { TransactionList } from "./TransactionList";
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useMemo, useState } from "react";
|
import { useMemo } from "react";
|
||||||
import { Skeleton } from "@seed-design/react";
|
import { Skeleton } from "@seed-design/react";
|
||||||
import { useTransactions } from "@/api/hooks/reads";
|
import { useTransactions } from "@/api/hooks/reads";
|
||||||
import type { Txn } from "@/api/schemas";
|
import type { Txn } from "@/api/schemas";
|
||||||
import { Card, EmptyState, HideAmountsToggle, IconChip, MercuryButton } from "@/ds";
|
import { Card, HideAmountsToggle } from "@/ds";
|
||||||
import { categoryStyle } from "@/ds/categoryStyle";
|
import { categoryStyle } from "@/ds/categoryStyle";
|
||||||
|
import { Icon } from "@/ds/icons";
|
||||||
import { MASKED, tugrikRaw } from "@/ds/money";
|
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 { accountingStrings as s } from "./strings";
|
||||||
import { txnRouteId } from "./txnRoute";
|
import { txnRouteId } from "./txnRoute";
|
||||||
import { useHiddenAmounts } from "./useHiddenAmounts";
|
import { useHiddenAmounts } from "./useHiddenAmounts";
|
||||||
|
|
@ -94,11 +92,21 @@ function TxnRow({ txn, hidden }: { txn: Txn; hidden: boolean }) {
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
<IconChip
|
<span
|
||||||
icon={isTransfer ? "repeat" : cat.icon}
|
aria-hidden
|
||||||
fg={isTransfer ? "var(--seed-color-fg-neutral-muted, #6b7280)" : cat.fg}
|
style={{
|
||||||
tint={isTransfer ? "var(--seed-color-bg-neutral-subtle, #eef0f2)" : cat.tint}
|
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>
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||||
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
<span style={{ fontSize: 15, fontWeight: 700, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||||
{txn.title || cat.name}
|
{txn.title || cat.name}
|
||||||
|
|
@ -113,42 +121,19 @@ 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: a month navigator, income/expense totals for the loaded
|
* The Тооцоо list: income/expense totals for the loaded month, then the
|
||||||
* month, category filter chips, and the transaction rows grouped by day.
|
* transaction rows grouped by day. Mirrors `TransactionsView.ledgerTab` /
|
||||||
* Mirrors `TransactionsView.ledgerTab` / `TransactionsModel` (narrowed to
|
* `TransactionsModel.recompute` (narrowed to this task's scope — no month
|
||||||
* this task's scope): salary deposits (`salary === true`) are hidden by
|
* nav or category chips): salary deposits (`salary === true`) are hidden by
|
||||||
* default, and transfers are excluded from the totals and tagged in the row
|
* default, and transfers are excluded from the totals and tagged in the row
|
||||||
* meta line instead of colored green/red.
|
* meta line instead of colored green/red.
|
||||||
*/
|
*/
|
||||||
export function TransactionList() {
|
export function TransactionList() {
|
||||||
const [monthOffset, setMonthOffset] = useState(0);
|
const { data, isLoading } = useTransactions();
|
||||||
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 all = useMemo(() => data ?? [], [data]);
|
||||||
const hidden = useHiddenAmounts();
|
const hidden = useHiddenAmounts();
|
||||||
|
|
||||||
function changeMonth(next: number) {
|
|
||||||
setMonthOffset(next);
|
|
||||||
setSelectedCategory(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => {
|
||||||
let income = 0;
|
let income = 0;
|
||||||
let expense = 0;
|
let expense = 0;
|
||||||
|
|
@ -160,60 +145,33 @@ export function TransactionList() {
|
||||||
return { income, expense };
|
return { income, expense };
|
||||||
}, [all]);
|
}, [all]);
|
||||||
|
|
||||||
const categoryOptions = useMemo(() => categoriesIn(all), [all]);
|
|
||||||
|
|
||||||
const hasUncategorized = useMemo(
|
|
||||||
() => all.some((txn) => !txn.category && (txn.matchKey || txn.title)),
|
|
||||||
[all],
|
|
||||||
);
|
|
||||||
|
|
||||||
const visible = useMemo(
|
const visible = useMemo(
|
||||||
() =>
|
() =>
|
||||||
all
|
all
|
||||||
.filter((txn) => txn.salary !== true)
|
.filter((txn) => txn.salary !== true)
|
||||||
.filter((txn) => selectedCategory === null || txn.category === selectedCategory)
|
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
|
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()),
|
||||||
[all, selectedCategory],
|
[all],
|
||||||
);
|
);
|
||||||
const groups = useMemo(() => groupByDay(visible), [visible]);
|
const groups = useMemo(() => groupByDay(visible), [visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
|
<h1 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{s.list.title}</h1>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12 }}>
|
<HideAmountsToggle label={s.list.hideToggle} />
|
||||||
{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>
|
</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 }}>
|
<Card style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||||
<SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" />
|
<SummaryRow label={s.list.income} value={totals.income} hidden={hidden} tone="income" />
|
||||||
<SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" />
|
<SummaryRow label={s.list.expense} value={totals.expense} hidden={hidden} tone="expense" />
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CategoryChips categories={categoryOptions} selected={selectedCategory} onSelect={setSelectedCategory} />
|
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
|
<Skeleton style={{ height: 320, width: "100%", borderRadius: "var(--seed-radius-r3)" }} />
|
||||||
) : groups.length === 0 ? (
|
) : groups.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<EmptyState icon="calendar-check" title={s.list.empty} hint={s.list.emptyHint} compact />
|
<p style={{ margin: 0, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>{s.list.empty}</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
<Card style={{ display: "flex", flexDirection: "column", gap: 20 }}>
|
||||||
|
|
|
||||||
|
|
@ -1,35 +0,0 @@
|
||||||
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-р сар");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,22 +0,0 @@
|
||||||
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}-р сар`;
|
|
||||||
}
|
|
||||||
|
|
@ -10,22 +10,7 @@ export const accountingStrings = {
|
||||||
income: "Орлого",
|
income: "Орлого",
|
||||||
expense: "Зарлага",
|
expense: "Зарлага",
|
||||||
empty: "Гүйлгээ алга",
|
empty: "Гүйлгээ алга",
|
||||||
emptyHint: "Энэ сард гүйлгээ бүртгэгдээгүй байна.",
|
|
||||||
transferTag: "Шилжүүлэг",
|
transferTag: "Шилжүүлэг",
|
||||||
allCategories: "Бүгд",
|
|
||||||
categorizeCta: "Ангилах",
|
|
||||||
prevMonth: "Өмнөх сар",
|
|
||||||
nextMonth: "Дараагийн сар",
|
|
||||||
},
|
|
||||||
review: {
|
|
||||||
title: "Ангилалжуулах",
|
|
||||||
later: "Дараа нь",
|
|
||||||
remaining: (n: number) => `${n} ангилалгүй худалдагч үлдлээ`,
|
|
||||||
question: "Аль ангилалд хамаарах вэ?",
|
|
||||||
skip: "Алгасах",
|
|
||||||
done: "Бүгд ангилагдлаа",
|
|
||||||
close: "Хаах",
|
|
||||||
transactionCount: (n: number) => `${n} гүйлгээ`,
|
|
||||||
},
|
},
|
||||||
detail: {
|
detail: {
|
||||||
total: "Нийт",
|
total: "Нийт",
|
||||||
|
|
@ -56,19 +41,4 @@ export const accountingStrings = {
|
||||||
cancel: "Болих",
|
cancel: "Болих",
|
||||||
select: "Сонгох",
|
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;
|
} as const;
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,10 @@ import { useNetWorth, useManualAssets, useLending } from "@/api/hooks/reads";
|
||||||
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
|
import { useManualAssetMutations, useLendingMutations } from "@/api/hooks/mutations";
|
||||||
import { Card, HideAmountsToggle, MercuryButton, IconChip, SectionHeader, EmptyState, Icon, type IconName } from "@/ds";
|
import { Card, HideAmountsToggle, MercuryButton, IconChip, SectionHeader, EmptyState, Icon, type IconName } from "@/ds";
|
||||||
import { tugrik, tugrikRaw } from "@/ds/money";
|
import { tugrik, tugrikRaw } from "@/ds/money";
|
||||||
import type { Account, ManualAsset, Lending, Txn } from "@/api/schemas";
|
import type { Account, ManualAsset, Lending } from "@/api/schemas";
|
||||||
import { assetsStrings as s } from "./strings";
|
import { assetsStrings as s } from "./strings";
|
||||||
import { useHideAmountsTick } from "./useHideAmountsTick";
|
import { useHideAmountsTick } from "./useHideAmountsTick";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { TransactionPickerSheet } from "./TransactionPickerSheet";
|
|
||||||
import { linkedTxnIdsOf } from "./lendingLinks";
|
|
||||||
|
|
||||||
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
const mutedStyle: React.CSSProperties = { color: "var(--seed-color-fg-neutral-subtle)" };
|
||||||
const rowStyle: React.CSSProperties = {
|
const rowStyle: React.CSSProperties = {
|
||||||
|
|
@ -81,7 +79,6 @@ export function AssetsView() {
|
||||||
const accounts: Account[] = netWorth.data?.accounts ?? [];
|
const accounts: Account[] = netWorth.data?.accounts ?? [];
|
||||||
const assets: ManualAsset[] = manualAssets.data ?? [];
|
const assets: ManualAsset[] = manualAssets.data ?? [];
|
||||||
const loans: Lending[] = lending.data ?? [];
|
const loans: Lending[] = lending.data ?? [];
|
||||||
const linkedTxnIds = linkedTxnIdsOf(loans);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 28 }}>
|
||||||
|
|
@ -180,7 +177,6 @@ export function AssetsView() {
|
||||||
|
|
||||||
{addLoanOpen && (
|
{addLoanOpen && (
|
||||||
<AddLoanSheet
|
<AddLoanSheet
|
||||||
linkedTxnIds={linkedTxnIds}
|
|
||||||
onClose={() => setAddLoanOpen(false)}
|
onClose={() => setAddLoanOpen(false)}
|
||||||
saving={lendingMutations.create.isPending}
|
saving={lendingMutations.create.isPending}
|
||||||
onSave={async (values) => {
|
onSave={async (values) => {
|
||||||
|
|
@ -396,13 +392,8 @@ function LoanRow({ loan, onDelete }: { loan: Lending; onDelete: () => void }) {
|
||||||
<IconChip icon="hand-coins" {...LENDING_TINT} />
|
<IconChip icon="hand-coins" {...LENDING_TINT} />
|
||||||
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 2, minWidth: 0 }}>
|
||||||
<span style={titleStyle}>{loan.person}</span>
|
<span style={titleStyle}>{loan.person}</span>
|
||||||
<span style={{ display: "flex", alignItems: "center", gap: 6, fontSize: 12 }}>
|
<span style={{ fontSize: 12, color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
|
||||||
<span style={{ color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)" }}>
|
{statusLabel(loan)}
|
||||||
{statusLabel(loan)}
|
|
||||||
</span>
|
|
||||||
{loan.txnId != null && loan.txnId !== 0 && (
|
|
||||||
<span style={{ color: "var(--seed-color-fg-neutral-subtle)" }}>{s.lending.linkedBadge}</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -534,7 +525,6 @@ interface NewLoanValues {
|
||||||
lentOn: string;
|
lentOn: string;
|
||||||
dueOn?: string;
|
dueOn?: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
txnId?: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function todayISO(): string {
|
function todayISO(): string {
|
||||||
|
|
@ -542,12 +532,10 @@ function todayISO(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddLoanSheet({
|
function AddLoanSheet({
|
||||||
linkedTxnIds,
|
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
saving,
|
saving,
|
||||||
}: {
|
}: {
|
||||||
linkedTxnIds: Set<number>;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (values: NewLoanValues) => void | Promise<void>;
|
onSave: (values: NewLoanValues) => void | Promise<void>;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
|
|
@ -557,13 +545,10 @@ function AddLoanSheet({
|
||||||
const [lentOn, setLentOn] = React.useState(todayISO());
|
const [lentOn, setLentOn] = React.useState(todayISO());
|
||||||
const [dueOn, setDueOn] = React.useState("");
|
const [dueOn, setDueOn] = React.useState("");
|
||||||
const [note, setNote] = 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;
|
const canSave = person.trim().length > 0 && Number(amount) > 0 && !saving;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
|
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
|
||||||
<BottomSheetBackdrop />
|
<BottomSheetBackdrop />
|
||||||
<BottomSheetPositioner>
|
<BottomSheetPositioner>
|
||||||
|
|
@ -594,16 +579,6 @@ function AddLoanSheet({
|
||||||
<TextFieldRoot value={note} onValueChange={setNote} name="loan-note">
|
<TextFieldRoot value={note} onValueChange={setNote} name="loan-note">
|
||||||
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
|
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
|
||||||
</TextFieldRoot>
|
</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>
|
</BottomSheetBody>
|
||||||
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
|
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
|
||||||
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
|
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
|
||||||
|
|
@ -621,7 +596,6 @@ function AddLoanSheet({
|
||||||
lentOn,
|
lentOn,
|
||||||
dueOn: dueOn || undefined,
|
dueOn: dueOn || undefined,
|
||||||
note: note.trim() || undefined,
|
note: note.trim() || undefined,
|
||||||
txnId: linkedTxnId,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
@ -631,20 +605,5 @@ function AddLoanSheet({
|
||||||
</BottomSheetContent>
|
</BottomSheetContent>
|
||||||
</BottomSheetPositioner>
|
</BottomSheetPositioner>
|
||||||
</BottomSheetRoot>
|
</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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,6 @@ import { tugrikRaw } from "@/ds/money";
|
||||||
import { assetsStrings as s } from "./strings";
|
import { assetsStrings as s } from "./strings";
|
||||||
import { ConfirmDialog } from "./ConfirmDialog";
|
import { ConfirmDialog } from "./ConfirmDialog";
|
||||||
import { DetailHeader } from "./DetailHeader";
|
import { DetailHeader } from "./DetailHeader";
|
||||||
import { TransactionPickerSheet } from "./TransactionPickerSheet";
|
|
||||||
import { linkedTxnIdsOf } from "./lendingLinks";
|
|
||||||
|
|
||||||
type LendingRepayment = Lending["repayments"][number];
|
type LendingRepayment = Lending["repayments"][number];
|
||||||
|
|
||||||
|
|
@ -59,9 +57,7 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
||||||
const [deletingRepayment, setDeletingRepayment] = React.useState<LendingRepayment | null>(null);
|
const [deletingRepayment, setDeletingRepayment] = React.useState<LendingRepayment | null>(null);
|
||||||
const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false);
|
const [confirmDeleteEntry, setConfirmDeleteEntry] = React.useState(false);
|
||||||
|
|
||||||
const loans = lending.data ?? [];
|
const loan: Lending | undefined = (lending.data ?? []).find((l) => l.id === id);
|
||||||
const loan: Lending | undefined = loans.find((l) => l.id === id);
|
|
||||||
const linkedTxnIds = linkedTxnIdsOf(loans);
|
|
||||||
|
|
||||||
if (lending.isLoading && !loan) {
|
if (lending.isLoading && !loan) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -99,22 +95,19 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
||||||
<div style={{ marginTop: 6, ...mutedStyle }}>
|
<div style={{ marginTop: 6, ...mutedStyle }}>
|
||||||
{s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong>
|
{s.lending.total} <strong>{tugrikRaw(loan.principal)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ display: "flex", justifyContent: "center", alignItems: "center", gap: 8, marginTop: 10 }}>
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
display: "inline-block",
|
||||||
padding: "5px 12px",
|
marginTop: 10,
|
||||||
borderRadius: 999,
|
padding: "5px 12px",
|
||||||
fontSize: 12,
|
borderRadius: 999,
|
||||||
fontWeight: 600,
|
fontSize: 12,
|
||||||
background: "var(--seed-color-bg-layer-floating)",
|
fontWeight: 600,
|
||||||
color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)",
|
background: "var(--seed-color-bg-layer-floating)",
|
||||||
}}
|
color: loan.overdue ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-neutral-subtle)",
|
||||||
>
|
}}
|
||||||
{statusLabel(loan)}
|
>
|
||||||
</div>
|
{statusLabel(loan)}
|
||||||
{loan.txnId != null && loan.txnId !== 0 && (
|
|
||||||
<span style={{ fontSize: 12, ...mutedStyle }}>{s.lending.linkedBadge}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|
@ -134,12 +127,7 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
|
||||||
<IconChip icon="receipt" tint="#E1E5EA" fg="#42505F" size={36} />
|
<IconChip icon="receipt" tint="#E1E5EA" fg="#42505F" size={36} />
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0 }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
<div style={{ fontSize: 15, fontWeight: 700 }}>{tugrikRaw(r.amount)}</div>
|
||||||
<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 }}>
|
<div style={{ fontSize: 12, ...mutedStyle }}>
|
||||||
{r.paidOn}
|
{r.paidOn}
|
||||||
{r.note ? ` · ${r.note}` : ""}
|
{r.note ? ` · ${r.note}` : ""}
|
||||||
|
|
@ -172,7 +160,6 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
||||||
|
|
||||||
{addRepaymentOpen && (
|
{addRepaymentOpen && (
|
||||||
<AddRepaymentSheet
|
<AddRepaymentSheet
|
||||||
linkedTxnIds={linkedTxnIds}
|
|
||||||
onClose={() => setAddRepaymentOpen(false)}
|
onClose={() => setAddRepaymentOpen(false)}
|
||||||
saving={mutations.addRepayment.isPending}
|
saving={mutations.addRepayment.isPending}
|
||||||
onSave={async (values) => {
|
onSave={async (values) => {
|
||||||
|
|
@ -208,26 +195,21 @@ export function LendingDetail({ id }: LendingDetailProps) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function AddRepaymentSheet({
|
function AddRepaymentSheet({
|
||||||
linkedTxnIds,
|
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
saving,
|
saving,
|
||||||
}: {
|
}: {
|
||||||
linkedTxnIds: Set<number>;
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (values: { amount: string; paidOn: string; note?: string; txnId?: number }) => void | Promise<void>;
|
onSave: (values: { amount: string; paidOn: string; note?: string }) => void | Promise<void>;
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [amount, setAmount] = React.useState("");
|
const [amount, setAmount] = React.useState("");
|
||||||
const [paidOn, setPaidOn] = React.useState(todayISO());
|
const [paidOn, setPaidOn] = React.useState(todayISO());
|
||||||
const [note, setNote] = React.useState("");
|
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;
|
const canSave = Number(amount) > 0 && !saving;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
|
<BottomSheetRoot open onOpenChange={(next) => { if (!next) onClose(); }}>
|
||||||
<BottomSheetBackdrop />
|
<BottomSheetBackdrop />
|
||||||
<BottomSheetPositioner>
|
<BottomSheetPositioner>
|
||||||
|
|
@ -252,16 +234,6 @@ function AddRepaymentSheet({
|
||||||
<TextFieldRoot value={note} onValueChange={setNote} name="repayment-note">
|
<TextFieldRoot value={note} onValueChange={setNote} name="repayment-note">
|
||||||
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
|
<TextFieldInput placeholder={s.lending.fields.note} aria-label={s.lending.fields.note} />
|
||||||
</TextFieldRoot>
|
</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>
|
</BottomSheetBody>
|
||||||
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
|
<BottomSheetFooter style={{ display: "flex", gap: 12 }}>
|
||||||
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
|
<MercuryButton variant="secondary" onClick={onClose} style={{ flex: 1 }}>
|
||||||
|
|
@ -272,7 +244,7 @@ function AddRepaymentSheet({
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
disabled={!canSave}
|
disabled={!canSave}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined, txnId: linkedTxnId })}
|
onClick={() => onSave({ amount, paidOn, note: note.trim() || undefined })}
|
||||||
>
|
>
|
||||||
{s.common.save}
|
{s.common.save}
|
||||||
</MercuryButton>
|
</MercuryButton>
|
||||||
|
|
@ -280,19 +252,5 @@ function AddRepaymentSheet({
|
||||||
</BottomSheetContent>
|
</BottomSheetContent>
|
||||||
</BottomSheetPositioner>
|
</BottomSheetPositioner>
|
||||||
</BottomSheetRoot>
|
</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);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,138 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,17 +0,0 @@
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
@ -82,9 +82,7 @@ export const assetsStrings = {
|
||||||
lentOn: "Өгсөн огноо",
|
lentOn: "Өгсөн огноо",
|
||||||
dueOn: "Төлөх огноо",
|
dueOn: "Төлөх огноо",
|
||||||
note: "Тэмдэглэл",
|
note: "Тэмдэглэл",
|
||||||
linkTxn: "Гүйлгээ холбох",
|
|
||||||
},
|
},
|
||||||
linkedBadge: "Холбоотой",
|
|
||||||
repayments: {
|
repayments: {
|
||||||
title: "Төлөлтийн түүх",
|
title: "Төлөлтийн түүх",
|
||||||
empty: "Төлөлт бүртгэгдээгүй",
|
empty: "Төлөлт бүртгэгдээгүй",
|
||||||
|
|
@ -92,11 +90,6 @@ export const assetsStrings = {
|
||||||
deleteTitle: "Төлөлт устгах уу?",
|
deleteTitle: "Төлөлт устгах уу?",
|
||||||
deleteDescription: "Энэ төлөлтийг түүхээс хасна.",
|
deleteDescription: "Энэ төлөлтийг түүхээс хасна.",
|
||||||
},
|
},
|
||||||
picker: {
|
|
||||||
title: "Гүйлгээ сонгох",
|
|
||||||
search: "Хайх",
|
|
||||||
empty: "Гүйлгээ олдсонгүй",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
accountDetail: {
|
accountDetail: {
|
||||||
balance: "Үлдэгдэл",
|
balance: "Үлдэгдэл",
|
||||||
|
|
|
||||||
|
|
@ -1,106 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,69 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -15,7 +15,6 @@ const ERROR_MESSAGE_BY_CODE: Record<string, string> = {
|
||||||
email_taken: authStrings.errors.emailTaken,
|
email_taken: authStrings.errors.emailTaken,
|
||||||
invalid_code: authStrings.errors.invalidCode,
|
invalid_code: authStrings.errors.invalidCode,
|
||||||
invalid_challenge: authStrings.errors.invalidCode,
|
invalid_challenge: authStrings.errors.invalidCode,
|
||||||
invalid_token: authStrings.errors.invalidToken,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function messageForCode(code: string | undefined): string {
|
function messageForCode(code: string | undefined): string {
|
||||||
|
|
@ -24,7 +23,7 @@ function messageForCode(code: string | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postAuth(
|
async function postAuth(
|
||||||
path: "login" | "register" | "forgot" | "2fa" | "reset" | "verify",
|
path: "login" | "register" | "forgot" | "2fa",
|
||||||
body: unknown,
|
body: unknown,
|
||||||
): Promise<AuthActionResult> {
|
): Promise<AuthActionResult> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|
@ -74,20 +73,3 @@ export function submit2FA(
|
||||||
export function submitForgot(email: string): Promise<AuthActionResult> {
|
export function submitForgot(email: string): Promise<AuthActionResult> {
|
||||||
return postAuth("forgot", { email });
|
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 });
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -28,26 +28,6 @@ export const authStrings = {
|
||||||
submit: "Сэргээх холбоос авах",
|
submit: "Сэргээх холбоос авах",
|
||||||
sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.",
|
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: {
|
twoFactor: {
|
||||||
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
|
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
|
||||||
code: "Баталгаажуулах код",
|
code: "Баталгаажуулах код",
|
||||||
|
|
@ -60,7 +40,6 @@ export const authStrings = {
|
||||||
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
|
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
|
||||||
emailTaken: "Энэ имэйл бүртгэлтэй байна.",
|
emailTaken: "Энэ имэйл бүртгэлтэй байна.",
|
||||||
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
|
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
|
||||||
invalidToken: "Холбоосны хугацаа дууссан эсвэл буруу байна.",
|
|
||||||
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
|
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
|
||||||
unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
|
unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,6 @@ import { tugrik, tugrikShort } from "@/ds/money";
|
||||||
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
||||||
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
||||||
import type { Budget, SavingsGoal, Account } from "@/api/schemas";
|
import type { Budget, SavingsGoal, Account } from "@/api/schemas";
|
||||||
import { SpendBreakdown } from "./SpendBreakdown";
|
|
||||||
import { plannerStrings as s } from "./strings";
|
import { plannerStrings as s } from "./strings";
|
||||||
|
|
||||||
type Horizon = "day" | "week" | "month";
|
type Horizon = "day" | "week" | "month";
|
||||||
|
|
@ -83,7 +82,6 @@ export function PlannerView() {
|
||||||
const goalMutations = useSavingsGoalMutations();
|
const goalMutations = useSavingsGoalMutations();
|
||||||
|
|
||||||
const [horizon, setHorizon] = React.useState<Horizon>("day");
|
const [horizon, setHorizon] = React.useState<Horizon>("day");
|
||||||
const [breakdownOpen, setBreakdownOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const accounts: Account[] = netWorth?.accounts ?? [];
|
const accounts: Account[] = netWorth?.accounts ?? [];
|
||||||
|
|
||||||
|
|
@ -169,15 +167,6 @@ export function PlannerView() {
|
||||||
limit={overallLimit}
|
limit={overallLimit}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onSave={(v) => saveOverall(horizon, v)}
|
onSave={(v) => saveOverall(horizon, v)}
|
||||||
onOpenBreakdown={() => setBreakdownOpen(true)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<SpendBreakdown
|
|
||||||
open={breakdownOpen}
|
|
||||||
onOpenChange={setBreakdownOpen}
|
|
||||||
horizonLabel={OVERALL_LABEL[horizon]}
|
|
||||||
total={overallSpent}
|
|
||||||
rows={rows}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategoryList
|
<CategoryList
|
||||||
|
|
@ -317,14 +306,12 @@ function OverallCard({
|
||||||
limit,
|
limit,
|
||||||
loading,
|
loading,
|
||||||
onSave,
|
onSave,
|
||||||
onOpenBreakdown,
|
|
||||||
}: {
|
}: {
|
||||||
horizon: Horizon;
|
horizon: Horizon;
|
||||||
spent: number;
|
spent: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSave: (value: number) => void;
|
onSave: (value: number) => void;
|
||||||
onOpenBreakdown: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const [draft, setDraft] = React.useState("");
|
const [draft, setDraft] = React.useState("");
|
||||||
|
|
@ -365,54 +352,31 @@ function OverallCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<button
|
||||||
<div className="flex items-center justify-end">
|
type="button"
|
||||||
<button
|
onClick={() => {
|
||||||
type="button"
|
setDraft(limit > 0 ? String(limit) : "");
|
||||||
onClick={onOpenBreakdown}
|
setEditing(true);
|
||||||
className="flex items-center gap-1"
|
}}
|
||||||
style={{
|
className="flex w-full items-center gap-4 text-left"
|
||||||
background: "none",
|
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }}
|
||||||
border: "none",
|
>
|
||||||
padding: "2px 0",
|
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
|
||||||
cursor: "pointer",
|
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
|
||||||
color: "#fff",
|
<ProgressCircleTrack style={{ opacity: 0.3 }} />
|
||||||
opacity: 0.85,
|
<ProgressCircleRange />
|
||||||
fontSize: 12,
|
</ProgressCircleRoot>
|
||||||
fontWeight: 600,
|
<div className="flex flex-col gap-1">
|
||||||
}}
|
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
|
||||||
>
|
<span style={{ fontSize: 26, fontWeight: 700 }}>
|
||||||
<Icon name="trending" size={13} />
|
{tugrikShort(spent)}
|
||||||
{s.overall.breakdown}
|
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
|
||||||
</button>
|
</span>
|
||||||
</div>
|
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
|
||||||
|
|
||||||
<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>
|
</div>
|
||||||
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
|
</div>
|
||||||
</button>
|
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -626,7 +590,7 @@ function AmountField({ label, value, onChange }: { label: string; value: string;
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
|
function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
|
||||||
return (
|
return (
|
||||||
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
|
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -25,14 +25,6 @@ export const plannerStrings = {
|
||||||
},
|
},
|
||||||
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
||||||
unsetHint: "Лимит тохируулаагүй",
|
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: {
|
categories: {
|
||||||
title: "Ангиллын лимит",
|
title: "Ангиллын лимит",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue