diff --git a/apps/miniapp/src/contexts/dashboard-context.tsx b/apps/miniapp/src/contexts/dashboard-context.tsx index dac8a81..c7eb07d 100644 --- a/apps/miniapp/src/contexts/dashboard-context.tsx +++ b/apps/miniapp/src/contexts/dashboard-context.tsx @@ -102,6 +102,8 @@ type DashboardContextValue = { purchaseTotalMajor: () => string memberBalanceVisuals: () => ReturnType purchaseInvestmentChart: () => ReturnType + 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, diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 0a5986c..821a23c 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -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: 'Участник', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index a3c9f93..493a91f 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -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 { diff --git a/apps/miniapp/src/lib/ledger-helpers.ts b/apps/miniapp/src/lib/ledger-helpers.ts index e13c5a3..edefa0b 100644 --- a/apps/miniapp/src/lib/ledger-helpers.ts +++ b/apps/miniapp/src/lib/ledger-helpers.ts @@ -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}` } diff --git a/apps/miniapp/src/routes/balances.tsx b/apps/miniapp/src/routes/balances.tsx index 34f9b92..135119d 100644 --- a/apps/miniapp/src/routes/balances.tsx +++ b/apps/miniapp/src/routes/balances.tsx @@ -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 (
@@ -39,7 +46,52 @@ export default function BalancesRoute() { {(data) => ( <> - {/* ── Household balances ─────────────────── */} + {/* ── Balance summary ─────────────────────────── */} + +
+
+ {copy().balancesTitle} + + {data() + .members.reduce( + (sum, m) => sum + Number(m.netDueMajor.replace(/[^0-9.-]/g, '')), + 0 + ) + .toFixed(2)}{' '} + {data().currency} + + {copy().balancesSubtitle} +
+
+ {copy().purchasesBalanceTitle} + + {data() + .members.reduce( + (sum, m) => sum + Number(m.purchaseOffsetMajor.replace(/[^0-9.-]/g, '')), + 0 + ) + .toFixed(2)}{' '} + {data().currency} + + {copy().purchasesBalanceBody} +
+
+ {copy().utilitiesBalanceTitle} + + {data() + .members.reduce( + (sum, m) => sum + Number(m.utilityShareMajor.replace(/[^0-9.-]/g, '')), + 0 + ) + .toFixed(2)}{' '} + {data().currency} + + {copy().utilitiesBalanceBody} +
+
+
+ + {/* ── Household balances ──────────────────────── */}
{copy().householdBalancesTitle} @@ -64,7 +116,53 @@ export default function BalancesRoute() {
- {/* ── Balance breakdown bars ──────────────── */} + {/* ── Purchases balance ────────────────────────── */} + +
+ {copy().purchasesBalanceTitle} +

{copy().purchasesBalanceBody}

+
+
+ + {(item) => ( +
+ {item.member.displayName} +
+ + {item.amountMajor} {data().currency} + +
+
+ )} +
+
+
+ + {/* ── Utilities balance ────────────────────────── */} + +
+ {copy().utilitiesBalanceTitle} +

{copy().utilitiesBalanceBody}

+
+
+ + {(item) => ( +
+ {item.member.displayName} +
+ + {item.amountMajor} {data().currency} + +
+
+ )} +
+
+
+ + {/* ── Balance breakdown bars ───────────────────── */}
@@ -110,7 +208,7 @@ export default function BalancesRoute() {
- {/* ── Purchase investment donut ───────────── */} + {/* ── Purchase investment donut ────────────────── */}
{copy().purchaseInvestmentsTitle}