merge rw/planner: visual rework to reference bar

This commit is contained in:
Munkherdene 2026-08-22 22:18:47 +08:00
commit edef3272cd
2 changed files with 144 additions and 87 deletions

View file

@ -21,7 +21,7 @@ import {
ContentDialogFooter, ContentDialogFooter,
Skeleton, Skeleton,
} from "@seed-design/react"; } from "@seed-design/react";
import { Card, MercuryButton, HideAmountsToggle, NameEdit } from "@/ds"; import { Card, MercuryButton, HideAmountsToggle, NameEdit, IconChip, SectionHeader, EmptyState } from "@/ds";
import { categoryStyle } from "@/ds/categoryStyle"; import { categoryStyle } from "@/ds/categoryStyle";
import { Icon } from "@/ds/icons"; import { Icon } from "@/ds/icons";
import { tugrik, tugrikShort } from "@/ds/money"; import { tugrik, tugrikShort } from "@/ds/money";
@ -35,6 +35,12 @@ type CategoryLimit = Budget["categories"][number];
type HorizonReport = Budget["report"]["day"]; type HorizonReport = Budget["report"]["day"];
type LimitRow = HorizonReport["rows"][number]; type LimitRow = HorizonReport["rows"][number];
// Savings goals aren't a transaction category, so they get their own fixed
// chip color rather than borrowing `categoryStyle` (which maps backend
// category names → Mongolian display + color).
const SAVINGS_TINT = "#DDEAF6";
const SAVINGS_FG = "#215C9A";
function dec(v: string | undefined | null): number { function dec(v: string | undefined | null): number {
return parseFloat(v ?? "0") || 0; return parseFloat(v ?? "0") || 0;
} }
@ -43,6 +49,15 @@ function onlyDigits(v: string): string {
return v.replace(/[^0-9]/g, ""); return v.replace(/[^0-9]/g, "");
} }
/** Splits a "Title hint" string (the shape every empty-state copy in
* strings.ts uses) into its two halves for `EmptyState`, without inventing
* new copy. Falls back to the whole string as the title if there's no dash. */
function splitEmptyCopy(text: string): { title: string; hint?: string } {
const idx = text.indexOf(" — ");
if (idx === -1) return { title: text };
return { title: text.slice(0, idx), hint: text.slice(idx + 3) };
}
const HORIZON_LABEL: Record<Horizon, string> = { const HORIZON_LABEL: Record<Horizon, string> = {
day: s.horizon.day, day: s.horizon.day,
week: s.horizon.week, week: s.horizon.week,
@ -245,10 +260,18 @@ function PlannedIncomeCard({
className="flex w-full flex-col items-start gap-3 text-left" className="flex w-full flex-col items-start gap-3 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }} style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
> >
<span style={{ fontWeight: 700, fontSize: 14 }}>{s.plannedIncome.label}</span> <div className="flex w-full items-center justify-between">
<span style={{ fontWeight: 700, fontSize: 26 }}>+{tugrikShort(plannedIncome)}</span> <span style={{ fontWeight: 700, fontSize: 13, color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }}>
{s.plannedIncome.label}
</span>
<Icon name="chevron-right" size={14} style={{ color: "var(--seed-color-fg-neutral-muted, #8b8b8b)" }} />
</div>
<span style={{ fontWeight: 700, fontSize: 28 }}>+{tugrikShort(plannedIncome)}</span>
{showBreakdown && ( {showBreakdown && (
<div className="flex w-full flex-col gap-1"> <div
className="flex w-full flex-col gap-2"
style={{ borderTop: "1px solid var(--seed-color-border-default, #eee)", paddingTop: 10 }}
>
{loanObligations > 0 && ( {loanObligations > 0 && (
<BreakdownRow label={s.plannedIncome.loanObligations} value={`${tugrikShort(loanObligations)}`} /> <BreakdownRow label={s.plannedIncome.loanObligations} value={`${tugrikShort(loanObligations)}`} />
)} )}
@ -293,6 +316,7 @@ function OverallCard({
const [editing, setEditing] = React.useState(false); const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(""); const [draft, setDraft] = React.useState("");
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0; const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
const limitSet = limit > 0;
return ( return (
<div <div
@ -335,19 +359,23 @@ function OverallCard({
setEditing(true); setEditing(true);
}} }}
className="flex w-full items-center gap-4 text-left" className="flex w-full items-center gap-4 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff" }} style={{ background: "none", border: "none", padding: 0, cursor: "pointer", color: "#fff", justifyContent: "space-between" }}
> >
<ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}> <div className="flex items-center gap-4" style={{ minWidth: 0 }}>
<ProgressCircleTrack style={{ opacity: 0.3 }} /> <ProgressCircleRoot value={percent} maxValue={100} style={{ width: 49, height: 49, flexShrink: 0 }}>
<ProgressCircleRange /> <ProgressCircleTrack style={{ opacity: 0.3 }} />
</ProgressCircleRoot> <ProgressCircleRange />
<div className="flex flex-col gap-1"> </ProgressCircleRoot>
<span style={{ fontSize: 12 }}>{OVERALL_LABEL[horizon]}</span> <div className="flex flex-col gap-1">
<span style={{ fontSize: 26, fontWeight: 700 }}> <span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
{tugrikShort(spent)} <span style={{ fontSize: 26, fontWeight: 700 }}>
{limit > 0 ? ` / ${tugrikShort(limit)}` : " / —"} {tugrikShort(spent)}
</span> {limitSet ? ` / ${tugrikShort(limit)}` : ""}
</span>
{!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>
)} )}
</div> </div>
@ -377,6 +405,8 @@ function CategoryList({
const [newName, setNewName] = React.useState(""); const [newName, setNewName] = React.useState("");
const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null); const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null);
const empty = React.useMemo(() => splitEmptyCopy(s.categories.empty), []);
function openEditor(name: string) { function openEditor(name: string) {
const limit = categoryLimitFor(name); 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 : "" }); setDrafts({ day: dec(limit.day) > 0 ? limit.day : "", week: dec(limit.week) > 0 ? limit.week : "", month: dec(limit.month) > 0 ? limit.month : "" });
@ -385,12 +415,14 @@ function CategoryList({
return ( return (
<Card> <Card>
<div className="flex items-center justify-between"> <SectionHeader
<h2 style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.categories.title}</h2> title={s.categories.title}
<MercuryButton variant="ghost" onClick={() => { setNewName(""); setAdding(true); }}> onAction={() => {
{s.categories.add} setNewName("");
</MercuryButton> setAdding(true);
</div> }}
actionLabel={s.categories.add}
/>
{adding && ( {adding && (
<div className="mt-3 flex flex-col gap-2"> <div className="mt-3 flex flex-col gap-2">
@ -425,9 +457,7 @@ function CategoryList({
)} )}
{!loading && rows.length === 0 && ( {!loading && rows.length === 0 && (
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}> <EmptyState icon="layers" title={empty.title} hint={empty.hint} compact />
{s.categories.empty}
</p>
)} )}
{!loading && rows.length > 0 && ( {!loading && rows.length > 0 && (
@ -437,6 +467,7 @@ function CategoryList({
const limit = dec(row.limit); const limit = dec(row.limit);
const over = limit > 0 && spent > limit; const over = limit > 0 && spent > limit;
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0; const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
const style = categoryStyle(row.category);
if (editingName === row.category) { if (editingName === row.category) {
return ( return (
@ -472,47 +503,59 @@ function CategoryList({
} }
return ( return (
<li key={row.category} className="flex flex-col gap-2"> <li key={row.category} className="flex items-center gap-3">
<div className="flex items-center gap-2"> <Link
<Link href={`/planner/${encodeURIComponent(row.category)}`}
href={`/planner/${encodeURIComponent(row.category)}`} className="flex flex-1 items-center gap-3"
className="flex flex-1 flex-col gap-2" style={{ color: "inherit", textDecoration: "none", minWidth: 0 }}
style={{ color: "inherit", textDecoration: "none" }} >
> <IconChip icon={style.icon} tint={style.tint} fg={style.fg} size={36} />
<div className="flex items-center justify-between"> <div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
<span style={{ display: "inline-flex", alignItems: "center", gap: 10, fontWeight: 700 }}> <div className="flex items-center justify-between gap-2">
<span <span
aria-hidden style={{
style={{ fontWeight: 700,
width: 34, fontSize: 14,
height: 34, overflow: "hidden",
borderRadius: 11, textOverflow: "ellipsis",
display: "grid", whiteSpace: "nowrap",
placeItems: "center", }}
color: categoryStyle(row.category).fg, >
background: categoryStyle(row.category).tint, {style.name}
}}
>
<Icon name={categoryStyle(row.category).icon} size={18} />
</span>
{categoryStyle(row.category).name}
</span> </span>
<span style={{ fontWeight: 700, color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)" }}> <span
style={{
fontWeight: 700,
fontSize: 13,
flexShrink: 0,
color: over ? "var(--seed-color-fg-critical)" : "var(--seed-color-fg-placeholder)",
}}
>
{tugrik(spent)} {tugrik(spent)}
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"} {limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
</span> </span>
</div> </div>
<ProgressBar percent={percent} tone={over ? "critical" : "brand"} /> <ProgressBar percent={percent} tone={over ? "critical" : "brand"} />
</Link> </div>
<button </Link>
type="button" <button
onClick={() => openEditor(row.category)} type="button"
aria-label={s.categories.editTitle(row.category, HORIZON_LABEL[horizon])} onClick={() => openEditor(row.category)}
style={{ background: "none", border: "none", cursor: "pointer", color: "var(--seed-color-fg-placeholder)" }} aria-label={s.categories.editTitle(row.category, HORIZON_LABEL[horizon])}
> style={{
all: "unset",
</button> cursor: "pointer",
</div> width: 28,
height: 28,
borderRadius: 8,
flexShrink: 0,
display: "grid",
placeItems: "center",
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
}}
>
</button>
</li> </li>
); );
})} })}
@ -587,20 +630,13 @@ function SavingsSection({
const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null); const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null);
const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null); const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null);
const empty = React.useMemo(() => splitEmptyCopy(s.savings.empty), []);
return ( return (
<Card> <Card>
<div className="flex items-center justify-between"> <SectionHeader title={s.savings.title} onAction={() => setEditing({ mode: "add" })} actionLabel={s.savings.add} />
<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 && ( {goals.length === 0 && !editing && <EmptyState icon="hand-coins" title={empty.title} hint={empty.hint} compact />}
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
{s.savings.empty}
</p>
)}
{goals.length > 0 && ( {goals.length > 0 && (
<ul className="mt-4 flex flex-col gap-4"> <ul className="mt-4 flex flex-col gap-4">
@ -616,26 +652,46 @@ function SavingsSection({
<button <button
type="button" type="button"
onClick={() => setEditing({ mode: "edit", goal })} onClick={() => setEditing({ mode: "edit", goal })}
className="flex w-full flex-col gap-2 text-left" className="flex w-full items-center gap-3 text-left"
style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }} style={{ background: "none", border: "none", padding: 0, cursor: "pointer" }}
> >
<div className="flex items-center justify-between"> <IconChip icon="hand-coins" tint={SAVINGS_TINT} fg={SAVINGS_FG} size={36} />
<span style={{ fontWeight: 700 }}>{goal.name}</span> <div className="flex flex-1 flex-col gap-2" style={{ minWidth: 0 }}>
<span style={{ fontWeight: 700, color: done ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-placeholder)" }}> <div className="flex items-center justify-between gap-2">
{tugrik(saved)} <span
{target > 0 ? ` / ${tugrik(target)}` : " / —"} style={{
</span> fontWeight: 700,
fontSize: 14,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{goal.name}
</span>
<span
style={{
fontWeight: 700,
fontSize: 13,
flexShrink: 0,
color: done ? "var(--seed-color-fg-positive)" : "var(--seed-color-fg-placeholder)",
}}
>
{tugrik(saved)}
{target > 0 ? ` / ${tugrik(target)}` : " / —"}
</span>
</div>
<ProgressBar percent={percent} tone="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>
)}
</div> </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> </button>
</li> </li>
); );

View file

@ -24,6 +24,7 @@ export const plannerStrings = {
month: "Энэ сарын лимит", month: "Энэ сарын лимит",
}, },
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`, editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
unsetHint: "Лимит тохируулаагүй",
}, },
categories: { categories: {
title: "Ангиллын лимит", title: "Ангиллын лимит",