feat(web): planner budget/limits/savings goals

This commit is contained in:
Munkherdene 2026-08-22 20:57:01 +08:00
parent 2555e42f21
commit f9195c5ffa
6 changed files with 1060 additions and 0 deletions

View file

@ -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 <CategoryTransactions category={decodeURIComponent(category)} />;
}

View file

@ -0,0 +1,5 @@
import { PlannerView } from "@/features/planner/PlannerView";
export default function PlannerPage() {
return <PlannerView />;
}

View file

@ -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 (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3">
<Link href="/planner" aria-label={s.amountEntry.cancel} style={{ color: "var(--seed-color-fg-neutral)" }}>
</Link>
<h1 style={{ fontWeight: 700, fontSize: 16 }}>{category}</h1>
</div>
<TextFieldRoot value={search} onValueChange={setSearch}>
<TextFieldInput
placeholder={s.categoryTransactions.filterPlaceholder}
aria-label={s.categoryTransactions.filterPlaceholder}
/>
</TextFieldRoot>
<Card>
{isLoading && <p style={{ color: "var(--seed-color-fg-placeholder)" }}></p>}
{!isLoading && rows.length === 0 && (
<p style={{ color: "var(--seed-color-fg-placeholder)" }}>{s.categoryTransactions.empty}</p>
)}
{!isLoading && rows.length > 0 && (
<ul className="flex flex-col gap-3">
{rows.map((t, i) => {
const income = t.direction === "income";
return (
<li
key={`${t.txnId ?? i}`}
className="flex items-center justify-between gap-3"
style={{
borderBottom: i < rows.length - 1 ? "1px solid var(--seed-color-border-default, #eee)" : undefined,
paddingBottom: 12,
}}
>
<div className="flex flex-col">
<span style={{ fontWeight: 700 }}>{t.title || t.category}</span>
<span style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
{shortDate(t.date)} · {t.category}
</span>
</div>
<span
style={{
fontWeight: 700,
color: income ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-critical)",
}}
>
{income ? "+" : ""}
{tugrik(t.amount)}
</span>
</li>
);
})}
</ul>
)}
</Card>
</div>
);
}

View file

@ -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(<PlannerView />);
// Overall (day) card: spent / limit from budget.report.day.
expect(screen.getByText("15,000₮ / 50,000₮")).toBeInTheDocument();
// Category limit row: name + spent / limit from budget.report.day.rows.
expect(screen.getByText("хоол")).toBeInTheDocument();
expect(screen.getByText("5,000₮ / 20,000₮")).toBeInTheDocument();
});
});

View file

@ -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<Horizon, string> = {
day: s.horizon.day,
week: s.horizon.week,
month: s.horizon.month,
};
const OVERALL_LABEL: Record<Horizon, string> = {
day: s.overall.label.day,
week: s.overall.label.week,
month: s.overall.label.month,
};
/** The Төлөвлөгөө hub ports `PlannerView.swift` + `LimitsHubModel.swift`.
* Shows the editable planned income, a day/week/month horizon toggle, the
* overall spend-vs-limit for that horizon, per-category limits, and savings
* goals. Every edit persists via `PUT /budget`, echoing back the full
* `categories` array (the DTO contract nothing else may be dropped). */
export function PlannerView() {
const { data: budget, isLoading } = useBudget();
const { data: netWorth } = useNetWorth();
const putBudget = usePutBudget();
const goalMutations = useSavingsGoalMutations();
const [horizon, setHorizon] = React.useState<Horizon>("day");
const accounts: Account[] = netWorth?.accounts ?? [];
function reportFor(h: Horizon): HorizonReport | undefined {
return budget?.report[h];
}
function categoryLimitFor(name: string): CategoryLimit {
return budget?.categories.find((c) => c.name === name) ?? { name, day: "0", week: "0", month: "0" };
}
/** Save a partial change, echoing the current budget for everything else
* (mirrors `LimitsHubModel.save` nothing, including the planned-income
* override, may be silently lost). */
function save(partial: {
dayLimit?: string;
weekLimit?: string;
monthLimit?: string;
plannedIncomeManual?: string;
categories?: CategoryLimit[];
}) {
if (!budget) return;
putBudget.mutate({
dayLimit: partial.dayLimit ?? budget.dayLimit,
weekLimit: partial.weekLimit ?? budget.weekLimit,
monthLimit: partial.monthLimit ?? budget.monthLimit,
plannedIncomeManual: partial.plannedIncomeManual ?? budget.plannedIncomeManual,
categories: partial.categories ?? budget.categories,
});
}
function saveOverall(h: Horizon, value: number) {
if (!budget) return;
save({
dayLimit: h === "day" ? String(value) : budget.dayLimit,
weekLimit: h === "week" ? String(value) : budget.weekLimit,
monthLimit: h === "month" ? String(value) : budget.monthLimit,
});
}
function saveCategoryLimit(name: string, day: number, week: number, month: number) {
if (!budget) return;
const cats = [...budget.categories];
const updated: CategoryLimit = { name, day: String(day), week: String(week), month: String(month) };
const idx = cats.findIndex((c) => c.name === name);
if (idx >= 0) cats[idx] = updated;
else cats.push(updated);
save({ categories: cats });
}
function removeCategoryLimit(name: string) {
if (!budget) return;
save({ categories: budget.categories.filter((c) => c.name !== name) });
}
const report = reportFor(horizon);
const overallSpent = dec(report?.overallSpent);
const overallLimit = dec(report?.overallLimit);
const rows: LimitRow[] = report?.rows ?? [];
const goals: SavingsGoal[] = budget?.savingsGoals ?? [];
return (
<div className="flex flex-col gap-4">
<header className="flex items-center justify-between">
<h1 style={{ fontWeight: 700, fontSize: 18 }}>{s.header.title}</h1>
<HideAmountsToggle />
</header>
<PlannedIncomeCard budget={budget} loading={isLoading} onSave={(v) => save({ plannedIncomeManual: String(v) })} />
<SegmentedControlRoot value={horizon} onValueChange={(v) => setHorizon(v as Horizon)}>
{(["day", "week", "month"] as const).map((h) => (
<SegmentedControlItem key={h} value={h}>
<SegmentedControlItemHiddenInput />
<span>{HORIZON_LABEL[h]}</span>
</SegmentedControlItem>
))}
</SegmentedControlRoot>
<OverallCard
horizon={horizon}
spent={overallSpent}
limit={overallLimit}
loading={isLoading}
onSave={(v) => saveOverall(horizon, v)}
/>
<CategoryList
loading={isLoading}
rows={rows}
horizon={horizon}
categoryLimitFor={categoryLimitFor}
onSaveLimit={saveCategoryLimit}
onRemoveLimit={removeCategoryLimit}
/>
<SavingsSection
goals={goals}
accounts={accounts}
onSave={(goal) =>
goalMutations.post.mutate({
originalName: goal.originalName,
name: goal.name,
target: String(goal.target),
monthlyContribution: String(goal.monthly),
accountId: goal.accountId,
targetDate: goal.targetDate,
})
}
onDelete={(name) => goalMutations.delete.mutate(name)}
/>
</div>
);
}
// --- Planned income ---------------------------------------------------------
function PlannedIncomeCard({
budget,
loading,
onSave,
}: {
budget: Budget | undefined;
loading: boolean;
onSave: (value: number) => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const plannedIncome = dec(budget?.plannedIncome);
const loanObligations = dec(budget?.loanObligations);
const savingsContributions = dec(budget?.savingsContributions);
const availableIncome = dec(budget?.availableIncome);
const showBreakdown = loanObligations > 0 || savingsContributions > 0;
return (
<Card>
{loading ? (
<Skeleton height="64px" />
) : editing ? (
<div className="flex flex-col gap-3">
<span style={{ fontWeight: 700, fontSize: 14 }}>{s.plannedIncome.editTitle}</span>
<TextFieldRoot value={draft} onValueChange={(v) => setDraft(onlyDigits(v))}>
<TextFieldInput
inputMode="numeric"
aria-label={s.plannedIncome.editTitle}
placeholder="0"
/>
</TextFieldRoot>
<p style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
{s.plannedIncome.autoHint(tugrik(plannedIncome))}
</p>
<div className="flex gap-2">
<MercuryButton variant="secondary" onClick={() => setEditing(false)} style={{ flex: 1 }}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
onClick={() => {
onSave(parseFloat(draft) || 0);
setEditing(false);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
</div>
) : (
<button
type="button"
onClick={() => {
setDraft(budget?.plannedIncomeManual && dec(budget.plannedIncomeManual) > 0 ? budget.plannedIncomeManual : "");
setEditing(true);
}}
className="flex w-full flex-col items-start gap-3 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
>
<span style={{ fontWeight: 700, fontSize: 14 }}>{s.plannedIncome.label}</span>
<span style={{ fontWeight: 700, fontSize: 26 }}>+{tugrikShort(plannedIncome)}</span>
{showBreakdown && (
<div className="flex w-full flex-col gap-1">
{loanObligations > 0 && (
<BreakdownRow label={s.plannedIncome.loanObligations} value={`${tugrikShort(loanObligations)}`} />
)}
{savingsContributions > 0 && (
<BreakdownRow label={s.plannedIncome.savings} value={`${tugrikShort(savingsContributions)}`} />
)}
<BreakdownRow label={s.plannedIncome.available} value={tugrikShort(availableIncome)} bold />
</div>
)}
</button>
)}
</Card>
);
}
function BreakdownRow({ label, value, bold }: { label: string; value: string; bold?: boolean }) {
return (
<div className="flex w-full items-center justify-between">
<span style={{ fontSize: 13, fontWeight: bold ? 700 : 400, color: bold ? undefined : "var(--seed-color-fg-placeholder)" }}>
{label}
</span>
<span style={{ fontSize: 14, fontWeight: 700 }}>{value}</span>
</div>
);
}
// --- Overall (horizon) card --------------------------------------------------
function OverallCard({
horizon,
spent,
limit,
loading,
onSave,
}: {
horizon: Horizon;
spent: number;
limit: number;
loading: boolean;
onSave: (value: number) => void;
}) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState("");
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
return (
<div
style={{
background: "var(--mercury-limit-card)",
borderRadius: "var(--seed-radius-r5)",
padding: "16px 20px",
color: "#fff",
}}
>
{loading ? (
<Skeleton height="64px" />
) : editing ? (
<div className="flex flex-col gap-3">
<span style={{ fontWeight: 700 }}>{s.overall.editTitle(HORIZON_LABEL[horizon])}</span>
<TextFieldRoot value={draft} onValueChange={(v) => setDraft(onlyDigits(v))}>
<TextFieldInput inputMode="numeric" aria-label={s.overall.editTitle(HORIZON_LABEL[horizon])} placeholder="0" />
</TextFieldRoot>
<div className="flex gap-2">
<MercuryButton variant="secondary" onClick={() => setEditing(false)} style={{ flex: 1 }}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
onClick={() => {
onSave(parseFloat(draft) || 0);
setEditing(false);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
</div>
) : (
<button
type="button"
onClick={() => {
setDraft(limit > 0 ? String(limit) : "");
setEditing(true);
}}
className="flex w-full items-center gap-4 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff" }}
>
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
<ProgressCircleTrack style={{ opacity: 0.3 }} />
<ProgressCircleRange />
</ProgressCircleRoot>
<div className="flex flex-col gap-1">
<span style={{ fontSize: 12 }}>{OVERALL_LABEL[horizon]}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}>
{tugrikShort(spent)}
{limit > 0 ? ` / ${tugrikShort(limit)}` : " / —"}
</span>
</div>
</button>
)}
</div>
);
}
// --- Category limits ---------------------------------------------------------
function CategoryList({
loading,
rows,
horizon,
categoryLimitFor,
onSaveLimit,
onRemoveLimit,
}: {
loading: boolean;
rows: LimitRow[];
horizon: Horizon;
categoryLimitFor: (name: string) => CategoryLimit;
onSaveLimit: (name: string, day: number, week: number, month: number) => void;
onRemoveLimit: (name: string) => void;
}) {
const [editingName, setEditingName] = React.useState<string | null>(null);
const [drafts, setDrafts] = React.useState({ day: "", week: "", month: "" });
const [adding, setAdding] = React.useState(false);
const [newName, setNewName] = React.useState("");
const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null);
function openEditor(name: string) {
const limit = categoryLimitFor(name);
setDrafts({ day: dec(limit.day) > 0 ? limit.day : "", week: dec(limit.week) > 0 ? limit.week : "", month: dec(limit.month) > 0 ? limit.month : "" });
setEditingName(name);
}
return (
<Card>
<div className="flex items-center justify-between">
<h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.categories.title}</h2>
<MercuryButton variant="ghost" onClick={() => { setNewName(""); setAdding(true); }}>
{s.categories.add}
</MercuryButton>
</div>
{adding && (
<div className="mt-3 flex flex-col gap-2">
<TextFieldRoot value={newName} onValueChange={setNewName}>
<TextFieldInput placeholder={s.categories.addNamePlaceholder} aria-label={s.categories.addNamePlaceholder} />
</TextFieldRoot>
<div className="flex gap-2">
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setAdding(false)}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!newName.trim()}
onClick={() => {
const name = newName.trim();
setAdding(false);
openEditor(name);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
</div>
)}
{loading && (
<div className="mt-4 flex flex-col gap-3">
<Skeleton height="56px" />
<Skeleton height="56px" />
</div>
)}
{!loading && rows.length === 0 && (
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
{s.categories.empty}
</p>
)}
{!loading && rows.length > 0 && (
<ul className="mt-4 flex flex-col gap-4">
{rows.map((row) => {
const spent = dec(row.spent);
const limit = dec(row.limit);
const over = limit > 0 && spent > limit;
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
if (editingName === row.category) {
return (
<li key={row.category} className="flex flex-col gap-2">
<span style={{ fontWeight: 700 }}>{s.categories.editTitle(row.category, HORIZON_LABEL[horizon])}</span>
<AmountField label={s.horizon.day} value={drafts.day} onChange={(v) => setDrafts((d) => ({ ...d, day: v }))} />
<AmountField label={s.horizon.week} value={drafts.week} onChange={(v) => setDrafts((d) => ({ ...d, week: v }))} />
<AmountField label={s.horizon.month} value={drafts.month} onChange={(v) => setDrafts((d) => ({ ...d, month: v }))} />
<div className="flex gap-2">
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={() => setEditingName(null)}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
onClick={() => {
onSaveLimit(row.category, parseFloat(drafts.day) || 0, parseFloat(drafts.week) || 0, parseFloat(drafts.month) || 0);
setEditingName(null);
}}
>
{s.amountEntry.save}
</MercuryButton>
</div>
<button
type="button"
onClick={() => setConfirmRemove(row.category)}
style={{ color: "var(--seed-color-fg-critical)", background: "none", border: "none", cursor: "pointer" }}
>
{s.categories.remove}
</button>
</li>
);
}
return (
<li key={row.category} className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Link
href={`/planner/${encodeURIComponent(row.category)}`}
className="flex flex-1 flex-col gap-2"
style={{ color: "inherit", textDecoration: "none" }}
>
<div className="flex items-center justify-between">
<span style={{ fontWeight: 700 }}>{row.category}</span>
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}>
{tugrik(spent)}
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
</span>
</div>
<ProgressBar percent={percent} tone={over ? "critical" : "brand"} />
</Link>
<button
type="button"
onClick={() => openEditor(row.category)}
aria-label={s.categories.editTitle(row.category, HORIZON_LABEL[horizon])}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-placeholder)" }}
>
</button>
</div>
</li>
);
})}
</ul>
)}
<ConfirmDialog
open={confirmRemove !== null}
onOpenChange={(open) => !open && setConfirmRemove(null)}
title={s.categories.removeConfirmTitle}
body={confirmRemove ? s.categories.removeConfirmBody(confirmRemove) : ""}
confirmLabel={s.categories.remove}
onConfirm={() => {
if (confirmRemove) {
onRemoveLimit(confirmRemove);
setEditingName(null);
}
}}
/>
</Card>
);
}
function AmountField({ label, value, onChange }: { label: string; value: string; onChange: (v: string) => void }) {
return (
<div className="flex items-center gap-2">
<span style={{ width: 72, fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{label}</span>
<TextFieldRoot value={value} onValueChange={(v) => onChange(onlyDigits(v))} style={{ flex: 1 }}>
<TextFieldInput inputMode="numeric" aria-label={label} placeholder="0" />
</TextFieldRoot>
</div>
);
}
function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
return (
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
<div
style={{
height: "100%",
width: `${percent}%`,
borderRadius: 999,
background: tone === "critical" ? "var(--seed-color-fg-critical)" : "var(--mercury-limit-circle)",
}}
/>
</div>
);
}
// --- Savings goals ------------------------------------------------------------
interface GoalDraft {
originalName?: string;
name: string;
target: number;
monthly: number;
accountId: number;
targetDate: string;
}
function SavingsSection({
goals,
accounts,
onSave,
onDelete,
}: {
goals: SavingsGoal[];
accounts: Account[];
onSave: (goal: GoalDraft) => void;
onDelete: (name: string) => void;
}) {
const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null);
const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null);
return (
<Card>
<div className="flex items-center justify-between">
<h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.title}</h2>
<MercuryButton variant="ghost" onClick={() => setEditing({ mode: "add" })}>
{s.savings.add}
</MercuryButton>
</div>
{goals.length === 0 && !editing && (
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
{s.savings.empty}
</p>
)}
{goals.length > 0 && (
<ul className="mt-4 flex flex-col gap-4">
{goals.map((goal) => {
const saved = dec(goal.saved);
const target = dec(goal.target);
const monthly = dec(goal.monthlyContribution);
const remaining = Math.max(0, target - saved);
const done = target > 0 && saved >= target;
const percent = target > 0 ? Math.min(100, (saved / target) * 100) : 0;
return (
<li key={goal.name}>
<button
type="button"
onClick={() => setEditing({ mode: "edit", goal })}
className="flex w-full flex-col gap-2 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
>
<div className="flex items-center justify-between">
<span style={{ fontWeight: 700 }}>{goal.name}</span>
<span style={{ fontWeight: 700, color: done ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-placeholder)" }}>
{tugrik(saved)}
{target > 0 ? ` / ${tugrik(target)}` : " / —"}
</span>
</div>
<ProgressBar percent={percent} tone={done ? "brand" : "brand"} />
<div className="flex items-center justify-between" style={{ fontSize: 12, color: "var(--seed-color-fg-placeholder)" }}>
<span>{goal.accountId === 0 ? s.savings.linkAccount : goal.accountName || s.savings.linkAccount}</span>
{monthly > 0 && <span>{s.savings.perMonth} {tugrik(monthly)}</span>}
</div>
{remaining > 0 && target > 0 && (
<span style={{ fontSize: 11, color: "var(--seed-color-fg-placeholder)" }}>
{s.savings.remaining} {tugrik(remaining)}
</span>
)}
</button>
</li>
);
})}
</ul>
)}
{editing && (
<GoalEditor
existing={editing.goal}
accounts={accounts}
onCancel={() => setEditing(null)}
onSave={(draft) => {
onSave(draft);
setEditing(null);
}}
onRequestDelete={() => editing.goal && setConfirmDelete(editing.goal.name)}
/>
)}
<ConfirmDialog
open={confirmDelete !== null}
onOpenChange={(open) => !open && setConfirmDelete(null)}
title={s.savings.deleteConfirmTitle}
body={confirmDelete ? s.savings.deleteConfirmBody(confirmDelete) : ""}
confirmLabel={s.savings.delete}
onConfirm={() => {
if (confirmDelete) {
onDelete(confirmDelete);
setConfirmDelete(null);
setEditing(null);
}
}}
/>
</Card>
);
}
function GoalEditor({
existing,
accounts,
onSave,
onCancel,
onRequestDelete,
}: {
existing?: SavingsGoal;
accounts: Account[];
onSave: (draft: GoalDraft) => void;
onCancel: () => void;
onRequestDelete: () => void;
}) {
const [name, setName] = React.useState(existing?.name ?? "");
const [editingName, setEditingName] = React.useState(false);
const [target, setTarget] = React.useState(existing?.target ?? "");
const [monthly, setMonthly] = React.useState(existing?.monthlyContribution ?? "");
const [accountId, setAccountId] = React.useState<number>(existing?.accountId ?? 0);
const [targetDate, setTargetDate] = React.useState(existing?.targetDate ?? "");
const canSave = name.trim().length > 0 && (parseFloat(target) || 0) > 0;
return (
<div className="mt-4 flex flex-col gap-3" style={{ borderTop: "1px solid var(--seed-color-border-default, #eee)", paddingTop: 12 }}>
<span style={{ fontWeight: 700 }}>{existing ? s.savings.editTitle : s.savings.addTitle}</span>
{editingName ? (
<NameEdit
initial={name}
title={s.savings.name}
placeholder={s.savings.namePlaceholder}
onSave={(n) => {
setName(n);
setEditingName(false);
}}
onCancel={() => setEditingName(false)}
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className="flex items-center justify-between"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", textAlign: "left" }}
>
<span style={{ fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.name}</span>
<span style={{ fontWeight: 700 }}>{name || "—"}</span>
</button>
)}
<AmountField label={s.savings.target} value={target} onChange={setTarget} />
<AmountField label={s.savings.monthly} value={monthly} onChange={setMonthly} />
<label className="flex items-center gap-2">
<span style={{ width: 100, fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.account}</span>
<select
value={accountId}
onChange={(e) => setAccountId(Number(e.target.value))}
aria-label={s.savings.account}
style={{ flex: 1, padding: 8 }}
>
<option value={0}>{s.savings.noAccount}</option>
{accounts.map((a) => (
<option key={a.accountId} value={a.accountId}>
{a.bank} ·{a.accountNumber.slice(-4)}
</option>
))}
</select>
</label>
<label className="flex items-center gap-2">
<span style={{ width: 100, fontSize: 13, color: "var(--seed-color-fg-placeholder)" }}>{s.savings.date}</span>
<input
type="date"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
aria-label={s.savings.date}
style={{ flex: 1, padding: 8 }}
/>
</label>
<div className="flex gap-2">
<MercuryButton variant="secondary" style={{ flex: 1 }} onClick={onCancel}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
style={{ flex: 1 }}
disabled={!canSave}
onClick={() =>
onSave({
originalName: existing?.name,
name: name.trim(),
target: parseFloat(target) || 0,
monthly: parseFloat(monthly) || 0,
accountId,
targetDate,
})
}
>
{s.amountEntry.save}
</MercuryButton>
</div>
{existing && (
<button
type="button"
onClick={onRequestDelete}
style={{ color: "var(--seed-color-fg-critical)", background: "none", border: "none", cursor: "pointer" }}
>
{s.savings.delete}
</button>
)}
</div>
);
}
// --- Shared confirmation dialog (never a native alert) -----------------------
function ConfirmDialog({
open,
onOpenChange,
title,
body,
confirmLabel,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
body: string;
confirmLabel: string;
onConfirm: () => void;
}) {
return (
<ContentDialogRoot open={open} onOpenChange={onOpenChange}>
<ContentDialogBackdrop />
<ContentDialogPositioner>
<ContentDialogContent>
<ContentDialogHeader>
<ContentDialogTitle>{title}</ContentDialogTitle>
</ContentDialogHeader>
<ContentDialogBody>
<p>{body}</p>
</ContentDialogBody>
<ContentDialogFooter>
<MercuryButton variant="secondary" onClick={() => onOpenChange(false)}>
{s.amountEntry.cancel}
</MercuryButton>
<MercuryButton
variant="primary"
onClick={() => {
onConfirm();
onOpenChange(false);
}}
>
{confirmLabel}
</MercuryButton>
</ContentDialogFooter>
</ContentDialogContent>
</ContentDialogPositioner>
</ContentDialogRoot>
);
}

View file

@ -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;