From f9195c5ffa0bfd0a7e3b192d42f2da65844913ea Mon Sep 17 00:00:00 2001 From: Munkherdene Date: Sat, 22 Aug 2026 20:57:01 +0800 Subject: [PATCH] feat(web): planner budget/limits/savings goals --- src/app/(app)/planner/[category]/page.tsx | 10 + src/app/(app)/planner/page.tsx | 5 + src/features/planner/CategoryTransactions.tsx | 103 +++ src/features/planner/PlannerView.test.tsx | 55 ++ src/features/planner/PlannerView.tsx | 821 ++++++++++++++++++ src/features/planner/strings.ts | 66 ++ 6 files changed, 1060 insertions(+) create mode 100644 src/app/(app)/planner/[category]/page.tsx create mode 100644 src/app/(app)/planner/page.tsx create mode 100644 src/features/planner/CategoryTransactions.tsx create mode 100644 src/features/planner/PlannerView.test.tsx create mode 100644 src/features/planner/PlannerView.tsx create mode 100644 src/features/planner/strings.ts diff --git a/src/app/(app)/planner/[category]/page.tsx b/src/app/(app)/planner/[category]/page.tsx new file mode 100644 index 0000000..27ff359 --- /dev/null +++ b/src/app/(app)/planner/[category]/page.tsx @@ -0,0 +1,10 @@ +import { CategoryTransactions } from "@/features/planner/CategoryTransactions"; + +export default async function CategoryTransactionsPage({ + params, +}: { + params: Promise<{ category: string }>; +}) { + const { category } = await params; + return ; +} diff --git a/src/app/(app)/planner/page.tsx b/src/app/(app)/planner/page.tsx new file mode 100644 index 0000000..1b75157 --- /dev/null +++ b/src/app/(app)/planner/page.tsx @@ -0,0 +1,5 @@ +import { PlannerView } from "@/features/planner/PlannerView"; + +export default function PlannerPage() { + return ; +} diff --git a/src/features/planner/CategoryTransactions.tsx b/src/features/planner/CategoryTransactions.tsx new file mode 100644 index 0000000..d624a23 --- /dev/null +++ b/src/features/planner/CategoryTransactions.tsx @@ -0,0 +1,103 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { TextFieldRoot, TextFieldInput } from "@seed-design/react"; +import { Card } from "@/ds"; +import { tugrik } from "@/ds/money"; +import { useTransactions } from "@/api/hooks/reads"; +import type { Txn } from "@/api/schemas"; +import { plannerStrings as s } from "./strings"; + +export interface CategoryTransactionsProps { + category: string; +} + +/** MM.dd from an RFC3339/ISO timestamp — mirrors CategoryTransactionsView's + * `shortDate` on iOS. Falls back to an empty string on unparsable input. */ +function shortDate(rfc: string): string { + const d = new Date(rfc); + if (Number.isNaN(d.getTime())) return ""; + const mm = String(d.getMonth() + 1).padStart(2, "0"); + const dd = String(d.getDate()).padStart(2, "0"); + return `${mm}.${dd}`; +} + +/** Every transaction in one category (server expands to sub-categories too) — + * opened by tapping a category-limit row on the planner hub. Ports + * `CategoryTransactionsView.swift`: header + filter + list, tap-free (the web + * port has no transaction detail cover yet). */ +export function CategoryTransactions({ category }: CategoryTransactionsProps) { + const { data, isLoading } = useTransactions({ + category, + from: "2020-01-01", + to: "2027-12-31", + limit: 300, + }); + const [search, setSearch] = React.useState(""); + + const rows: Txn[] = React.useMemo(() => { + const all = data ?? []; + const needle = search.trim().toLowerCase(); + if (!needle) return all; + return all.filter((t) => t.title.toLowerCase().includes(needle)); + }, [data, search]); + + return ( +
+
+ + ← + +

{category}

+
+ + + + + + + {isLoading &&

} + {!isLoading && rows.length === 0 && ( +

{s.categoryTransactions.empty}

+ )} + {!isLoading && rows.length > 0 && ( +
    + {rows.map((t, i) => { + const income = t.direction === "income"; + return ( +
  • +
    + {t.title || t.category} + + {shortDate(t.date)} · {t.category} + +
    + + {income ? "+" : "−"} + {tugrik(t.amount)} + +
  • + ); + })} +
+ )} +
+
+ ); +} diff --git a/src/features/planner/PlannerView.test.tsx b/src/features/planner/PlannerView.test.tsx new file mode 100644 index 0000000..0aa6916 --- /dev/null +++ b/src/features/planner/PlannerView.test.tsx @@ -0,0 +1,55 @@ +import { render, screen } from "@testing-library/react"; +import { describe, it, expect, vi } from "vitest"; +import type { Budget } from "@/api/schemas"; + +// jsdom has no CSS.supports(); Seed's SegmentedControl/TextField call it via +// @seed-design/react-supports to detect :focus-visible support. +if (typeof (globalThis as any).CSS === "undefined") { + (globalThis as any).CSS = { supports: () => false }; +} else if (typeof (globalThis as any).CSS.supports !== "function") { + (globalThis as any).CSS.supports = () => false; +} + +const budgetFixture: Budget = { + dayLimit: "50000", + weekLimit: "300000", + monthLimit: "1200000", + plannedIncome: "2000000", + plannedIncomeManual: "0", + loanObligations: "0", + savingsContributions: "0", + subscriptionContributions: "0", + availableIncome: "2000000", + categories: [{ name: "хоол", day: "10000", week: "60000", month: "240000" }], + savingsGoals: [], + report: { + day: { overallSpent: "15000", overallLimit: "50000", rows: [{ category: "хоол", spent: "5000", limit: "20000" }] }, + week: { overallSpent: "0", overallLimit: "300000", rows: [] }, + month: { overallSpent: "0", overallLimit: "1200000", rows: [] }, + }, +}; + +vi.mock("@/api/hooks/reads", () => ({ + useBudget: () => ({ data: budgetFixture, isLoading: false }), + useNetWorth: () => ({ data: { assets: "0", liabilities: "0", total: "0", accounts: [] }, isLoading: false }), +})); + +vi.mock("@/api/hooks/mutations", () => ({ + usePutBudget: () => ({ mutate: vi.fn() }), + useSavingsGoalMutations: () => ({ post: { mutate: vi.fn() }, delete: { mutate: vi.fn() } }), +})); + +import { PlannerView } from "./PlannerView"; + +describe("PlannerView", () => { + it("renders the day-horizon overall total and a category limit", () => { + render(); + + // Overall (day) card: spent / limit from budget.report.day. + expect(screen.getByText("15,000₮ / 50,000₮")).toBeInTheDocument(); + + // Category limit row: name + spent / limit from budget.report.day.rows. + expect(screen.getByText("хоол")).toBeInTheDocument(); + expect(screen.getByText("5,000₮ / 20,000₮")).toBeInTheDocument(); + }); +}); diff --git a/src/features/planner/PlannerView.tsx b/src/features/planner/PlannerView.tsx new file mode 100644 index 0000000..d8f0608 --- /dev/null +++ b/src/features/planner/PlannerView.tsx @@ -0,0 +1,821 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { + TextFieldRoot, + TextFieldInput, + SegmentedControlRoot, + SegmentedControlItem, + SegmentedControlItemHiddenInput, + ProgressCircleRoot, + ProgressCircleTrack, + ProgressCircleRange, + ContentDialogRoot, + ContentDialogBackdrop, + ContentDialogPositioner, + ContentDialogContent, + ContentDialogHeader, + ContentDialogTitle, + ContentDialogBody, + ContentDialogFooter, + Skeleton, +} from "@seed-design/react"; +import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds"; +import { tugrik, tugrikShort } from "@/ds/money"; +import { useBudget, useNetWorth } from "@/api/hooks/reads"; +import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations"; +import type { Budget, SavingsGoal, Account } from "@/api/schemas"; +import { plannerStrings as s } from "./strings"; + +type Horizon = "day" | "week" | "month"; +type CategoryLimit = Budget["categories"][number]; +type HorizonReport = Budget["report"]["day"]; +type LimitRow = HorizonReport["rows"][number]; + +function dec(v: string | undefined | null): number { + return parseFloat(v ?? "0") || 0; +} + +function onlyDigits(v: string): string { + return v.replace(/[^0-9]/g, ""); +} + +const HORIZON_LABEL: Record = { + day: s.horizon.day, + week: s.horizon.week, + month: s.horizon.month, +}; + +const OVERALL_LABEL: Record = { + day: s.overall.label.day, + week: s.overall.label.week, + month: s.overall.label.month, +}; + +/** The Төлөвлөгөө hub — ports `PlannerView.swift` + `LimitsHubModel.swift`. + * Shows the editable planned income, a day/week/month horizon toggle, the + * overall spend-vs-limit for that horizon, per-category limits, and savings + * goals. Every edit persists via `PUT /budget`, echoing back the full + * `categories` array (the DTO contract — nothing else may be dropped). */ +export function PlannerView() { + const { data: budget, isLoading } = useBudget(); + const { data: netWorth } = useNetWorth(); + const putBudget = usePutBudget(); + const goalMutations = useSavingsGoalMutations(); + + const [horizon, setHorizon] = React.useState("day"); + + const accounts: Account[] = netWorth?.accounts ?? []; + + function reportFor(h: Horizon): HorizonReport | undefined { + return budget?.report[h]; + } + + function categoryLimitFor(name: string): CategoryLimit { + return budget?.categories.find((c) => c.name === name) ?? { name, day: "0", week: "0", month: "0" }; + } + + /** Save a partial change, echoing the current budget for everything else + * (mirrors `LimitsHubModel.save` — nothing, including the planned-income + * override, may be silently lost). */ + function save(partial: { + dayLimit?: string; + weekLimit?: string; + monthLimit?: string; + plannedIncomeManual?: string; + categories?: CategoryLimit[]; + }) { + if (!budget) return; + putBudget.mutate({ + dayLimit: partial.dayLimit ?? budget.dayLimit, + weekLimit: partial.weekLimit ?? budget.weekLimit, + monthLimit: partial.monthLimit ?? budget.monthLimit, + plannedIncomeManual: partial.plannedIncomeManual ?? budget.plannedIncomeManual, + categories: partial.categories ?? budget.categories, + }); + } + + function saveOverall(h: Horizon, value: number) { + if (!budget) return; + save({ + dayLimit: h === "day" ? String(value) : budget.dayLimit, + weekLimit: h === "week" ? String(value) : budget.weekLimit, + monthLimit: h === "month" ? String(value) : budget.monthLimit, + }); + } + + function saveCategoryLimit(name: string, day: number, week: number, month: number) { + if (!budget) return; + const cats = [...budget.categories]; + const updated: CategoryLimit = { name, day: String(day), week: String(week), month: String(month) }; + const idx = cats.findIndex((c) => c.name === name); + if (idx >= 0) cats[idx] = updated; + else cats.push(updated); + save({ categories: cats }); + } + + function removeCategoryLimit(name: string) { + if (!budget) return; + save({ categories: budget.categories.filter((c) => c.name !== name) }); + } + + const report = reportFor(horizon); + const overallSpent = dec(report?.overallSpent); + const overallLimit = dec(report?.overallLimit); + const rows: LimitRow[] = report?.rows ?? []; + const goals: SavingsGoal[] = budget?.savingsGoals ?? []; + + return ( +
+
+

{s.header.title}

+ +
+ + save({ plannedIncomeManual: String(v) })} /> + + setHorizon(v as Horizon)}> + {(["day", "week", "month"] as const).map((h) => ( + + + {HORIZON_LABEL[h]} + + ))} + + + saveOverall(horizon, v)} + /> + + + + + goalMutations.post.mutate({ + originalName: goal.originalName, + name: goal.name, + target: String(goal.target), + monthlyContribution: String(goal.monthly), + accountId: goal.accountId, + targetDate: goal.targetDate, + }) + } + onDelete={(name) => goalMutations.delete.mutate(name)} + /> +
+ ); +} + +// --- Planned income --------------------------------------------------------- + +function PlannedIncomeCard({ + budget, + loading, + onSave, +}: { + budget: Budget | undefined; + loading: boolean; + onSave: (value: number) => void; +}) { + const [editing, setEditing] = React.useState(false); + const [draft, setDraft] = React.useState(""); + + const plannedIncome = dec(budget?.plannedIncome); + const loanObligations = dec(budget?.loanObligations); + const savingsContributions = dec(budget?.savingsContributions); + const availableIncome = dec(budget?.availableIncome); + const showBreakdown = loanObligations > 0 || savingsContributions > 0; + + return ( + + {loading ? ( + + ) : editing ? ( +
+ {s.plannedIncome.editTitle} + setDraft(onlyDigits(v))}> + + +

+ {s.plannedIncome.autoHint(tugrik(plannedIncome))} +

+
+ setEditing(false)} style={{ flex: 1 }}> + {s.amountEntry.cancel} + + { + onSave(parseFloat(draft) || 0); + setEditing(false); + }} + > + {s.amountEntry.save} + +
+
+ ) : ( + + )} +
+ ); +} + +function BreakdownRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) { + return ( +
+ + {label} + + {value} +
+ ); +} + +// --- Overall (horizon) card -------------------------------------------------- + +function OverallCard({ + horizon, + spent, + limit, + loading, + onSave, +}: { + horizon: Horizon; + spent: number; + limit: number; + loading: boolean; + onSave: (value: number) => void; +}) { + const [editing, setEditing] = React.useState(false); + const [draft, setDraft] = React.useState(""); + const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0; + + return ( +
+ {loading ? ( + + ) : editing ? ( +
+ {s.overall.editTitle(HORIZON_LABEL[horizon])} + setDraft(onlyDigits(v))}> + + +
+ setEditing(false)} style={{ flex: 1 }}> + {s.amountEntry.cancel} + + { + onSave(parseFloat(draft) || 0); + setEditing(false); + }} + > + {s.amountEntry.save} + +
+
+ ) : ( + + )} +
+ ); +} + +// --- Category limits --------------------------------------------------------- + +function CategoryList({ + loading, + rows, + horizon, + categoryLimitFor, + onSaveLimit, + onRemoveLimit, +}: { + loading: boolean; + rows: LimitRow[]; + horizon: Horizon; + categoryLimitFor: (name: string) => CategoryLimit; + onSaveLimit: (name: string, day: number, week: number, month: number) => void; + onRemoveLimit: (name: string) => void; +}) { + const [editingName, setEditingName] = React.useState(null); + const [drafts, setDrafts] = React.useState({ day: "", week: "", month: "" }); + const [adding, setAdding] = React.useState(false); + const [newName, setNewName] = React.useState(""); + const [confirmRemove, setConfirmRemove] = React.useState(null); + + function openEditor(name: string) { + const limit = categoryLimitFor(name); + setDrafts({ day: dec(limit.day) > 0 ? limit.day : "", week: dec(limit.week) > 0 ? limit.week : "", month: dec(limit.month) > 0 ? limit.month : "" }); + setEditingName(name); + } + + return ( + +
+

{s.categories.title}

+ { setNewName(""); setAdding(true); }}> + {s.categories.add} + +
+ + {adding && ( +
+ + + +
+ setAdding(false)}> + {s.amountEntry.cancel} + + { + const name = newName.trim(); + setAdding(false); + openEditor(name); + }} + > + {s.amountEntry.save} + +
+
+ )} + + {loading && ( +
+ + +
+ )} + + {!loading && rows.length === 0 && ( +

+ {s.categories.empty} +

+ )} + + {!loading && rows.length > 0 && ( +
    + {rows.map((row) => { + const spent = dec(row.spent); + const limit = dec(row.limit); + const over = limit > 0 && spent > limit; + const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0; + + if (editingName === row.category) { + return ( +
  • + {s.categories.editTitle(row.category, HORIZON_LABEL[horizon])} + setDrafts((d) => ({ ...d, day: v }))} /> + setDrafts((d) => ({ ...d, week: v }))} /> + setDrafts((d) => ({ ...d, month: v }))} /> +
    + setEditingName(null)}> + {s.amountEntry.cancel} + + { + onSaveLimit(row.category, parseFloat(drafts.day) || 0, parseFloat(drafts.week) || 0, parseFloat(drafts.month) || 0); + setEditingName(null); + }} + > + {s.amountEntry.save} + +
    + +
  • + ); + } + + return ( +
  • +
    + +
    + {row.category} + + {tugrik(spent)} + {limit > 0 ? ` / ${tugrik(limit)}` : " / —"} + +
    + + + +
    +
  • + ); + })} +
+ )} + + !open && setConfirmRemove(null)} + title={s.categories.removeConfirmTitle} + body={confirmRemove ? s.categories.removeConfirmBody(confirmRemove) : ""} + confirmLabel={s.categories.remove} + onConfirm={() => { + if (confirmRemove) { + onRemoveLimit(confirmRemove); + setEditingName(null); + } + }} + /> +
+ ); +} + +function AmountField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) { + return ( +
+ {label} + onChange(onlyDigits(v))} style={{ flex: 1 }}> + + +
+ ); +} + +function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) { + return ( +
+
+
+ ); +} + +// --- Savings goals ------------------------------------------------------------ + +interface GoalDraft { + originalName?: string; + name: string; + target: number; + monthly: number; + accountId: number; + targetDate: string; +} + +function SavingsSection({ + goals, + accounts, + onSave, + onDelete, +}: { + goals: SavingsGoal[]; + accounts: Account[]; + onSave: (goal: GoalDraft) => void; + onDelete: (name: string) => void; +}) { + const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null); + const [confirmDelete, setConfirmDelete] = React.useState(null); + + return ( + +
+

{s.savings.title}

+ setEditing({ mode: "add" })}> + {s.savings.add} + +
+ + {goals.length === 0 && !editing && ( +

+ {s.savings.empty} +

+ )} + + {goals.length > 0 && ( +
    + {goals.map((goal) => { + const saved = dec(goal.saved); + const target = dec(goal.target); + const monthly = dec(goal.monthlyContribution); + const remaining = Math.max(0, target - saved); + const done = target > 0 && saved >= target; + const percent = target > 0 ? Math.min(100, (saved / target) * 100) : 0; + return ( +
  • + +
  • + ); + })} +
+ )} + + {editing && ( + setEditing(null)} + onSave={(draft) => { + onSave(draft); + setEditing(null); + }} + onRequestDelete={() => editing.goal && setConfirmDelete(editing.goal.name)} + /> + )} + + !open && setConfirmDelete(null)} + title={s.savings.deleteConfirmTitle} + body={confirmDelete ? s.savings.deleteConfirmBody(confirmDelete) : ""} + confirmLabel={s.savings.delete} + onConfirm={() => { + if (confirmDelete) { + onDelete(confirmDelete); + setConfirmDelete(null); + setEditing(null); + } + }} + /> +
+ ); +} + +function GoalEditor({ + existing, + accounts, + onSave, + onCancel, + onRequestDelete, +}: { + existing?: SavingsGoal; + accounts: Account[]; + onSave: (draft: GoalDraft) => void; + onCancel: () => void; + onRequestDelete: () => void; +}) { + const [name, setName] = React.useState(existing?.name ?? ""); + const [editingName, setEditingName] = React.useState(false); + const [target, setTarget] = React.useState(existing?.target ?? ""); + const [monthly, setMonthly] = React.useState(existing?.monthlyContribution ?? ""); + const [accountId, setAccountId] = React.useState(existing?.accountId ?? 0); + const [targetDate, setTargetDate] = React.useState(existing?.targetDate ?? ""); + + const canSave = name.trim().length > 0 && (parseFloat(target) || 0) > 0; + + return ( +
+ {existing ? s.savings.editTitle : s.savings.addTitle} + + {editingName ? ( + { + setName(n); + setEditingName(false); + }} + onCancel={() => setEditingName(false)} + /> + ) : ( + + )} + + + + + + + + +
+ + {s.amountEntry.cancel} + + + onSave({ + originalName: existing?.name, + name: name.trim(), + target: parseFloat(target) || 0, + monthly: parseFloat(monthly) || 0, + accountId, + targetDate, + }) + } + > + {s.amountEntry.save} + +
+ + {existing && ( + + )} +
+ ); +} + +// --- Shared confirmation dialog (never a native alert) ----------------------- + +function ConfirmDialog({ + open, + onOpenChange, + title, + body, + confirmLabel, + onConfirm, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + body: string; + confirmLabel: string; + onConfirm: () => void; +}) { + return ( + + + + + + {title} + + +

{body}

+
+ + onOpenChange(false)}> + {s.amountEntry.cancel} + + { + onConfirm(); + onOpenChange(false); + }} + > + {confirmLabel} + + +
+
+
+ ); +} diff --git a/src/features/planner/strings.ts b/src/features/planner/strings.ts new file mode 100644 index 0000000..d9b1530 --- /dev/null +++ b/src/features/planner/strings.ts @@ -0,0 +1,66 @@ +// Planner feature copy, ported verbatim from +// ios/Mercury/Features/Planner/{PlannerView,LimitsHubModel,PlannerEditViews, +// SavingsGoalEditView,CategoryTransactionsView}.swift. +export const plannerStrings = { + header: { title: "Төлөвлөгөө" }, + horizon: { + day: "Өдөр", + week: "7 хоног", + month: "Сар", + }, + plannedIncome: { + label: "Төлөвлөгдсөн орлого", + loanObligations: "Зээлийн төлбөр", + savings: "Хадгаламж", + available: "Зарцуулах боломжтой", + editTitle: "Төлөвлөгдсөн орлого", + autoHint: (detected: string) => `Цалингаар илрүүлсэн: ${detected}`, + autoAction: "Автоматаар тооцох (цалингаар)", + }, + overall: { + label: { + day: "Өнөөдрийн лимит", + week: "7 хоногийн лимит", + month: "Энэ сарын лимит", + }, + editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`, + }, + categories: { + title: "Ангиллын лимит", + empty: "Лимит алга — ангилал нэмнэ үү", + add: "Ангилал нэмэх", + addNamePlaceholder: "Ангиллын нэр", + editTitle: (name: string, horizonLabel: string) => `${name} · ${horizonLabel}`, + remove: "Лимит хасах", + removeConfirmTitle: "Ангиллын лимит хасах уу?", + removeConfirmBody: (name: string) => `«${name}» ангиллын лимит хасагдана.`, + }, + savings: { + title: "Хадгаламж", + empty: "Зорилго алга — хадгаламжийн зорилго нэмнэ үү", + add: "Зорилго нэмэх", + linkAccount: "Данс холбох", + noAccount: "Холбохгүй", + perMonth: "Сар бүр", + remaining: "Үлдсэн", + addTitle: "Шинэ зорилго", + editTitle: "Зорилго засах", + name: "Нэр", + namePlaceholder: "Жишээ: Машины балон сан", + target: "Зорилтот дүн", + account: "Холбосон данс", + monthly: "Сар бүрийн хуримтлал", + date: "Зорилтот огноо", + delete: "Зорилго устгах", + deleteConfirmTitle: "Зорилго устгах уу?", + deleteConfirmBody: (name: string) => `«${name}» хадгаламжийн зорилго устана.`, + }, + amountEntry: { + save: "Хадгалах", + cancel: "Болих", + }, + categoryTransactions: { + empty: "Гүйлгээ алга", + filterPlaceholder: "филтер", + }, +} as const;