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,
|
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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ export const plannerStrings = {
|
||||||
month: "Энэ сарын лимит",
|
month: "Энэ сарын лимит",
|
||||||
},
|
},
|
||||||
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
editTitle: (horizonLabel: string) => `${horizonLabel} — нийт лимит`,
|
||||||
|
unsetHint: "Лимит тохируулаагүй",
|
||||||
},
|
},
|
||||||
categories: {
|
categories: {
|
||||||
title: "Ангиллын лимит",
|
title: "Ангиллын лимит",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue