diff --git a/src/app/(app)/profile/categories/page.tsx b/src/app/(app)/profile/categories/page.tsx new file mode 100644 index 0000000..8ff6ce6 --- /dev/null +++ b/src/app/(app)/profile/categories/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { CategoriesManager } from "@/features/profile/CategoriesManager"; + +export default function ProfileCategoriesPage() { + const router = useRouter(); + return router.push("/profile")} />; +} diff --git a/src/app/(app)/profile/page.tsx b/src/app/(app)/profile/page.tsx new file mode 100644 index 0000000..eba50d1 --- /dev/null +++ b/src/app/(app)/profile/page.tsx @@ -0,0 +1,5 @@ +import { ProfileView } from "@/features/profile/ProfileView"; + +export default function ProfilePage() { + return ; +} diff --git a/src/app/(app)/profile/subscriptions/page.tsx b/src/app/(app)/profile/subscriptions/page.tsx new file mode 100644 index 0000000..6a27768 --- /dev/null +++ b/src/app/(app)/profile/subscriptions/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SubscriptionsView } from "@/features/profile/SubscriptionsView"; + +export default function ProfileSubscriptionsPage() { + const router = useRouter(); + return router.push("/profile")} />; +} diff --git a/src/features/profile/CategoriesManager.tsx b/src/features/profile/CategoriesManager.tsx new file mode 100644 index 0000000..05bb9b2 --- /dev/null +++ b/src/features/profile/CategoriesManager.tsx @@ -0,0 +1,388 @@ +"use client"; + +import * as React from "react"; +import { + TextFieldRoot, + TextFieldInput, + DialogRoot, + DialogBackdrop, + DialogPositioner, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, + Skeleton, +} from "@seed-design/react"; +import { MercuryButton } from "@/ds/MercuryButton"; +import { useCategories } from "@/api/hooks/reads"; +import { useCategoryMutations } from "@/api/hooks/mutations"; +import type { Category } from "@/api/schemas"; +import { profileStrings } from "./strings"; + +// A small curated glyph set stands in for the iOS `SeedMulticolorIcon` catalog +// (not ported to web) — plain emoji stored verbatim in the `icon` string field. +const ICON_CHOICES = ["🍔", "🚗", "🏠", "💊", "🎮", "🛍️", "💡", "📚", "✈️", "🎁", "💰", "📱", "🐾", "⚽", "☕", "🧾"]; + +interface CategoryGroup { + name: string; + icon?: string | null; + children: Category[]; +} + +/** Mirrors `CategoriesModel.load()` in CategoriesView.swift: a depth-0 row + * starts a new group, every following depth>0 row until the next depth-0 + * belongs to it. */ +function groupCategories(categories: Category[]): CategoryGroup[] { + const groups: CategoryGroup[] = []; + let current: CategoryGroup | null = null; + for (const c of categories) { + if (c.depth === 0) { + current = { name: c.name, icon: c.icon, children: [] }; + groups.push(current); + } else if (current) { + current.children.push(c); + } + } + return groups; +} + +type SheetState = { mode: "add" } | { mode: "edit"; category: Category } | null; + +export interface CategoriesManagerProps { + onBack?: () => void; +} + +export function CategoriesManager({ onBack }: CategoriesManagerProps) { + const { data: categories, isLoading } = useCategories(); + const { add, update, delete: remove } = useCategoryMutations(); + + const [sheet, setSheet] = React.useState(null); + const [deleteTarget, setDeleteTarget] = React.useState(null); + + const groups = React.useMemo(() => groupCategories(categories ?? []), [categories]); + const parentNames = React.useMemo(() => groups.map((g) => g.name), [groups]); + + async function handleConfirmDelete() { + if (!deleteTarget) return; + await remove.mutateAsync(deleteTarget); + setDeleteTarget(null); + } + + if (sheet) { + return ( + setSheet(null)} + onSave={async (values) => { + if (sheet.mode === "add") { + await add.mutateAsync({ + name: values.name, + kind: "expense", + parent: values.parent || undefined, + icon: values.icon, + }); + } else { + await update.mutateAsync({ + oldName: sheet.category.name, + newName: values.name, + parent: values.parent ? values.parent : null, + icon: values.icon, + }); + } + setSheet(null); + }} + /> + ); + } + + return ( +
+
+ {onBack && ( + + )} +

{profileStrings.categories.title}

+ setSheet({ mode: "add" })}> + + {profileStrings.categories.add} + +
+ + {isLoading && ( +
+ + +
+ )} + + {!isLoading && groups.length === 0 && ( +

{profileStrings.categories.empty}

+ )} + + {groups.map((group) => ( +
+ setSheet({ mode: "edit", category: { name: group.name, kind: "expense", depth: 0, icon: group.icon } })} + onDelete={() => setDeleteTarget(group.name)} + /> + {group.children.length > 0 && ( +
+ {group.children.map((child) => ( + setSheet({ mode: "edit", category: child })} + onDelete={() => setDeleteTarget(child.name)} + /> + ))} +
+ )} +
+ ))} + + { if (!open) setDeleteTarget(null); }}> + + + + + + {profileStrings.categories.deleteTitle} + + + + {deleteTarget ? profileStrings.categories.deleteMessage(deleteTarget) : ""} + + + setDeleteTarget(null)} style={{ flex: 1 }}> + {profileStrings.categories.cancel} + + + {profileStrings.categories.deleteAction} + + + + + +
+ ); +} + +function CategoryRow({ + icon, + name, + heading, + onEdit, + onDelete, +}: { + icon?: string | null; + name: string; + heading?: boolean; + onEdit: () => void; + onDelete: () => void; +}) { + return ( +
+ {icon || "🏷️"} + {name} + + +
+ ); +} + +function CategoryChip({ + category, + onEdit, + onDelete, +}: { + category: Category; + onEdit: () => void; + onDelete: () => void; +}) { + return ( +
+ {category.icon || "🏷️"} + {category.name} + + +
+ ); +} + +function CategoryEditor({ + parents, + initial, + onCancel, + onSave, +}: { + parents: string[]; + initial?: Category; + onCancel: () => void; + onSave: (values: { name: string; parent: string; icon: string }) => Promise; +}) { + const [name, setName] = React.useState(initial?.name ?? ""); + const [parent, setParent] = React.useState(""); + const [icon, setIcon] = React.useState(initial?.icon || ICON_CHOICES[0]); + const [saving, setSaving] = React.useState(false); + const editing = Boolean(initial); + const canSave = name.trim().length > 0 && !saving; + + async function handleSave() { + if (!canSave) return; + setSaving(true); + try { + await onSave({ name: name.trim(), parent, icon }); + } finally { + setSaving(false); + } + } + + return ( +
+
+ +

+ {editing ? profileStrings.categories.edit : profileStrings.categories.add} +

+
+ + + + + +
+

+ {profileStrings.categories.icon} +

+
+ {ICON_CHOICES.map((choice) => ( + + ))} +
+
+ + + + + {profileStrings.categories.save} + +
+ ); +} diff --git a/src/features/profile/ConnectedBanks.tsx b/src/features/profile/ConnectedBanks.tsx new file mode 100644 index 0000000..4d2bde4 --- /dev/null +++ b/src/features/profile/ConnectedBanks.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useConnections } from "@/api/hooks/reads"; +import { Skeleton } from "@seed-design/react"; +import { profileStrings } from "./strings"; + +/** + * Read-only connected-banks list (Task 13 scope: NO connect/disconnect here — + * that stays a mobile-only action per the brief). Mirrors the bank rows in + * `ProfileView.swift`'s `banksCard`, minus the connect/disconnect chips. + */ +export function ConnectedBanks() { + const { data: connections, isLoading } = useConnections(); + + return ( +
+

+ {profileStrings.banks.title} +

+ + {isLoading && ( +
+ + +
+ )} + + {!isLoading && (!connections || connections.length === 0) && ( +

+ {profileStrings.banks.empty} +

+ )} + + {!isLoading && connections && connections.length > 0 && ( +
    + {connections.map((c) => ( +
  • +
    + {c.bank} + + {c.username} + +
    + {c.courierManaged && ( + + {profileStrings.banks.courierManaged} + + )} +
  • + ))} +
+ )} + +

+ {profileStrings.banks.manageNote} +

+
+ ); +} diff --git a/src/features/profile/ProfileView.tsx b/src/features/profile/ProfileView.tsx new file mode 100644 index 0000000..d167e98 --- /dev/null +++ b/src/features/profile/ProfileView.tsx @@ -0,0 +1,124 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { Card, HideAmountsToggle, MercuryButton } from "@/ds"; +import { useMe, useSettings } from "@/api/hooks/reads"; +import { profileStrings } from "./strings"; +import { SettingsForm } from "./SettingsForm"; +import { ConnectedBanks } from "./ConnectedBanks"; + +/** The Миний (profile) tab hub: account summary, hide-amounts toggle, + * entries into Categories/Subscriptions (their own routes) and Settings + * (rendered inline, no dedicated route), the read-only connected-banks list, + * and logout. Ports `ProfileView.swift`'s layout minus bank connect/disconnect + * (mobile-only per the Task 13 brief) and the not-yet-wired static rows + * (Шинэ мэдээ / Түгээмэл асуултууд / Санал хүсэлт / Үйлчилгээний нөхцөл). */ +export function ProfileView() { + const router = useRouter(); + const { data: me } = useMe(); + const { data: settings } = useSettings(); + const [showSettings, setShowSettings] = React.useState(false); + const [loggingOut, setLoggingOut] = React.useState(false); + + const emailLocal = me?.email ? me.email.split("@")[0] : ""; + const displayName = settings?.holderName || emailLocal || me?.email || ""; + + async function handleLogout() { + setLoggingOut(true); + try { + await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); + } finally { + router.push("/login"); + } + } + + if (showSettings) { + return setShowSettings(false)} />; + } + + return ( +
+
+

{profileStrings.header.title}

+ +
+ + +
+ {displayName} + {me?.email && ( + + {profileStrings.account.handlePrefix} + {emailLocal} + + )} +
+
+ + + + + + + + + + + {profileStrings.logout} + +
+ ); +} + +function MenuRow({ label, onClick }: { label: string; onClick: () => void }) { + return ( + + ); +} + +function MenuLink({ label, href }: { label: string; href: string }) { + return ( + + {label} + + › + + + ); +} diff --git a/src/features/profile/SettingsForm.test.tsx b/src/features/profile/SettingsForm.test.tsx new file mode 100644 index 0000000..15990a8 --- /dev/null +++ b/src/features/profile/SettingsForm.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from "@testing-library/react"; +import { it, expect, vi } from "vitest"; +import type { Settings } from "@/api/schemas"; + +const settingsFixture: Settings = { + holderName: "Бат-Эрдэнэ", + employer: "Меркури ХХК", + salaryKeywords: ["цалин", "salary"], + payDays: [1, 15], + ownAccounts: ["1234567890"], + peerAccounts: ["0987654321"], + hideAmounts: false, +}; + +vi.mock("@/api/hooks/reads", () => ({ + useSettings: () => ({ data: settingsFixture, isLoading: false, isSuccess: true }), +})); + +vi.mock("@/api/hooks/mutations", () => ({ + useSaveSettings: () => ({ + mutateAsync: vi.fn().mockResolvedValue(settingsFixture), + isPending: false, + isError: false, + }), +})); + +import { SettingsForm } from "./SettingsForm"; + +it("renders the holder-name field pre-filled from useSettings", () => { + render(); + const input = screen.getByLabelText("Данс эзэмшигчийн нэр") as HTMLInputElement; + expect(input.value).toBe(settingsFixture.holderName); +}); diff --git a/src/features/profile/SettingsForm.tsx b/src/features/profile/SettingsForm.tsx new file mode 100644 index 0000000..b5e652d --- /dev/null +++ b/src/features/profile/SettingsForm.tsx @@ -0,0 +1,202 @@ +"use client"; + +import * as React from "react"; +import { TextFieldRoot, TextFieldInput, SwitchRoot, SwitchControl, SwitchThumb, SwitchLabel, Skeleton } from "@seed-design/react"; +import { MercuryButton } from "@/ds/MercuryButton"; +import { useSettings } from "@/api/hooks/reads"; +import { useSaveSettings } from "@/api/hooks/mutations"; +import type { Settings } from "@/api/schemas"; +import { profileStrings } from "./strings"; + +interface FormState { + holderName: string; + employer: string; + salaryKeywords: string; + payDays: string; + ownAccounts: string; + peerAccounts: string; + hideAmounts: boolean; +} + +const EMPTY_FORM: FormState = { + holderName: "", + employer: "", + salaryKeywords: "", + payDays: "", + ownAccounts: "", + peerAccounts: "", + hideAmounts: false, +}; + +function toFormState(settings: Settings | undefined): FormState { + if (!settings) return EMPTY_FORM; + return { + holderName: settings.holderName, + employer: settings.employer, + salaryKeywords: settings.salaryKeywords.join(", "), + payDays: settings.payDays.join(", "), + ownAccounts: settings.ownAccounts.join(", "), + peerAccounts: settings.peerAccounts.join(", "), + hideAmounts: settings.hideAmounts ?? false, + }; +} + +function splitList(value: string): string[] { + return value + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0); +} + +function splitNumberList(value: string): number[] { + return splitList(value) + .map((v) => Number(v)) + .filter((n) => Number.isFinite(n)); +} + +export interface SettingsFormProps { + onBack?: () => void; +} + +/** Edits the account-level settings (holder name, employer, salary detection + * keywords/pay days, own/peer account lists, hide-amounts) that back the + * backend's auto-categorization — not currently exposed anywhere in the iOS + * app, but commissioned for the web app per the Task 13 brief. */ +export function SettingsForm({ onBack }: SettingsFormProps) { + const query = useSettings(); + const save = useSaveSettings(); + + const initialized = React.useRef(Boolean(query.data)); + const [form, setForm] = React.useState(() => toFormState(query.data)); + const [savedAt, setSavedAt] = React.useState(null); + + React.useEffect(() => { + if (!initialized.current && query.data) { + setForm(toFormState(query.data)); + initialized.current = true; + } + }, [query.data]); + + function field(key: keyof Omit) { + return { + value: form[key], + onValueChange: (v: string) => setForm((f) => ({ ...f, [key]: v })), + }; + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setSavedAt(null); + await save.mutateAsync({ + holderName: form.holderName.trim(), + employer: form.employer.trim(), + salaryKeywords: splitList(form.salaryKeywords), + payDays: splitNumberList(form.payDays), + ownAccounts: splitList(form.ownAccounts), + peerAccounts: splitList(form.peerAccounts), + hideAmounts: form.hideAmounts, + }); + setSavedAt(Date.now()); + } + + if (query.isLoading && !query.data) { + return ( +
+ + + +
+ ); + } + + return ( +
+
+ {onBack && ( + + )} +

{profileStrings.settings.title}

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + setForm((f) => ({ ...f, hideAmounts: v }))} + > + + + + {profileStrings.settings.hideAmounts} + + + {save.isError && ( +

+ {profileStrings.settings.error} +

+ )} + {savedAt && !save.isPending && ( +

+ {profileStrings.settings.saved} +

+ )} + + + {profileStrings.settings.save} + +
+
+ ); +} + +function Labeled({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( + + ); +} diff --git a/src/features/profile/SubscriptionsView.tsx b/src/features/profile/SubscriptionsView.tsx new file mode 100644 index 0000000..559553d --- /dev/null +++ b/src/features/profile/SubscriptionsView.tsx @@ -0,0 +1,261 @@ +"use client"; + +import * as React from "react"; +import { TextFieldRoot, TextFieldInput, Skeleton } from "@seed-design/react"; +import { MercuryButton } from "@/ds/MercuryButton"; +import { tugrik } from "@/ds/money"; +import { useSubscriptions } from "@/api/hooks/reads"; +import { useSubscriptionMutations } from "@/api/hooks/mutations"; +import type { Subscription } from "@/api/schemas"; +import { profileStrings } from "./strings"; + +export interface SubscriptionsViewProps { + onBack?: () => void; +} + +/** Detected (subscriptions + bills) and manually-added recurring items, + * mirroring the `/subscriptions` response consumed by iOS's + * `ManualSubscriptionView`/`TransactionDetailView`. Detected rows can be + * deactivated (their matchKey stops being force-included); manual rows can + * be added and deleted. There is no "edit manual" hook exposed to this task, + * so manual rows are add/delete only. */ +export function SubscriptionsView({ onBack }: SubscriptionsViewProps) { + const { data, isLoading } = useSubscriptions(); + const { setSubscription, createManualSub, deleteManualSub } = useSubscriptionMutations(); + const [showAdd, setShowAdd] = React.useState(false); + + const all = [...(data?.subscriptions ?? []), ...(data?.bills ?? [])]; + const detected = all.filter((s) => !s.manual); + const manual = all.filter((s) => s.manual); + + async function handleDeactivate(sub: Subscription) { + if (!sub.matchKey) return; + await setSubscription.mutateAsync({ matchKey: sub.matchKey, name: sub.label, active: false }); + } + + async function handleDeleteManual(sub: Subscription) { + if (sub.id == null) return; + await deleteManualSub.mutateAsync(sub.id); + } + + if (showAdd) { + return ( + setShowAdd(false)} + onSave={async (values) => { + await createManualSub.mutateAsync(values); + setShowAdd(false); + }} + /> + ); + } + + return ( +
+
+ {onBack && ( + + )} +

{profileStrings.subscriptions.title}

+ setShowAdd(true)}> + + {profileStrings.subscriptions.addManual} + +
+ + {isLoading && ( +
+ + +
+ )} + + {!isLoading && all.length === 0 && ( +

{profileStrings.subscriptions.empty}

+ )} + + {!isLoading && detected.length > 0 && ( +
+

+ {profileStrings.subscriptions.detected} +

+
+ {detected.map((s) => ( + handleDeactivate(s)} + pending={setSubscription.isPending} + /> + ))} +
+
+ )} + + {!isLoading && manual.length > 0 && ( +
+

+ {profileStrings.subscriptions.manual} +

+
+ {manual.map((s) => ( + handleDeleteManual(s)} + pending={deleteManualSub.isPending} + destructive + /> + ))} +
+
+ )} +
+ ); +} + +function SubscriptionRow({ + sub, + actionLabel, + onAction, + pending, + destructive, +}: { + sub: Subscription; + actionLabel: string; + onAction: () => void; + pending?: boolean; + destructive?: boolean; +}) { + return ( +
+
+ {sub.label} + + {tugrik(sub.monthly)} / {sub.cadence} + {sub.nextDue ? ` · ${sub.nextDue}` : ""} + +
+ +
+ ); +} + +function ManualSubscriptionForm({ + onCancel, + onSave, +}: { + onCancel: () => void; + onSave: (values: { name: string; amount: string; category?: string; nextDue?: string }) => Promise; +}) { + const [name, setName] = React.useState(""); + const [amount, setAmount] = React.useState(""); + const [category, setCategory] = React.useState(""); + const [nextDue, setNextDue] = React.useState(""); + const [saving, setSaving] = React.useState(false); + const [error, setError] = React.useState(); + + const canSave = name.trim().length > 0 && Number(amount) > 0 && !saving; + + async function handleSave() { + if (!canSave) return; + setSaving(true); + setError(undefined); + try { + await onSave({ + name: name.trim(), + amount, + category: category.trim() || undefined, + nextDue: nextDue || undefined, + }); + } catch { + setError(profileStrings.subscriptions.saveError); + } finally { + setSaving(false); + } + } + + return ( +
+
+ +

{profileStrings.subscriptions.addManual}

+
+ + + + + + + + + + {error && ( +

+ {error} +

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