merge task/t12: planner budget/limits/savings goals
This commit is contained in:
commit
c52a2d731d
6 changed files with 1060 additions and 0 deletions
10
src/app/(app)/planner/[category]/page.tsx
Normal file
10
src/app/(app)/planner/[category]/page.tsx
Normal 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)} />;
|
||||||
|
}
|
||||||
5
src/app/(app)/planner/page.tsx
Normal file
5
src/app/(app)/planner/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { PlannerView } from "@/features/planner/PlannerView";
|
||||||
|
|
||||||
|
export default function PlannerPage() {
|
||||||
|
return <PlannerView />;
|
||||||
|
}
|
||||||
103
src/features/planner/CategoryTransactions.tsx
Normal file
103
src/features/planner/CategoryTransactions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/features/planner/PlannerView.test.tsx
Normal file
55
src/features/planner/PlannerView.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
821
src/features/planner/PlannerView.tsx
Normal file
821
src/features/planner/PlannerView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
66
src/features/planner/strings.ts
Normal file
66
src/features/planner/strings.ts
Normal 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;
|
||||||
Loading…
Add table
Reference in a new issue