mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:24:02 +00:00
feat(miniapp): add purchase-only and utilities balance sections to balances view
This commit is contained in:
@@ -102,6 +102,8 @@ type DashboardContextValue = {
|
||||
purchaseTotalMajor: () => string
|
||||
memberBalanceVisuals: () => ReturnType<typeof computeMemberBalanceVisuals>
|
||||
purchaseInvestmentChart: () => ReturnType<typeof computePurchaseInvestmentChart>
|
||||
memberPurchaseBalanceVisuals: () => MemberBalanceItem[]
|
||||
memberUtilityBalanceVisuals: () => MemberBalanceItem[]
|
||||
testingRolePreview: () => TestingRolePreview | null
|
||||
setTestingRolePreview: (value: TestingRolePreview | null) => void
|
||||
testingPeriodOverride: () => string | null
|
||||
@@ -240,6 +242,49 @@ function computePurchaseInvestmentChart(
|
||||
}
|
||||
}
|
||||
|
||||
interface MemberBalanceItem {
|
||||
member: MiniAppDashboard['members'][number]
|
||||
amountMajor: string
|
||||
amountMinor: bigint
|
||||
isCredit: boolean
|
||||
}
|
||||
|
||||
function computeMemberPurchaseBalanceVisuals(data: MiniAppDashboard | null): MemberBalanceItem[] {
|
||||
if (!data) return []
|
||||
|
||||
return data.members
|
||||
.map((member) => ({
|
||||
member,
|
||||
amountMajor: member.purchaseOffsetMajor,
|
||||
amountMinor: absoluteMinor(majorStringToMinor(member.purchaseOffsetMajor)),
|
||||
isCredit: majorStringToMinor(member.purchaseOffsetMajor) < 0n
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.amountMinor === left.amountMinor) {
|
||||
return left.member.displayName.localeCompare(right.member.displayName)
|
||||
}
|
||||
return right.amountMinor > left.amountMinor ? 1 : -1
|
||||
})
|
||||
}
|
||||
|
||||
function computeMemberUtilityBalanceVisuals(data: MiniAppDashboard | null): MemberBalanceItem[] {
|
||||
if (!data) return []
|
||||
|
||||
return data.members
|
||||
.map((member) => ({
|
||||
member,
|
||||
amountMajor: member.utilityShareMajor,
|
||||
amountMinor: absoluteMinor(majorStringToMinor(member.utilityShareMajor)),
|
||||
isCredit: false
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (right.amountMinor === left.amountMinor) {
|
||||
return left.member.displayName.localeCompare(right.member.displayName)
|
||||
}
|
||||
return right.amountMinor > left.amountMinor ? 1 : -1
|
||||
})
|
||||
}
|
||||
|
||||
/* ── Provider ───────────────────────────────────────── */
|
||||
|
||||
export function DashboardProvider(props: ParentProps) {
|
||||
@@ -297,6 +342,14 @@ export function DashboardProvider(props: ParentProps) {
|
||||
computePurchaseInvestmentChart(dashboard(), purchaseLedger(), copy().ledgerActorFallback)
|
||||
)
|
||||
|
||||
const memberPurchaseBalanceVisuals = createMemo(() =>
|
||||
computeMemberPurchaseBalanceVisuals(dashboard())
|
||||
)
|
||||
|
||||
const memberUtilityBalanceVisuals = createMemo(() =>
|
||||
computeMemberUtilityBalanceVisuals(dashboard())
|
||||
)
|
||||
|
||||
const unregisterDashboardRefreshListener = registerRefreshListener(loadDashboardData)
|
||||
onCleanup(unregisterDashboardRefreshListener)
|
||||
|
||||
@@ -367,6 +420,8 @@ export function DashboardProvider(props: ParentProps) {
|
||||
purchaseTotalMajor,
|
||||
memberBalanceVisuals,
|
||||
purchaseInvestmentChart,
|
||||
memberPurchaseBalanceVisuals,
|
||||
memberUtilityBalanceVisuals,
|
||||
testingRolePreview,
|
||||
setTestingRolePreview,
|
||||
testingPeriodOverride,
|
||||
|
||||
@@ -106,7 +106,13 @@ export const dictionary = {
|
||||
balanceScreenScopeBody:
|
||||
'This screen only explains your current cycle balance. Older activity stays in the ledger.',
|
||||
householdBalancesTitle: 'Household balances',
|
||||
householdBalancesBody: 'Everyone’s current split for this cycle.',
|
||||
householdBalancesBody: "Everyone's current split for this cycle.",
|
||||
balancesTitle: 'Balance',
|
||||
balancesSubtitle: 'Total due',
|
||||
purchasesBalanceTitle: 'Purchases balance',
|
||||
purchasesBalanceBody: 'Net balance from shared purchases only.',
|
||||
utilitiesBalanceTitle: 'Utilities balance',
|
||||
utilitiesBalanceBody: 'Utility bills split.',
|
||||
inspectMemberTitle: 'Inspect member',
|
||||
inspectMemberBody: 'Check another member balance without opening a long list.',
|
||||
inspectMemberLabel: 'Member',
|
||||
@@ -453,6 +459,12 @@ export const dictionary = {
|
||||
'На этом экране только разбор твоего текущего баланса. Более старые записи остаются в леджере.',
|
||||
householdBalancesTitle: 'Баланс дома',
|
||||
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
|
||||
balancesTitle: 'Баланс',
|
||||
balancesSubtitle: 'Всего к оплате',
|
||||
purchasesBalanceTitle: 'Баланс покупок',
|
||||
purchasesBalanceBody: 'Чистый баланс только от общих покупок.',
|
||||
utilitiesBalanceTitle: 'Баланс коммуналки',
|
||||
utilitiesBalanceBody: 'Разбивка счетов за коммуналку.',
|
||||
inspectMemberTitle: 'Посмотреть участника',
|
||||
inspectMemberBody: 'Можно быстро проверить чужой баланс без длинного списка карточек.',
|
||||
inspectMemberLabel: 'Участник',
|
||||
|
||||
@@ -1289,6 +1289,50 @@ a {
|
||||
color: var(--status-due);
|
||||
}
|
||||
|
||||
.text-credit {
|
||||
color: var(--status-credit);
|
||||
}
|
||||
|
||||
.text-debit {
|
||||
color: var(--status-due);
|
||||
}
|
||||
|
||||
/* ── Balance Summary ─────────────────────────────────────── */
|
||||
|
||||
.balance-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.balance-summary__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: var(--spacing-sm);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.balance-summary__label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.balance-summary__value {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.balance-summary__sub {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Balance Visuals ──────────────────────────────────── */
|
||||
|
||||
.balance-visuals {
|
||||
|
||||
@@ -72,6 +72,20 @@ export function memberRemainingClass(member: MiniAppDashboard['members'][number]
|
||||
return 'is-due'
|
||||
}
|
||||
|
||||
export function memberCreditClass(member: MiniAppDashboard['members'][number]): string {
|
||||
const purchaseOffsetMinor = majorStringToMinor(member.purchaseOffsetMajor)
|
||||
|
||||
if (purchaseOffsetMinor < 0n) {
|
||||
return 'is-credit'
|
||||
}
|
||||
|
||||
if (purchaseOffsetMinor === 0n) {
|
||||
return 'is-settled'
|
||||
}
|
||||
|
||||
return 'is-due'
|
||||
}
|
||||
|
||||
export function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
|
||||
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
|
||||
}
|
||||
|
||||
@@ -5,11 +5,18 @@ import { useI18n } from '../contexts/i18n-context'
|
||||
import { useDashboard } from '../contexts/dashboard-context'
|
||||
import { Card } from '../components/ui/card'
|
||||
import { Skeleton } from '../components/ui/skeleton'
|
||||
import { memberRemainingClass } from '../lib/ledger-helpers'
|
||||
import { memberRemainingClass, memberCreditClass } from '../lib/ledger-helpers'
|
||||
|
||||
export default function BalancesRoute() {
|
||||
const { copy } = useI18n()
|
||||
const { dashboard, loading, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard()
|
||||
const {
|
||||
dashboard,
|
||||
loading,
|
||||
memberBalanceVisuals,
|
||||
purchaseInvestmentChart,
|
||||
memberPurchaseBalanceVisuals,
|
||||
memberUtilityBalanceVisuals
|
||||
} = useDashboard()
|
||||
|
||||
return (
|
||||
<div class="route route--balances">
|
||||
@@ -39,7 +46,52 @@ export default function BalancesRoute() {
|
||||
<Match when={dashboard()}>
|
||||
{(data) => (
|
||||
<>
|
||||
{/* ── Household balances ─────────────────── */}
|
||||
{/* ── Balance summary ─────────────────────────── */}
|
||||
<Card>
|
||||
<div class="balance-summary">
|
||||
<div class="balance-summary__col">
|
||||
<span class="balance-summary__label">{copy().balancesTitle}</span>
|
||||
<span class="balance-summary__value">
|
||||
{data()
|
||||
.members.reduce(
|
||||
(sum, m) => sum + Number(m.netDueMajor.replace(/[^0-9.-]/g, '')),
|
||||
0
|
||||
)
|
||||
.toFixed(2)}{' '}
|
||||
{data().currency}
|
||||
</span>
|
||||
<span class="balance-summary__sub">{copy().balancesSubtitle}</span>
|
||||
</div>
|
||||
<div class="balance-summary__col">
|
||||
<span class="balance-summary__label">{copy().purchasesBalanceTitle}</span>
|
||||
<span class="balance-summary__value">
|
||||
{data()
|
||||
.members.reduce(
|
||||
(sum, m) => sum + Number(m.purchaseOffsetMajor.replace(/[^0-9.-]/g, '')),
|
||||
0
|
||||
)
|
||||
.toFixed(2)}{' '}
|
||||
{data().currency}
|
||||
</span>
|
||||
<span class="balance-summary__sub">{copy().purchasesBalanceBody}</span>
|
||||
</div>
|
||||
<div class="balance-summary__col">
|
||||
<span class="balance-summary__label">{copy().utilitiesBalanceTitle}</span>
|
||||
<span class="balance-summary__value">
|
||||
{data()
|
||||
.members.reduce(
|
||||
(sum, m) => sum + Number(m.utilityShareMajor.replace(/[^0-9.-]/g, '')),
|
||||
0
|
||||
)
|
||||
.toFixed(2)}{' '}
|
||||
{data().currency}
|
||||
</span>
|
||||
<span class="balance-summary__sub">{copy().utilitiesBalanceBody}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Household balances ──────────────────────── */}
|
||||
<Card>
|
||||
<div class="section-header">
|
||||
<strong>{copy().householdBalancesTitle}</strong>
|
||||
@@ -64,7 +116,53 @@ export default function BalancesRoute() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Balance breakdown bars ──────────────── */}
|
||||
{/* ── Purchases balance ────────────────────────── */}
|
||||
<Card>
|
||||
<div class="section-header">
|
||||
<strong>{copy().purchasesBalanceTitle}</strong>
|
||||
<p>{copy().purchasesBalanceBody}</p>
|
||||
</div>
|
||||
<div class="member-balance-list">
|
||||
<For each={memberPurchaseBalanceVisuals()}>
|
||||
{(item) => (
|
||||
<div class={`member-balance-row ${memberCreditClass(item.member)}`}>
|
||||
<span class="member-balance-row__name">{item.member.displayName}</span>
|
||||
<div class="member-balance-row__amounts">
|
||||
<span
|
||||
class={`member-balance-row__due ${item.isCredit ? 'text-credit' : 'text-debit'}`}
|
||||
>
|
||||
{item.amountMajor} {data().currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Utilities balance ────────────────────────── */}
|
||||
<Card>
|
||||
<div class="section-header">
|
||||
<strong>{copy().utilitiesBalanceTitle}</strong>
|
||||
<p>{copy().utilitiesBalanceBody}</p>
|
||||
</div>
|
||||
<div class="member-balance-list">
|
||||
<For each={memberUtilityBalanceVisuals()}>
|
||||
{(item) => (
|
||||
<div class="member-balance-row">
|
||||
<span class="member-balance-row__name">{item.member.displayName}</span>
|
||||
<div class="member-balance-row__amounts">
|
||||
<span class="member-balance-row__due">
|
||||
{item.amountMajor} {data().currency}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Balance breakdown bars ───────────────────── */}
|
||||
<Card>
|
||||
<div class="section-header">
|
||||
<BarChart3 size={16} />
|
||||
@@ -110,7 +208,7 @@ export default function BalancesRoute() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* ── Purchase investment donut ───────────── */}
|
||||
{/* ── Purchase investment donut ────────────────── */}
|
||||
<Card>
|
||||
<div class="section-header">
|
||||
<strong>{copy().purchaseInvestmentsTitle}</strong>
|
||||
|
||||
Reference in New Issue
Block a user