feat(web): income card on home — salary vs support + what's kept
New card on the money-clarity home showing this month's income split into earned salary and support/other, with a spent-vs-income bar and the save rate. Surfaces the backend income breakdown so the reliable-earnings-vs- variable-support distinction is visible, not just in reports.
This commit is contained in:
parent
fdebc97c9a
commit
741d5ca0f4
3 changed files with 70 additions and 3 deletions
|
|
@ -4,6 +4,8 @@ export const MonthSchema = z.object({ month: z.string(), income: z.string(), exp
|
||||||
export const AnalyzeSchema = z.object({ from: z.string(), to: z.string(), income: z.string(), expense: z.string(),
|
export const AnalyzeSchema = z.object({ from: z.string(), to: z.string(), income: z.string(), expense: z.string(),
|
||||||
discretionaryExpense: z.string().nullish(), net: z.string(), months: z.array(MonthSchema).nullish(),
|
discretionaryExpense: z.string().nullish(), net: z.string(), months: z.array(MonthSchema).nullish(),
|
||||||
expenseCategories: z.array(NamedSchema).nullish(), incomePayees: z.array(NamedSchema).nullish(),
|
expenseCategories: z.array(NamedSchema).nullish(), incomePayees: z.array(NamedSchema).nullish(),
|
||||||
expensePayees: z.array(NamedSchema).nullish() });
|
expensePayees: z.array(NamedSchema).nullish(),
|
||||||
|
salaryIncome: z.string().nullish(), otherIncome: z.string().nullish(),
|
||||||
|
monthlySalary: z.string().nullish(), nextPayday: z.string().nullish() });
|
||||||
export type Named = z.infer<typeof NamedSchema>;
|
export type Named = z.infer<typeof NamedSchema>;
|
||||||
export type Analyze = z.infer<typeof AnalyzeSchema>;
|
export type Analyze = z.infer<typeof AnalyzeSchema>;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import {
|
||||||
useSubscriptions,
|
useSubscriptions,
|
||||||
useTransactions,
|
useTransactions,
|
||||||
} from "../../api/hooks/reads";
|
} from "../../api/hooks/reads";
|
||||||
import type { Named } from "../../api/schemas/analyze";
|
import type { Named, Analyze } from "../../api/schemas/analyze";
|
||||||
import type { Subscription } from "../../api/schemas/subscription";
|
import type { Subscription } from "../../api/schemas/subscription";
|
||||||
import { buildHome, dec, type HomeData } from "./buildHome";
|
import { buildHome, dec, type HomeData } from "./buildHome";
|
||||||
import { buildTrendMonths, buildCashFlowVerdict, type TrendMonth } from "./cashFlowTrend";
|
import { buildTrendMonths, buildCashFlowVerdict, type TrendMonth } from "./cashFlowTrend";
|
||||||
|
|
@ -232,6 +232,59 @@ function Dot({ color }: { color: string }) {
|
||||||
return <span aria-hidden style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2, background: color, marginRight: 5 }} />;
|
return <span aria-hidden style={{ display: "inline-block", width: 7, height: 7, borderRadius: 2, background: color, marginRight: 5 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One labelled income component (salary / support) with a leading colour dot. */
|
||||||
|
function IncomeRow({ label, value, dotColor, loading, hidden }: { label: string; value: number; dotColor: string; loading: boolean; hidden: boolean }) {
|
||||||
|
return (
|
||||||
|
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
|
||||||
|
<span style={{ display: "flex", alignItems: "center", gap: 8, fontSize: 14 }}>
|
||||||
|
<span aria-hidden style={{ width: 10, height: 10, borderRadius: "50%", background: dotColor, flex: "none" }} />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 700 }}>
|
||||||
|
<Amount value={value} loading={loading} hidden={hidden} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Income card: this month's earnings split into salary vs support/other, and
|
||||||
|
* how much was kept, so reliable pay reads apart from variable family support. */
|
||||||
|
function IncomeCard({ month, loading, hidden }: { month?: Analyze; loading: boolean; hidden: boolean }) {
|
||||||
|
const salary = dec(month?.salaryIncome);
|
||||||
|
const support = dec(month?.otherIncome);
|
||||||
|
const income = dec(month?.income);
|
||||||
|
const expense = dec(month?.expense);
|
||||||
|
const saved = income - expense;
|
||||||
|
const rate = income > 0 ? Math.round((saved / income) * 100) : 0;
|
||||||
|
const spentFrac = income > 0 ? Math.min(1, expense / income) : 0;
|
||||||
|
const over = saved < 0;
|
||||||
|
const good = "var(--mercury-success, #1e9e6b)";
|
||||||
|
const bad = "var(--mercury-critical, #e5484d)";
|
||||||
|
return (
|
||||||
|
<Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}>
|
||||||
|
<SectionHeader title={s.incomeTitle} />
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
|
||||||
|
<IncomeRow label={s.incomeSalary} value={salary} dotColor={good} loading={loading} hidden={hidden} />
|
||||||
|
<IncomeRow label={s.incomeSupport} value={support} dotColor="var(--mercury-brand-yellow, #f2b021)" loading={loading} hidden={hidden} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style={{ height: 8, borderRadius: 999, background: "var(--seed-color-bg-neutral-subtle, #eef0f2)", overflow: "hidden" }}>
|
||||||
|
<div style={{ height: "100%", width: `${Math.max(2, spentFrac * 100)}%`, borderRadius: 999, background: over ? bad : good }} />
|
||||||
|
</div>
|
||||||
|
<div style={{ marginTop: 10, display: "flex", justifyContent: "space-between", alignItems: "baseline", gap: 8 }}>
|
||||||
|
<span style={{ fontSize: 13, fontWeight: 700, color: over ? bad : good }}>
|
||||||
|
{over ? s.incomeOverspent : s.incomeSaveRate(rate)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 14, fontWeight: 700, color: over ? bad : good, flex: "none" }}>
|
||||||
|
{over ? "−" : "+"}
|
||||||
|
<Amount value={Math.abs(saved)} loading={loading} hidden={hidden} />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** The Нүүр (home) "money clarity" dashboard: a slim categorize-nudge banner,
|
/** The Нүүр (home) "money clarity" dashboard: a slim categorize-nudge banner,
|
||||||
* the safe-to-spend + cash-flow hero, where-your-money-went, recurring &
|
* the safe-to-spend + cash-flow hero, where-your-money-went, recurring &
|
||||||
* subscriptions, and net worth + composition. Rebuilt per the mercury-rethink
|
* subscriptions, and net worth + composition. Rebuilt per the mercury-rethink
|
||||||
|
|
@ -404,7 +457,10 @@ export function DashboardView() {
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 2. Хаана зарцуулсан бэ? — top categories + top merchants. */}
|
{/* 2. Income — salary vs support, and what was kept this month. */}
|
||||||
|
<IncomeCard month={monthQ.data} loading={heroLoading} hidden={hiddenAmounts} />
|
||||||
|
|
||||||
|
{/* 3. Хаана зарцуулсан бэ? — top categories + top merchants. */}
|
||||||
<Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}>
|
<Card style={{ display: "flex", flexDirection: "column", gap: 16, padding: 20 }}>
|
||||||
<SectionHeader title={s.whereWentTitle} />
|
<SectionHeader title={s.whereWentTitle} />
|
||||||
{whereItWentLoading ? (
|
{whereItWentLoading ? (
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,15 @@ export const homeStrings = {
|
||||||
// Shared "see the full list" link, capped sections on the home cards.
|
// Shared "see the full list" link, capped sections on the home cards.
|
||||||
viewAll: "Бүгдийг харах",
|
viewAll: "Бүгдийг харах",
|
||||||
|
|
||||||
|
// Income card — earned salary vs support, and what's kept.
|
||||||
|
incomeTitle: "Энэ сарын орлого",
|
||||||
|
incomeSalary: "Цалин",
|
||||||
|
incomeSupport: "Дэмжлэг ба бусад",
|
||||||
|
incomeSaved: "Хэмнэсэн",
|
||||||
|
incomeSpentOf: "зарцуулсан",
|
||||||
|
incomeSaveRate: (pct: number) => `Орлогынхоо ${pct}%-г үлдээсэн`,
|
||||||
|
incomeOverspent: "Энэ сар орлогоосоо илүү зарцуулсан",
|
||||||
|
|
||||||
// 2. Хаана зарцуулсан бэ?
|
// 2. Хаана зарцуулсан бэ?
|
||||||
whereWentTitle: "Хаана зарцуулсан бэ?",
|
whereWentTitle: "Хаана зарцуулсан бэ?",
|
||||||
categoriesLabel: "Ангилал",
|
categoriesLabel: "Ангилал",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue