merge feat/aux: feature build-out
This commit is contained in:
commit
245ef6ea03
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,
|
email_taken: authStrings.errors.emailTaken,
|
||||||
invalid_code: authStrings.errors.invalidCode,
|
invalid_code: authStrings.errors.invalidCode,
|
||||||
invalid_challenge: authStrings.errors.invalidCode,
|
invalid_challenge: authStrings.errors.invalidCode,
|
||||||
|
invalid_token: authStrings.errors.invalidToken,
|
||||||
};
|
};
|
||||||
|
|
||||||
function messageForCode(code: string | undefined): string {
|
function messageForCode(code: string | undefined): string {
|
||||||
|
|
@ -23,7 +24,7 @@ function messageForCode(code: string | undefined): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function postAuth(
|
async function postAuth(
|
||||||
path: "login" | "register" | "forgot" | "2fa",
|
path: "login" | "register" | "forgot" | "2fa" | "reset" | "verify",
|
||||||
body: unknown,
|
body: unknown,
|
||||||
): Promise<AuthActionResult> {
|
): Promise<AuthActionResult> {
|
||||||
let res: Response;
|
let res: Response;
|
||||||
|
|
@ -73,3 +74,20 @@ export function submit2FA(
|
||||||
export function submitForgot(email: string): Promise<AuthActionResult> {
|
export function submitForgot(email: string): Promise<AuthActionResult> {
|
||||||
return postAuth("forgot", { email });
|
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: "Сэргээх холбоос авах",
|
submit: "Сэргээх холбоос авах",
|
||||||
sent: "Хэрэв бүртгэл байгаа бол сэргээх холбоосыг имэйлээр илгээлээ.",
|
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: {
|
twoFactor: {
|
||||||
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
|
subtitle: "Имэйлээр илгээсэн баталгаажуулах кодоо оруулна уу.",
|
||||||
code: "Баталгаажуулах код",
|
code: "Баталгаажуулах код",
|
||||||
|
|
@ -40,6 +60,7 @@ export const authStrings = {
|
||||||
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
|
invalidCredentials: "Имэйл эсвэл нууц үг буруу байна.",
|
||||||
emailTaken: "Энэ имэйл бүртгэлтэй байна.",
|
emailTaken: "Энэ имэйл бүртгэлтэй байна.",
|
||||||
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
|
invalidCode: "Код буруу эсвэл хугацаа дууссан байна.",
|
||||||
|
invalidToken: "Холбоосны хугацаа дууссан эсвэл буруу байна.",
|
||||||
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
|
network: "Mercury-тэй холбогдож чадсангүй — холболтоо шалгана уу.",
|
||||||
unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
|
unknown: "Алдаа гарлаа. Дахин оролдоно уу.",
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import { tugrik, tugrikShort } from "@/ds/money";
|
||||||
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
import { useBudget, useNetWorth } from "@/api/hooks/reads";
|
||||||
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
import { usePutBudget, useSavingsGoalMutations } from "@/api/hooks/mutations";
|
||||||
import type { Budget, SavingsGoal, Account } from "@/api/schemas";
|
import type { Budget, SavingsGoal, Account } from "@/api/schemas";
|
||||||
|
import { SpendBreakdown } from "./SpendBreakdown";
|
||||||
import { plannerStrings as s } from "./strings";
|
import { plannerStrings as s } from "./strings";
|
||||||
|
|
||||||
type Horizon = "day" | "week" | "month";
|
type Horizon = "day" | "week" | "month";
|
||||||
|
|
@ -82,6 +83,7 @@ export function PlannerView() {
|
||||||
const goalMutations = useSavingsGoalMutations();
|
const goalMutations = useSavingsGoalMutations();
|
||||||
|
|
||||||
const [horizon, setHorizon] = React.useState<Horizon>("day");
|
const [horizon, setHorizon] = React.useState<Horizon>("day");
|
||||||
|
const [breakdownOpen, setBreakdownOpen] = React.useState(false);
|
||||||
|
|
||||||
const accounts: Account[] = netWorth?.accounts ?? [];
|
const accounts: Account[] = netWorth?.accounts ?? [];
|
||||||
|
|
||||||
|
|
@ -167,6 +169,15 @@ export function PlannerView() {
|
||||||
limit={overallLimit}
|
limit={overallLimit}
|
||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onSave={(v) => saveOverall(horizon, v)}
|
onSave={(v) => saveOverall(horizon, v)}
|
||||||
|
onOpenBreakdown={() => setBreakdownOpen(true)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SpendBreakdown
|
||||||
|
open={breakdownOpen}
|
||||||
|
onOpenChange={setBreakdownOpen}
|
||||||
|
horizonLabel={OVERALL_LABEL[horizon]}
|
||||||
|
total={overallSpent}
|
||||||
|
rows={rows}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<CategoryList
|
<CategoryList
|
||||||
|
|
@ -306,12 +317,14 @@ function OverallCard({
|
||||||
limit,
|
limit,
|
||||||
loading,
|
loading,
|
||||||
onSave,
|
onSave,
|
||||||
|
onOpenBreakdown,
|
||||||
}: {
|
}: {
|
||||||
horizon: Horizon;
|
horizon: Horizon;
|
||||||
spent: number;
|
spent: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
onSave: (value: number) => void;
|
onSave: (value: number) => void;
|
||||||
|
onOpenBreakdown: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [editing, setEditing] = React.useState(false);
|
const [editing, setEditing] = React.useState(false);
|
||||||
const [draft, setDraft] = React.useState("");
|
const [draft, setDraft] = React.useState("");
|
||||||
|
|
@ -352,31 +365,54 @@ function OverallCard({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<div className="flex flex-col gap-2">
|
||||||
type="button"
|
<div className="flex items-center justify-end">
|
||||||
onClick={() => {
|
<button
|
||||||
setDraft(limit > 0 ? String(limit) : "");
|
type="button"
|
||||||
setEditing(true);
|
onClick={onOpenBreakdown}
|
||||||
}}
|
className="flex items-center gap-1"
|
||||||
className="flex w-full items-center gap-4 text-left"
|
style={{
|
||||||
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }}
|
background: "none",
|
||||||
>
|
border: "none",
|
||||||
<div className="flex items-center gap-4" style={{ minWidth: 0 }}>
|
padding: "2px 0",
|
||||||
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
|
cursor: "pointer",
|
||||||
<ProgressCircleTrack style={{ opacity: 0.3 }} />
|
color: "#fff",
|
||||||
<ProgressCircleRange />
|
opacity: 0.85,
|
||||||
</ProgressCircleRoot>
|
fontSize: 12,
|
||||||
<div className="flex flex-col gap-1">
|
fontWeight: 600,
|
||||||
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
|
}}
|
||||||
<span style={{ fontSize: 26, fontWeight: 700 }}>
|
>
|
||||||
{tugrikShort(spent)}
|
<Icon name="trending" size={13} />
|
||||||
{limitSet ? ` / ${tugrikShort(limit)}` : ""}
|
{s.overall.breakdown}
|
||||||
</span>
|
</button>
|
||||||
{!limitSet && <span style={{ fontSize: 12, opacity: 0.75 }}>{s.overall.unsetHint}</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</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 (
|
return (
|
||||||
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
|
<div style={{ height: 6, borderRadius: 999, background: "var(--seed-color-bg-neutral-weak, #eee)", overflow: "hidden" }}>
|
||||||
<div
|
<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} — нийт лимит`,
|
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
||||||
unsetHint: "Лимит тохируулаагүй",
|
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: {
|
categories: {
|
||||||
title: "Ангиллын лимит",
|
title: "Ангиллын лимит",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue