feat(web): rework planner visuals to reference bar
Elevate the Planner page presentation to match the TransactionList reference: category-limit and savings-goal rows now use IconChip for the category/goal chip, SectionHeader for the "+" add actions, and EmptyState for the empty limits/goals states (split from the existing Mongolian copy, no new strings invented). The unset daily-limit card now shows a subtle "Лимит тохируулаагүй" hint instead of "0₮ / —". Pure presentation — usePutBudget's full-categories-echo save and all mutation/dialog logic are untouched.
This commit is contained in:
parent
e0c07be232
commit
404ab94e1c
2 changed files with 144 additions and 87 deletions
|
|
@ -21,7 +21,7 @@ import {
|
|||
ContentDialogFooter,
|
||||
Skeleton,
|
||||
} 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 { Icon } from "@/ds/icons";
|
||||
import { tugrik, tugrikShort } from "@/ds/money";
|
||||
|
|
@ -35,6 +35,12 @@ type CategoryLimit = Budget["categories"][number];
|
|||
type HorizonReport = Budget["report"]["day"];
|
||||
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 {
|
||||
return parseFloat(v ?? "0") || 0;
|
||||
}
|
||||
|
|
@ -43,6 +49,15 @@ function onlyDigits(v: string): string {
|
|||
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> = {
|
||||
day: s.horizon.day,
|
||||
week: s.horizon.week,
|
||||
|
|
@ -245,10 +260,18 @@ function PlannedIncomeCard({
|
|||
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>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<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 && (
|
||||
<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 && (
|
||||
<BreakdownRow label={s.plannedIncome.loanObligations} value={`−${tugrikShort(loanObligations)}`} />
|
||||
)}
|
||||
|
|
@ -293,6 +316,7 @@ function OverallCard({
|
|||
const [editing, setEditing] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
|
||||
const limitSet = limit > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -335,19 +359,23 @@ function OverallCard({
|
|||
setEditing(true);
|
||||
}}
|
||||
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" }}
|
||||
>
|
||||
<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 }}>{OVERALL_LABEL[horizon]}</span>
|
||||
<span style={{ fontSize: 12, opacity: 0.85 }}>{OVERALL_LABEL[horizon]}</span>
|
||||
<span style={{ fontSize: 26, fontWeight: 700 }}>
|
||||
{tugrikShort(spent)}
|
||||
{limit > 0 ? ` / ${tugrikShort(limit)}` : " / —"}
|
||||
{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>
|
||||
|
|
@ -377,6 +405,8 @@ function CategoryList({
|
|||
const [newName, setNewName] = React.useState("");
|
||||
const [confirmRemove, setConfirmRemove] = React.useState<string | null>(null);
|
||||
|
||||
const empty = React.useMemo(() => splitEmptyCopy(s.categories.empty), []);
|
||||
|
||||
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 : "" });
|
||||
|
|
@ -385,12 +415,14 @@ function CategoryList({
|
|||
|
||||
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>
|
||||
<SectionHeader
|
||||
title={s.categories.title}
|
||||
onAction={() => {
|
||||
setNewName("");
|
||||
setAdding(true);
|
||||
}}
|
||||
actionLabel={s.categories.add}
|
||||
/>
|
||||
|
||||
{adding && (
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
|
|
@ -425,9 +457,7 @@ function CategoryList({
|
|||
)}
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
|
||||
{s.categories.empty}
|
||||
</p>
|
||||
<EmptyState icon="layers" title={empty.title} hint={empty.hint} compact />
|
||||
)}
|
||||
|
||||
{!loading && rows.length > 0 && (
|
||||
|
|
@ -437,6 +467,7 @@ function CategoryList({
|
|||
const limit = dec(row.limit);
|
||||
const over = limit > 0 && spent > limit;
|
||||
const percent = limit > 0 ? Math.min(100, (spent / limit) * 100) : 0;
|
||||
const style = categoryStyle(row.category);
|
||||
|
||||
if (editingName === row.category) {
|
||||
return (
|
||||
|
|
@ -472,47 +503,59 @@ function CategoryList({
|
|||
}
|
||||
|
||||
return (
|
||||
<li key={row.category} className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<li key={row.category} className="flex items-center gap-3">
|
||||
<Link
|
||||
href={`/planner/${encodeURIComponent(row.category)}`}
|
||||
className="flex flex-1 flex-col gap-2"
|
||||
style={{ color: "inherit", textDecoration: "none" }}
|
||||
className="flex flex-1 items-center gap-3"
|
||||
style={{ color: "inherit", textDecoration: "none", minWidth: 0 }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: 10, fontWeight: 700 }}>
|
||||
<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
|
||||
aria-hidden
|
||||
style={{
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 11,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: categoryStyle(row.category).fg,
|
||||
background: categoryStyle(row.category).tint,
|
||||
fontWeight: 700,
|
||||
fontSize: 14,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
<Icon name={categoryStyle(row.category).icon} size={18} />
|
||||
{style.name}
|
||||
</span>
|
||||
{categoryStyle(row.category).name}
|
||||
</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)}
|
||||
{limit > 0 ? ` / ${tugrik(limit)}` : " / —"}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar percent={percent} tone={over ? "critical" : "brand"} />
|
||||
</div>
|
||||
</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)" }}
|
||||
style={{
|
||||
all: "unset",
|
||||
cursor: "pointer",
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 8,
|
||||
flexShrink: 0,
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
color: "var(--seed-color-fg-neutral-muted, #8b8b8b)",
|
||||
}}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
|
@ -587,20 +630,13 @@ function SavingsSection({
|
|||
const [editing, setEditing] = React.useState<{ mode: "add" | "edit"; goal?: SavingsGoal } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = React.useState<string | null>(null);
|
||||
|
||||
const empty = React.useMemo(() => splitEmptyCopy(s.savings.empty), []);
|
||||
|
||||
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>
|
||||
<SectionHeader title={s.savings.title} onAction={() => setEditing({ mode: "add" })} actionLabel={s.savings.add} />
|
||||
|
||||
{goals.length === 0 && !editing && (
|
||||
<p className="mt-3" style={{ color: "var(--seed-color-fg-placeholder)" }}>
|
||||
{s.savings.empty}
|
||||
</p>
|
||||
)}
|
||||
{goals.length === 0 && !editing && <EmptyState icon="hand-coins" title={empty.title} hint={empty.hint} compact />}
|
||||
|
||||
{goals.length > 0 && (
|
||||
<ul className="mt-4 flex flex-col gap-4">
|
||||
|
|
@ -616,17 +652,36 @@ function SavingsSection({
|
|||
<button
|
||||
type="button"
|
||||
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" }}
|
||||
>
|
||||
<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)" }}>
|
||||
<IconChip icon="hand-coins" tint={SAVINGS_TINT} fg={SAVINGS_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",
|
||||
}}
|
||||
>
|
||||
{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={done ? "brand" : "brand"} />
|
||||
<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>}
|
||||
|
|
@ -636,6 +691,7 @@ function SavingsSection({
|
|||
{s.savings.remaining} {tugrik(remaining)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ export const plannerStrings = {
|
|||
month: "Энэ сарын лимит",
|
||||
},
|
||||
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
||||
unsetHint: "Лимит тохируулаагүй",
|
||||
},
|
||||
categories: {
|
||||
title: "Ангиллын лимит",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue