feat(web): rework dashboard visuals to reference bar

This commit is contained in:
Munkherdene 2026-08-22 22:17:22 +08:00
parent e0c07be232
commit 1bb43e58c2
3 changed files with 113 additions and 40 deletions

View file

@ -3,10 +3,10 @@
import * as React from "react"; import * as React from "react";
import Link from "next/link"; import Link from "next/link";
import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react"; import { Skeleton, ProgressCircleRoot, ProgressCircleTrack, ProgressCircleRange } from "@seed-design/react";
import { Card, AmountToggle, HideAmountsToggle } from "../../ds"; import { Card, AmountToggle, HideAmountsToggle, IconChip, SectionHeader, EmptyState, categoryStyle } from "../../ds";
import { tugrikShortRaw } from "../../ds/money"; import { tugrikShortRaw } from "../../ds/money";
import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads"; import { useNetWorth, useAnalyzeMonth, useAnalyzeToday, useBudget } from "../../api/hooks/reads";
import { buildHome, type HomeData } from "./buildHome"; import { buildHome, type HomeData, type LedgerRow as LedgerRowData } from "./buildHome";
import { sample } from "./sample"; import { sample } from "./sample";
import { homeStrings as s } from "./strings"; import { homeStrings as s } from "./strings";
@ -42,6 +42,56 @@ function TugrikCircle({ bg, fg }: { bg: string; fg: string }) {
); );
} }
/** One Орлого/Зарлага row: an IconChip (income the wallet/green "income"
* style; expense the payee's category style, falling back to a neutral
* receipt when unknown) + the payee name + a trailing colored amount.
* Mirrors TransactionList's TxnRow pattern. */
function LedgerEntryRow({ row, income }: { row: LedgerRowData; income: boolean }) {
const cat = categoryStyle(income ? undefined : row.date, income);
return (
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "8px 0" }}>
<div style={{ display: "flex", alignItems: "center", gap: 12, minWidth: 0 }}>
<IconChip icon={cat.icon} tint={cat.tint} fg={cat.fg} />
<span
style={{
fontSize: 15,
fontWeight: 700,
color: "var(--seed-color-fg-neutral)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{row.date}
</span>
</div>
<span
style={{
fontSize: 15,
fontWeight: 700,
flexShrink: 0,
color: income ? "var(--mercury-success, #1e9e6b)" : "var(--mercury-critical, #e5484d)",
}}
>
{income ? "+" : ""}
{row.title}
</span>
</div>
);
}
/** Skeleton placeholder for a ledger row, shaped like `LedgerEntryRow` so the
* layout doesn't jump once real data (or the sample fallback) arrives
* avoids flashing the sample payee/amount as if it were a real loaded row. */
function LedgerRowSkeleton() {
return (
<div style={{ display: "flex", alignItems: "center", gap: 12, padding: "8px 0" }}>
<Skeleton height="40px" width="40px" style={{ borderRadius: 13, flexShrink: 0 }} />
<Skeleton height="1em" width="55%" />
</div>
);
}
/** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit + /** The Нүүр (home) dashboard: header, safe-to-spend hero, daily-limit +
* top budgets, this-month spend, and recent income/expense. Figures come * top budgets, this-month spend, and recent income/expense. Figures come
* from `buildHome` (real where the backend has them, `sample` otherwise). * from `buildHome` (real where the backend has them, `sample` otherwise).
@ -66,6 +116,14 @@ export function DashboardView() {
const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0; const fraction = budgetDenominator > 0 ? Math.min(1, data.monthlyExpense / budgetDenominator) : 0;
const dailyFraction = data.dailyLimitTotal > 0 ? Math.min(1, data.dailyLimitUsed / data.dailyLimitTotal) : 0; const dailyFraction = data.dailyLimitTotal > 0 ? Math.min(1, data.dailyLimitUsed / data.dailyLimitTotal) : 0;
// Genuinely sparse month: real backend data loaded (`built` succeeded, so
// this isn't the full pre-connection sample), but no discretionary spend
// and no real income/expense payees this month — show a designed empty
// state instead of the sample ledger placeholders standing in as if real.
const noRealPayees =
(monthQ.data?.incomePayees?.length ?? 0) === 0 && (monthQ.data?.expensePayees?.length ?? 0) === 0;
const sparseMonth = built !== null && data.monthlyExpense === 0 && noRealPayees;
return ( return (
<div style={{ display: "flex", flexDirection: "column", gap: 18 }}> <div style={{ display: "flex", flexDirection: "column", gap: 18 }}>
<header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}> <header style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
@ -84,21 +142,21 @@ export function DashboardView() {
color: "var(--mercury-on-brand)", color: "var(--mercury-on-brand)",
background: "var(--mercury-balance-card)", background: "var(--mercury-balance-card)",
borderRadius: "var(--seed-radius-r5, 20px)", borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px", padding: "18px 20px",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" /> <TugrikCircle bg="var(--mercury-balance-circle)" fg="var(--mercury-on-brand)" />
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 12 }}>{data.overspent ? s.overspent : s.safeToSpend}</span> <span style={{ fontSize: 12, opacity: 0.85 }}>{data.overspent ? s.overspent : s.safeToSpend}</span>
<span style={{ fontSize: 26, fontWeight: 700 }}> <span style={{ fontSize: 27, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.safeToSpend} loading={loading} width="140px" /> <Amount value={data.safeToSpend} loading={loading} width="140px" />
</span> </span>
</div> </div>
</div> </div>
<div <div
style={{ style={{
marginTop: 12, marginTop: 14,
height: 6, height: 6,
borderRadius: 999, borderRadius: 999,
background: "rgba(0,0,0,0.13)", background: "rgba(0,0,0,0.13)",
@ -114,11 +172,11 @@ export function DashboardView() {
}} }}
/> />
</div> </div>
<div style={{ marginTop: 8, display: "flex", justifyContent: "space-between", fontSize: 12 }}> <div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", fontSize: 12 }}>
<span>{s.spent}</span> <span style={{ opacity: 0.85 }}>{s.spent}</span>
<span style={{ fontWeight: 700, fontSize: 13 }}> <span style={{ fontWeight: 700, fontSize: 13 }}>
<Amount value={data.monthlyExpense} loading={loading} /> <Amount value={data.monthlyExpense} loading={loading} />
<span style={{ fontWeight: 400, fontSize: 11 }}> / {tugrikShortRaw(budgetDenominator)}</span> <span style={{ fontWeight: 400, fontSize: 11, opacity: 0.85 }}> / {tugrikShortRaw(budgetDenominator)}</span>
</span> </span>
</div> </div>
</Link> </Link>
@ -132,30 +190,30 @@ export function DashboardView() {
color: "#fff", color: "#fff",
background: "var(--mercury-limit-card)", background: "var(--mercury-limit-card)",
borderRadius: "var(--seed-radius-r5, 20px)", borderRadius: "var(--seed-radius-r5, 20px)",
padding: "14px 20px", padding: "18px 20px",
}} }}
> >
<div style={{ display: "flex", alignItems: "center", gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 14 }}>
<ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite"> <ProgressCircleRoot value={dailyFraction * 100} size="40" tone="staticWhite">
<ProgressCircleTrack /> <ProgressCircleTrack />
<ProgressCircleRange /> <ProgressCircleRange />
</ProgressCircleRoot> </ProgressCircleRoot>
<div style={{ display: "flex", flexDirection: "column" }}> <div style={{ display: "flex", flexDirection: "column", gap: 2 }}>
<span style={{ fontSize: 12 }}>{s.dailyLimit}</span> <span style={{ fontSize: 12, opacity: 0.85 }}>{s.dailyLimit}</span>
<span style={{ fontSize: 22, fontWeight: 700 }}> <span style={{ fontSize: 22, fontWeight: 700, lineHeight: 1.15 }}>
<Amount value={data.dailyLimitUsed} loading={loading} width="60px" /> <Amount value={data.dailyLimitUsed} loading={loading} width="60px" />
{" / "} {" / "}
<Amount value={data.dailyLimitTotal} loading={loading} width="60px" /> <Amount value={data.dailyLimitTotal} loading={loading} width="60px" />
</span> </span>
</div> </div>
</div> </div>
<div style={{ marginTop: 4, fontSize: 11, opacity: 0.85 }}> <div style={{ marginTop: 6, fontSize: 11, opacity: 0.85 }}>
{s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" /> {s.todaySpent}: <Amount value={data.todayExpense} loading={loading} width="50px" />
</div> </div>
<hr style={{ margin: "12px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} /> <hr style={{ margin: "14px 0", border: 0, borderTop: "1px solid rgba(0,0,0,0.13)" }} />
{data.budgets.map((b) => ( {data.budgets.map((b) => (
<div key={b.name} style={{ display: "flex", justifyContent: "space-between", padding: "4px 0" }}> <div key={b.name} style={{ display: "flex", justifyContent: "space-between", alignItems: "baseline", padding: "6px 0" }}>
<span style={{ fontSize: 12 }}>{b.name}</span> <span style={{ fontSize: 12, opacity: 0.85 }}>{b.name}</span>
<span style={{ fontSize: 14, fontWeight: 700 }}> <span style={{ fontSize: 14, fontWeight: 700 }}>
<span style={{ color: "var(--mercury-warning-chip)" }}> <span style={{ color: "var(--mercury-warning-chip)" }}>
<Amount value={b.spent} loading={loading} width="50px" /> <Amount value={b.spent} loading={loading} width="50px" />
@ -200,24 +258,37 @@ export function DashboardView() {
{/* Recent income / expense ledgers. */} {/* Recent income / expense ledgers. */}
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}> <div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{loading ? (
<>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<SectionHeader title={s.income} />
<LedgerRowSkeleton />
</Card>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<SectionHeader title={s.expense} />
<LedgerRowSkeleton />
</Card>
</>
) : sparseMonth ? (
<Card> <Card>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.income}</span> <EmptyState icon="receipt" title={s.noSpendTitle} hint={s.noSpendHint} compact />
</Card>
) : (
<>
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<SectionHeader title={s.income} />
{data.income.map((row, i) => ( {data.income.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}> <LedgerEntryRow key={i} row={row} income />
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))} ))}
</Card> </Card>
<Card> <Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.expense}</span> <SectionHeader title={s.expense} />
{data.expense.map((row, i) => ( {data.expense.map((row, i) => (
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}> <LedgerEntryRow key={i} row={row} income={false} />
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
</div>
))} ))}
</Card> </Card>
</>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -18,8 +18,8 @@ export const sample: HomeData = {
], ],
monthlyExpense, monthlyExpense,
availableBudget, availableBudget,
income: [{ title: "1,345,634₮", date: "04.01" }], income: [{ title: "1,345,634₮", date: "Цалин" }],
expense: [{ title: "худалдан авалт", date: "04.01" }], expense: [{ title: "52,000₮", date: "Худалдан авалт" }],
safeToSpend: Math.max(0, availableBudget - monthlyExpense), safeToSpend: Math.max(0, availableBudget - monthlyExpense),
overspent: monthlyExpense > availableBudget, overspent: monthlyExpense > availableBudget,
}; };

View file

@ -13,4 +13,6 @@ export const homeStrings = {
income: "Орлого", income: "Орлого",
expense: "Зарлага", expense: "Зарлага",
todaySpent: "Өнөөдөр зарцуулсан", todaySpent: "Өнөөдөр зарцуулсан",
noSpendTitle: "Энэ сар хараахан зарлага алга",
noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.",
} as const; } as const;