mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04: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
|
purchaseTotalMajor: () => string
|
||||||
memberBalanceVisuals: () => ReturnType<typeof computeMemberBalanceVisuals>
|
memberBalanceVisuals: () => ReturnType<typeof computeMemberBalanceVisuals>
|
||||||
purchaseInvestmentChart: () => ReturnType<typeof computePurchaseInvestmentChart>
|
purchaseInvestmentChart: () => ReturnType<typeof computePurchaseInvestmentChart>
|
||||||
|
memberPurchaseBalanceVisuals: () => MemberBalanceItem[]
|
||||||
|
memberUtilityBalanceVisuals: () => MemberBalanceItem[]
|
||||||
testingRolePreview: () => TestingRolePreview | null
|
testingRolePreview: () => TestingRolePreview | null
|
||||||
setTestingRolePreview: (value: TestingRolePreview | null) => void
|
setTestingRolePreview: (value: TestingRolePreview | null) => void
|
||||||
testingPeriodOverride: () => string | null
|
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 ───────────────────────────────────────── */
|
/* ── Provider ───────────────────────────────────────── */
|
||||||
|
|
||||||
export function DashboardProvider(props: ParentProps) {
|
export function DashboardProvider(props: ParentProps) {
|
||||||
@@ -297,6 +342,14 @@ export function DashboardProvider(props: ParentProps) {
|
|||||||
computePurchaseInvestmentChart(dashboard(), purchaseLedger(), copy().ledgerActorFallback)
|
computePurchaseInvestmentChart(dashboard(), purchaseLedger(), copy().ledgerActorFallback)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const memberPurchaseBalanceVisuals = createMemo(() =>
|
||||||
|
computeMemberPurchaseBalanceVisuals(dashboard())
|
||||||
|
)
|
||||||
|
|
||||||
|
const memberUtilityBalanceVisuals = createMemo(() =>
|
||||||
|
computeMemberUtilityBalanceVisuals(dashboard())
|
||||||
|
)
|
||||||
|
|
||||||
const unregisterDashboardRefreshListener = registerRefreshListener(loadDashboardData)
|
const unregisterDashboardRefreshListener = registerRefreshListener(loadDashboardData)
|
||||||
onCleanup(unregisterDashboardRefreshListener)
|
onCleanup(unregisterDashboardRefreshListener)
|
||||||
|
|
||||||
@@ -367,6 +420,8 @@ export function DashboardProvider(props: ParentProps) {
|
|||||||
purchaseTotalMajor,
|
purchaseTotalMajor,
|
||||||
memberBalanceVisuals,
|
memberBalanceVisuals,
|
||||||
purchaseInvestmentChart,
|
purchaseInvestmentChart,
|
||||||
|
memberPurchaseBalanceVisuals,
|
||||||
|
memberUtilityBalanceVisuals,
|
||||||
testingRolePreview,
|
testingRolePreview,
|
||||||
setTestingRolePreview,
|
setTestingRolePreview,
|
||||||
testingPeriodOverride,
|
testingPeriodOverride,
|
||||||
|
|||||||
@@ -106,7 +106,13 @@ export const dictionary = {
|
|||||||
balanceScreenScopeBody:
|
balanceScreenScopeBody:
|
||||||
'This screen only explains your current cycle balance. Older activity stays in the ledger.',
|
'This screen only explains your current cycle balance. Older activity stays in the ledger.',
|
||||||
householdBalancesTitle: 'Household balances',
|
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',
|
inspectMemberTitle: 'Inspect member',
|
||||||
inspectMemberBody: 'Check another member balance without opening a long list.',
|
inspectMemberBody: 'Check another member balance without opening a long list.',
|
||||||
inspectMemberLabel: 'Member',
|
inspectMemberLabel: 'Member',
|
||||||
@@ -453,6 +459,12 @@ export const dictionary = {
|
|||||||
'На этом экране только разбор твоего текущего баланса. Более старые записи остаются в леджере.',
|
'На этом экране только разбор твоего текущего баланса. Более старые записи остаются в леджере.',
|
||||||
householdBalancesTitle: 'Баланс дома',
|
householdBalancesTitle: 'Баланс дома',
|
||||||
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
|
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
|
||||||
|
balancesTitle: 'Баланс',
|
||||||
|
balancesSubtitle: 'Всего к оплате',
|
||||||
|
purchasesBalanceTitle: 'Баланс покупок',
|
||||||
|
purchasesBalanceBody: 'Чистый баланс только от общих покупок.',
|
||||||
|
utilitiesBalanceTitle: 'Баланс коммуналки',
|
||||||
|
utilitiesBalanceBody: 'Разбивка счетов за коммуналку.',
|
||||||
inspectMemberTitle: 'Посмотреть участника',
|
inspectMemberTitle: 'Посмотреть участника',
|
||||||
inspectMemberBody: 'Можно быстро проверить чужой баланс без длинного списка карточек.',
|
inspectMemberBody: 'Можно быстро проверить чужой баланс без длинного списка карточек.',
|
||||||
inspectMemberLabel: 'Участник',
|
inspectMemberLabel: 'Участник',
|
||||||
|
|||||||
@@ -1289,6 +1289,50 @@ a {
|
|||||||
color: var(--status-due);
|
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 ──────────────────────────────────── */
|
||||||
|
|
||||||
.balance-visuals {
|
.balance-visuals {
|
||||||
|
|||||||
@@ -72,6 +72,20 @@ export function memberRemainingClass(member: MiniAppDashboard['members'][number]
|
|||||||
return 'is-due'
|
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 {
|
export function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
|
||||||
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
|
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,18 @@ import { useI18n } from '../contexts/i18n-context'
|
|||||||
import { useDashboard } from '../contexts/dashboard-context'
|
import { useDashboard } from '../contexts/dashboard-context'
|
||||||
import { Card } from '../components/ui/card'
|
import { Card } from '../components/ui/card'
|
||||||
import { Skeleton } from '../components/ui/skeleton'
|
import { Skeleton } from '../components/ui/skeleton'
|
||||||
import { memberRemainingClass } from '../lib/ledger-helpers'
|
import { memberRemainingClass, memberCreditClass } from '../lib/ledger-helpers'
|
||||||
|
|
||||||
export default function BalancesRoute() {
|
export default function BalancesRoute() {
|
||||||
const { copy } = useI18n()
|
const { copy } = useI18n()
|
||||||
const { dashboard, loading, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard()
|
const {
|
||||||
|
dashboard,
|
||||||
|
loading,
|
||||||
|
memberBalanceVisuals,
|
||||||
|
purchaseInvestmentChart,
|
||||||
|
memberPurchaseBalanceVisuals,
|
||||||
|
memberUtilityBalanceVisuals
|
||||||
|
} = useDashboard()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="route route--balances">
|
<div class="route route--balances">
|
||||||
@@ -39,7 +46,52 @@ export default function BalancesRoute() {
|
|||||||
<Match when={dashboard()}>
|
<Match when={dashboard()}>
|
||||||
{(data) => (
|
{(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>
|
<Card>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<strong>{copy().householdBalancesTitle}</strong>
|
<strong>{copy().householdBalancesTitle}</strong>
|
||||||
@@ -64,7 +116,53 @@ export default function BalancesRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<BarChart3 size={16} />
|
<BarChart3 size={16} />
|
||||||
@@ -110,7 +208,7 @@ export default function BalancesRoute() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── Purchase investment donut ───────────── */}
|
{/* ── Purchase investment donut ────────────────── */}
|
||||||
<Card>
|
<Card>
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<strong>{copy().purchaseInvestmentsTitle}</strong>
|
<strong>{copy().purchaseInvestmentsTitle}</strong>
|
||||||
|
|||||||
Reference in New Issue
Block a user