feat(web): rework dashboard visuals to reference bar
This commit is contained in:
parent
e0c07be232
commit
1bb43e58c2
3 changed files with 113 additions and 40 deletions
|
|
@ -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 }}>
|
||||||
<Card>
|
{loading ? (
|
||||||
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.income}</span>
|
<>
|
||||||
{data.income.map((row, i) => (
|
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
|
<SectionHeader title={s.income} />
|
||||||
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
|
<LedgerRowSkeleton />
|
||||||
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
|
</Card>
|
||||||
</div>
|
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
))}
|
<SectionHeader title={s.expense} />
|
||||||
</Card>
|
<LedgerRowSkeleton />
|
||||||
<Card>
|
</Card>
|
||||||
<span style={{ fontWeight: 700, fontSize: 14, color: "var(--seed-color-fg-placeholder)" }}>{s.expense}</span>
|
</>
|
||||||
{data.expense.map((row, i) => (
|
) : sparseMonth ? (
|
||||||
<div key={i} style={{ display: "flex", justifyContent: "space-between", padding: "6px 0" }}>
|
<Card>
|
||||||
<span style={{ color: "var(--seed-color-fg-neutral)" }}>{row.title}</span>
|
<EmptyState icon="receipt" title={s.noSpendTitle} hint={s.noSpendHint} compact />
|
||||||
<span style={{ color: "var(--seed-color-fg-placeholder)" }}>{row.date}</span>
|
</Card>
|
||||||
</div>
|
) : (
|
||||||
))}
|
<>
|
||||||
</Card>
|
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<SectionHeader title={s.income} />
|
||||||
|
{data.income.map((row, i) => (
|
||||||
|
<LedgerEntryRow key={i} row={row} income />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
<Card style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
<SectionHeader title={s.expense} />
|
||||||
|
{data.expense.map((row, i) => (
|
||||||
|
<LedgerEntryRow key={i} row={row} income={false} />
|
||||||
|
))}
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -13,4 +13,6 @@ export const homeStrings = {
|
||||||
income: "Орлого",
|
income: "Орлого",
|
||||||
expense: "Зарлага",
|
expense: "Зарлага",
|
||||||
todaySpent: "Өнөөдөр зарцуулсан",
|
todaySpent: "Өнөөдөр зарцуулсан",
|
||||||
|
noSpendTitle: "Энэ сар хараахан зарлага алга",
|
||||||
|
noSpendHint: "Гүйлгээ хийгдмэгц энд харагдана.",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue