feat(web): password-reset + email-verify pages + planner spend-breakdown
This commit is contained in:
parent
5eff0f661f
commit
7753168b11
9 changed files with 462 additions and 26 deletions
24
src/app/(auth)/reset/page.tsx
Normal file
24
src/app/(auth)/reset/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { ResetForm } from "@/features/auth/ResetForm";
|
||||
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||
|
||||
// The password-reset landing: reached from the emailed link (?token=...).
|
||||
// Wrapped in Suspense because ResetForm reads useSearchParams, which Next.js
|
||||
// requires to sit below a Suspense boundary.
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 40 }} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<ResetForm />
|
||||
</Suspense>
|
||||
|
||||
<div style={{ paddingBottom: 60 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
24
src/app/(auth)/verify/page.tsx
Normal file
24
src/app/(auth)/verify/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import { Suspense } from "react";
|
||||
import { VerifyStatus } from "@/features/auth/VerifyStatus";
|
||||
import { AuthHeader } from "@/features/auth/AuthHeader";
|
||||
|
||||
// The email-verify landing: reached from the emailed link (?token=...).
|
||||
// Wrapped in Suspense because VerifyStatus reads useSearchParams, which
|
||||
// Next.js requires to sit below a Suspense boundary.
|
||||
export default function VerifyEmailPage() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
|
||||
<div style={{ flex: 1, minHeight: 40 }} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<VerifyStatus />
|
||||
</Suspense>
|
||||
|
||||
<div style={{ paddingBottom: 60 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
src/features/auth/ResetForm.tsx
Normal file
106
src/features/auth/ResetForm.tsx
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { TextFieldRoot, TextFieldInput } from "@seed-design/react";
|
||||
import { MercuryButton } from "../../ds/MercuryButton";
|
||||
import { authStrings } from "./strings";
|
||||
import { submitReset } from "./actions";
|
||||
|
||||
/** Set-new-password form for the emailed reset link (`/reset?token=...`).
|
||||
* Ported in spirit from ForgotPasswordView.swift's field/CTA styling — there is
|
||||
* no iOS screen for this step since the reset itself only happens on web (see
|
||||
* that file's header comment). Same 8-char minimum as AuthForm's register step. */
|
||||
export function ResetForm() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token") ?? "";
|
||||
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [confirmPassword, setConfirmPassword] = React.useState("");
|
||||
const [isSubmitting, setIsSubmitting] = React.useState(false);
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
|
||||
const [done, setDone] = React.useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
setErrorMessage(undefined);
|
||||
|
||||
if (!token) {
|
||||
setErrorMessage(authStrings.reset.missingToken);
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
setErrorMessage(authStrings.errors.passwordTooShort);
|
||||
return;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
setErrorMessage(authStrings.errors.passwordMismatch);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await submitReset(token, password);
|
||||
if (result.ok) {
|
||||
setDone(true);
|
||||
} else {
|
||||
setErrorMessage(result.error ?? authStrings.errors.unknown);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5" style={{ textAlign: "center" }}>
|
||||
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||
{authStrings.reset.success}
|
||||
</p>
|
||||
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
|
||||
{authStrings.reset.loginLink}
|
||||
</MercuryButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
|
||||
<p className="text-center text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||
{authStrings.reset.subtitle}
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
<TextFieldRoot value={password} onValueChange={setPassword} name="password">
|
||||
<TextFieldInput
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={authStrings.reset.password}
|
||||
aria-label={authStrings.reset.password}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
|
||||
<TextFieldRoot value={confirmPassword} onValueChange={setConfirmPassword} name="confirmPassword">
|
||||
<TextFieldInput
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
placeholder={authStrings.reset.confirmPassword}
|
||||
aria-label={authStrings.reset.confirmPassword}
|
||||
/>
|
||||
</TextFieldRoot>
|
||||
</div>
|
||||
|
||||
{errorMessage && (
|
||||
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<MercuryButton type="submit" variant="primary" loading={isSubmitting}>
|
||||
{authStrings.reset.submit}
|
||||
</MercuryButton>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
69
src/features/auth/VerifyStatus.tsx
Normal file
69
src/features/auth/VerifyStatus.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { MercuryButton } from "../../ds/MercuryButton";
|
||||
import { authStrings } from "./strings";
|
||||
import { submitVerify } from "./actions";
|
||||
|
||||
type State = "verifying" | "success" | "error";
|
||||
|
||||
/** Email-verify landing (`/verify?token=...`): posts the token once on mount
|
||||
* and shows a verifying/success/failure state. The backend's email-verification
|
||||
* feature may be disabled server-side (Config.EmailVerificationEnabled), in
|
||||
* which case the route 404s and this renders the generic error message —
|
||||
* still a graceful, non-broken screen rather than a blocked build. */
|
||||
export function VerifyStatus() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const token = searchParams.get("token") ?? "";
|
||||
|
||||
const [state, setState] = React.useState<State>("verifying");
|
||||
const [errorMessage, setErrorMessage] = React.useState<string | undefined>();
|
||||
const attempted = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (attempted.current) return;
|
||||
attempted.current = true;
|
||||
|
||||
if (!token) {
|
||||
setErrorMessage(authStrings.verify.missingToken);
|
||||
setState("error");
|
||||
return;
|
||||
}
|
||||
|
||||
submitVerify(token).then((result) => {
|
||||
if (result.ok) {
|
||||
setState("success");
|
||||
} else {
|
||||
setErrorMessage(result.error ?? authStrings.errors.unknown);
|
||||
setState("error");
|
||||
}
|
||||
});
|
||||
}, [token]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-5" style={{ textAlign: "center" }}>
|
||||
{state === "verifying" && (
|
||||
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||
{authStrings.verify.verifying}
|
||||
</p>
|
||||
)}
|
||||
{state === "success" && (
|
||||
<p className="text-sm" style={{ color: "var(--mercury-subtle, #6b7280)", margin: 0 }}>
|
||||
{authStrings.verify.success}
|
||||
</p>
|
||||
)}
|
||||
{state === "error" && (
|
||||
<p role="alert" className="text-sm" style={{ color: "var(--seed-color-fg-critical)", margin: 0 }}>
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
{state !== "verifying" && (
|
||||
<MercuryButton variant="primary" onClick={() => router.push("/login")}>
|
||||
{authStrings.verify.loginLink}
|
||||
</MercuryButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ const ERROR_MESSAGE_BY_CODE: Record<string, string> = {
|
|||
email_taken: authStrings.errors.emailTaken,
|
||||
invalid_code: authStrings.errors.invalidCode,
|
||||
invalid_challenge: authStrings.errors.invalidCode,
|
||||
invalid_token: authStrings.errors.invalidToken,
|
||||
};
|
||||
|
||||
function messageForCode(code: string | undefined): string {
|
||||
|
|
@ -23,7 +24,7 @@ function messageForCode(code: string | undefined): string {
|
|||
}
|
||||
|
||||
async function postAuth(
|
||||
path: "login" | "register" | "forgot" | "2fa",
|
||||
path: "login" | "register" | "forgot" | "2fa" | "reset" | "verify",
|
||||
body: unknown,
|
||||
): Promise<AuthActionResult> {
|
||||
let res: Response;
|
||||
|
|
@ -73,3 +74,20 @@ export function submit2FA(
|
|||
export function submitForgot(email: string): Promise<AuthActionResult> {
|
||||
return postAuth("forgot", { email });
|
||||
}
|
||||
|
||||
// Backend contract (internal/transport/api/password_handlers.go
|
||||
// handleResetPassword): { token, newPassword } — the response body carries no
|
||||
// user/session, just { status: "ok" }, so `postAuth`'s ok/error handling is
|
||||
// all callers need.
|
||||
export function submitReset(
|
||||
token: string,
|
||||
password: string,
|
||||
): Promise<AuthActionResult> {
|
||||
return postAuth("reset", { token, newPassword: password });
|
||||
}
|
||||
|
||||
// Backend contract (internal/transport/api/verify_handlers.go
|
||||
// handleVerifyEmail): { token } → { status: "verified" } on success.
|
||||
export function submitVerify(token: string): Promise<AuthActionResult> {
|
||||
return postAuth("verify", { token });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,26 @@ export const authStrings = {
|
|||
submit: "Сэргээх холбоос авах",
|
||||
sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.",
|
||||
},
|
||||
// /reset — reached from the emailed link (?token=...). No iOS counterpart:
|
||||
// ForgotPasswordView.swift is request-only, the actual change happens here
|
||||
// on web (see that file's header comment).
|
||||
reset: {
|
||||
subtitle: "Шинэ нууц үгээ хоёр удаа оруулна уу.",
|
||||
password: "Шинэ нууц үг",
|
||||
confirmPassword: "Нууц үг давтах",
|
||||
submit: "Нууц үг шинэчлэх",
|
||||
success: "Нууц үг амжилттай шинэчлэгдлээ. Шинэ нууц үгээрээ нэвтэрнэ үү.",
|
||||
loginLink: "Нэвтрэх хуудас руу очих",
|
||||
missingToken: "Холбоос буруу байна. Имэйлээр ирсэн холбоосоор дахин орно уу.",
|
||||
},
|
||||
// /verify — reached from the emailed link (?token=...). Also no iOS
|
||||
// counterpart; verification is a web-only flow today.
|
||||
verify: {
|
||||
verifying: "Имэйл хаягийг баталгаажуулж байна…",
|
||||
success: "Имэйл хаяг амжилттай баталгаажлаа.",
|
||||
loginLink: "Нэвтрэх хуудас руу очих",
|
||||
missingToken: "Баталгаажуулах холбоос буруу байна.",
|
||||
},
|
||||
twoFactor: {
|
||||
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
|
||||
code: "Баталгаажуулах код",
|
||||
|
|
@ -40,6 +60,7 @@ export const authStrings = {
|
|||
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
|
||||
emailTaken: "Энэ имэйл бүртгэлтэй байна.",
|
||||
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
|
||||
invalidToken: "Холбоосны хугацаа дууссан эсвэл буруу байна.",
|
||||
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
|
||||
unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ 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 { SpendBreakdown } from "./SpendBreakdown";
|
||||
import { plannerStrings as s } from "./strings";
|
||||
|
||||
type Horizon = "day" | "week" | "month";
|
||||
|
|
@ -82,6 +83,7 @@ export function PlannerView() {
|
|||
const goalMutations = useSavingsGoalMutations();
|
||||
|
||||
const [horizon, setHorizon] = React.useState<Horizon>("day");
|
||||
const [breakdownOpen, setBreakdownOpen] = React.useState(false);
|
||||
|
||||
const accounts: Account[] = netWorth?.accounts ?? [];
|
||||
|
||||
|
|
@ -167,6 +169,15 @@ export function PlannerView() {
|
|||
limit={overallLimit}
|
||||
loading={isLoading}
|
||||
onSave={(v) => saveOverall(horizon, v)}
|
||||
onOpenBreakdown={() => setBreakdownOpen(true)}
|
||||
/>
|
||||
|
||||
<SpendBreakdown
|
||||
open={breakdownOpen}
|
||||
onOpenChange={setBreakdownOpen}
|
||||
horizonLabel={OVERALL_LABEL[horizon]}
|
||||
total={overallSpent}
|
||||
rows={rows}
|
||||
/>
|
||||
|
||||
<CategoryList
|
||||
|
|
@ -306,12 +317,14 @@ function OverallCard({
|
|||
limit,
|
||||
loading,
|
||||
onSave,
|
||||
onOpenBreakdown,
|
||||
}: {
|
||||
horizon: Horizon;
|
||||
spent: number;
|
||||
limit: number;
|
||||
loading: boolean;
|
||||
onSave: (value: number) => void;
|
||||
onOpenBreakdown: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
|
|
@ -352,31 +365,54 @@ function OverallCard({
|
|||
</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", justifyContent: "space-between" }}
|
||||
>
|
||||
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
|
||||
<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, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
|
||||
<span style={{ fontSize: 26, fontWeight: 700 }}>
|
||||
{tugrikShort(spent)}
|
||||
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
|
||||
</span>
|
||||
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenBreakdown}
|
||||
className="flex items-center gap-1"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
padding: "2px 0",
|
||||
cursor: "pointer",
|
||||
color: "#fff",
|
||||
opacity: 0.85,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
<Icon name="trending" size={13} />
|
||||
{s.overall.breakdown}
|
||||
</button>
|
||||
</div>
|
||||
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
|
||||
</button>
|
||||
|
||||
<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", justifyContent: "space-between" }}
|
||||
>
|
||||
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
|
||||
<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, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
|
||||
<span style={{ fontSize: 26, fontWeight: 700 }}>
|
||||
{tugrikShort(spent)}
|
||||
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
|
||||
</span>
|
||||
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
|
||||
</div>
|
||||
</div>
|
||||
<Icon name="chevron-right" size={14} style={{ opacity: 0.8, flexShrink: 0 }} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
@ -590,7 +626,7 @@ function AmountField({ label, value, onChange }: { label: string; value: string;
|
|||
);
|
||||
}
|
||||
|
||||
function ProgressBar({ percent, tone }: { percent: number; tone: "brand" | "critical" }) {
|
||||
export 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
|
||||
|
|
|
|||
130
src/features/planner/SpendBreakdown.tsx
Normal file
130
src/features/planner/SpendBreakdown.tsx
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ContentDialogRoot,
|
||||
ContentDialogBackdrop,
|
||||
ContentDialogPositioner,
|
||||
ContentDialogContent,
|
||||
ContentDialogHeader,
|
||||
ContentDialogTitle,
|
||||
ContentDialogBody,
|
||||
ContentDialogFooter,
|
||||
} from "@seed-design/react";
|
||||
import { IconChip, EmptyState, MercuryButton } from "@/ds";
|
||||
import { categoryStyle } from "@/ds/categoryStyle";
|
||||
import { tugrik } from "@/ds/money";
|
||||
import { ProgressBar } from "./PlannerView";
|
||||
import { plannerStrings as s } from "./strings";
|
||||
|
||||
export interface SpendBreakdownRow {
|
||||
category: string;
|
||||
spent: string;
|
||||
limit: string;
|
||||
}
|
||||
|
||||
function dec(v: string | undefined | null): number {
|
||||
return parseFloat(v ?? "0") || 0;
|
||||
}
|
||||
|
||||
export interface SpendBreakdownProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
horizonLabel: string;
|
||||
total: number;
|
||||
rows: SpendBreakdownRow[];
|
||||
}
|
||||
|
||||
/** Opened from the overall-spend card's "Задаргаа" affordance: how the
|
||||
* horizon's total spend is composed, category by category, biggest first —
|
||||
* "how did this number get so big?". Ports SpendBreakdownView.swift; each row
|
||||
* links to the same `/planner/[category]` transactions page the category-limit
|
||||
* list already uses. */
|
||||
export function SpendBreakdown({ open, onOpenChange, horizonLabel, total, rows }: SpendBreakdownProps) {
|
||||
const spent = rows
|
||||
.filter((r) => dec(r.spent) > 0)
|
||||
.slice()
|
||||
.sort((a, b) => dec(b.spent) - dec(a.spent));
|
||||
|
||||
return (
|
||||
<ContentDialogRoot open={open} onOpenChange={onOpenChange}>
|
||||
<ContentDialogBackdrop />
|
||||
<ContentDialogPositioner>
|
||||
<ContentDialogContent style={{ maxWidth: 420, width: "100%" }}>
|
||||
<ContentDialogHeader>
|
||||
<ContentDialogTitle>{s.spendBreakdown.title(horizonLabel)}</ContentDialogTitle>
|
||||
</ContentDialogHeader>
|
||||
<ContentDialogBody>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 700, color: "var(--seed-color-fg-placeholder)" }}>
|
||||
{tugrik(total)}
|
||||
</span>
|
||||
|
||||
{spent.length === 0 ? (
|
||||
<EmptyState icon="layers" title={s.spendBreakdown.empty} compact />
|
||||
) : (
|
||||
<ul className="flex flex-col gap-3">
|
||||
{spent.map((row) => {
|
||||
const style = categoryStyle(row.category);
|
||||
const value = dec(row.spent);
|
||||
const percent = total > 0 ? Math.min(100, (value / total) * 100) : 0;
|
||||
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
|
||||
return (
|
||||
<li key={row.category}>
|
||||
<Link
|
||||
href={`/planner/${encodeURIComponent(row.category)}`}
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="flex items-center gap-3"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
>
|
||||
<IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} />
|
||||
<div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{style.name}
|
||||
</span>
|
||||
<span style={{ fontWeight: 700, fontSize: 13, flexShrink: 0 }}>{tugrik(value)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div style={{ flex: 1 }}>
|
||||
<ProgressBar percent={percent} tone="brand" />
|
||||
</div>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
width: 32,
|
||||
textAlign: "right",
|
||||
flexShrink: 0,
|
||||
color: "var(--seed-color-fg-placeholder)",
|
||||
}}
|
||||
>
|
||||
{pct}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ContentDialogBody>
|
||||
<ContentDialogFooter>
|
||||
<MercuryButton variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
{s.spendBreakdown.close}
|
||||
</MercuryButton>
|
||||
</ContentDialogFooter>
|
||||
</ContentDialogContent>
|
||||
</ContentDialogPositioner>
|
||||
</ContentDialogRoot>
|
||||
);
|
||||
}
|
||||
|
|
@ -25,6 +25,14 @@ export const plannerStrings = {
|
|||
},
|
||||
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
||||
unsetHint: "Лимит тохируулаагүй",
|
||||
breakdown: "Задаргаа",
|
||||
},
|
||||
// Spend-breakdown dialog, ported from SpendBreakdownView.swift: how a
|
||||
// horizon's spend total is composed, category by category.
|
||||
spendBreakdown: {
|
||||
title: (horizonLabel: string) => `${horizonLabel} — задаргаа`,
|
||||
empty: "Гүйлгээ алга",
|
||||
close: "Хаах",
|
||||
},
|
||||
categories: {
|
||||
title: "Ангиллын лимит",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue