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