feat(miniapp): add purchase-only and utilities balance sections to balances view

This commit is contained in:
2026-03-17 00:28:35 +04:00
parent 02c79ae629
commit 748878e789
5 changed files with 229 additions and 6 deletions

View File

@@ -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,

View File

@@ -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: 'Everyones 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: 'Участник',

View File

@@ -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 {

View File

@@ -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}`
}

View File

@@ -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>