feat(miniapp): redesign member balance surfaces

This commit is contained in:
2026-03-12 02:10:22 +04:00
parent 7467d3a4cf
commit 135a2301ca
16 changed files with 1389 additions and 1015 deletions

View File

@@ -283,6 +283,7 @@ function createFinanceService(): FinanceCommandService {
generateDashboard: async () => ({ generateDashboard: async () => ({
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities',
totalDue: Money.fromMajor('1000.00', 'GEL'), totalDue: Money.fromMajor('1000.00', 'GEL'),
totalPaid: Money.fromMajor('500.00', 'GEL'), totalPaid: Money.fromMajor('500.00', 'GEL'),
totalRemaining: Money.fromMajor('500.00', 'GEL'), totalRemaining: Money.fromMajor('500.00', 'GEL'),

View File

@@ -124,6 +124,7 @@ function createDashboard(): NonNullable<
return { return {
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities',
totalDue: Money.fromMajor('400', 'GEL'), totalDue: Money.fromMajor('400', 'GEL'),
totalPaid: Money.fromMajor('100', 'GEL'), totalPaid: Money.fromMajor('100', 'GEL'),
totalRemaining: Money.fromMajor('300', 'GEL'), totalRemaining: Money.fromMajor('300', 'GEL'),

View File

@@ -310,6 +310,7 @@ describe('createMiniAppDashboardHandler', () => {
dashboard: { dashboard: {
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities',
totalDueMajor: '2010.00', totalDueMajor: '2010.00',
totalPaidMajor: '500.00', totalPaidMajor: '500.00',
totalRemainingMajor: '1510.00', totalRemainingMajor: '1510.00',

View File

@@ -88,6 +88,7 @@ export function createMiniAppDashboardHandler(options: {
dashboard: { dashboard: {
period: dashboard.period, period: dashboard.period,
currency: dashboard.currency, currency: dashboard.currency,
paymentBalanceAdjustmentPolicy: dashboard.paymentBalanceAdjustmentPolicy,
totalDueMajor: dashboard.totalDue.toMajorString(), totalDueMajor: dashboard.totalDue.toMajorString(),
totalPaidMajor: dashboard.totalPaid.toMajorString(), totalPaidMajor: dashboard.totalPaid.toMajorString(),
totalRemainingMajor: dashboard.totalRemaining.toMajorString(), totalRemainingMajor: dashboard.totalRemaining.toMajorString(),

View File

@@ -168,6 +168,7 @@ function createFinanceService(): FinanceCommandService {
generateDashboard: async () => ({ generateDashboard: async () => ({
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities',
totalDue: Money.fromMajor('1000', 'GEL'), totalDue: Money.fromMajor('1000', 'GEL'),
totalPaid: Money.zero('GEL'), totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1000', 'GEL'), totalRemaining: Money.fromMajor('1000', 'GEL'),

View File

@@ -121,6 +121,8 @@ type PaymentDraft = {
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
} }
type TestingRolePreview = 'admin' | 'resident'
const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const
const demoSession: Extract<SessionState, { status: 'ready' }> = { const demoSession: Extract<SessionState, { status: 'ready' }> = {
@@ -380,6 +382,9 @@ function App() {
const [addingUtilityBillOpen, setAddingUtilityBillOpen] = createSignal(false) const [addingUtilityBillOpen, setAddingUtilityBillOpen] = createSignal(false)
const [addingPaymentOpen, setAddingPaymentOpen] = createSignal(false) const [addingPaymentOpen, setAddingPaymentOpen] = createSignal(false)
const [profileEditorOpen, setProfileEditorOpen] = createSignal(false) const [profileEditorOpen, setProfileEditorOpen] = createSignal(false)
const [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false)
const [roleChipTapHistory, setRoleChipTapHistory] = createSignal<number[]>([])
const [testingRolePreview, setTestingRolePreview] = createSignal<TestingRolePreview | null>(null)
const [addingPayment, setAddingPayment] = createSignal(false) const [addingPayment, setAddingPayment] = createSignal(false)
const [billingForm, setBillingForm] = createSignal({ const [billingForm, setBillingForm] = createSignal({
settlementCurrency: 'GEL' as 'USD' | 'GEL', settlementCurrency: 'GEL' as 'USD' | 'GEL',
@@ -421,6 +426,23 @@ function App() {
const current = session() const current = session()
return current.status === 'ready' ? current : null return current.status === 'ready' ? current : null
}) })
const effectiveIsAdmin = createMemo(() => {
const current = readySession()
if (!current) {
return false
}
if (!current.member.isAdmin) {
return false
}
const preview = testingRolePreview()
if (!preview) {
return true
}
return preview === 'admin'
})
const currentMemberLine = createMemo(() => { const currentMemberLine = createMemo(() => {
const current = readySession() const current = readySession()
const data = dashboard() const data = dashboard()
@@ -662,6 +684,24 @@ function App() {
} }
} }
function handleRoleChipTap() {
const currentReady = readySession()
if (!currentReady?.member.isAdmin) {
return
}
const now = Date.now()
const nextHistory = [...roleChipTapHistory().filter((timestamp) => now - timestamp < 1800), now]
if (nextHistory.length >= 5) {
setRoleChipTapHistory([])
setTestingSurfaceOpen(true)
return
}
setRoleChipTapHistory(nextHistory)
}
function defaultAbsencePolicyForStatus( function defaultAbsencePolicyForStatus(
status: 'active' | 'away' | 'left' status: 'active' | 'away' | 'left'
): MiniAppMemberAbsencePolicy { ): MiniAppMemberAbsencePolicy {
@@ -1948,7 +1988,7 @@ function App() {
<LedgerScreen <LedgerScreen
copy={copy()} copy={copy()}
dashboard={dashboard()} dashboard={dashboard()}
readyIsAdmin={readySession()?.member.isAdmin === true} readyIsAdmin={effectiveIsAdmin()}
adminMembers={adminSettings()?.members ?? []} adminMembers={adminSettings()?.members ?? []}
purchaseEntries={purchaseLedger()} purchaseEntries={purchaseLedger()}
utilityEntries={utilityLedger()} utilityEntries={utilityLedger()}
@@ -2075,7 +2115,7 @@ function App() {
return ( return (
<HouseScreen <HouseScreen
copy={copy()} copy={copy()}
readyIsAdmin={readySession()?.member.isAdmin === true} readyIsAdmin={effectiveIsAdmin()}
householdDefaultLocale={readySession()?.member.householdDefaultLocale ?? 'en'} householdDefaultLocale={readySession()?.member.householdDefaultLocale ?? 'en'}
dashboard={dashboard()} dashboard={dashboard()}
adminSettings={adminSettings()} adminSettings={adminSettings()}
@@ -2343,15 +2383,9 @@ function App() {
<HomeScreen <HomeScreen
copy={copy()} copy={copy()}
dashboard={dashboard()} dashboard={dashboard()}
readyIsAdmin={Boolean(readySession()?.member.isAdmin)}
pendingMembersCount={pendingMembers().length}
currentMemberLine={currentMemberLine()} currentMemberLine={currentMemberLine()}
utilityTotalMajor={utilityTotalMajor()} utilityTotalMajor={utilityTotalMajor()}
purchaseTotalMajor={purchaseTotalMajor()} purchaseTotalMajor={purchaseTotalMajor()}
memberBaseDueMajor={memberBaseDueMajor}
ledgerTitle={ledgerTitle}
ledgerPrimaryAmount={ledgerPrimaryAmount}
ledgerSecondaryAmount={ledgerSecondaryAmount}
/> />
) )
} }
@@ -2439,14 +2473,31 @@ function App() {
<MiniChip> <MiniChip>
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge} {readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
</MiniChip> </MiniChip>
<Show
when={readySession()?.member.isAdmin}
fallback={
<MiniChip muted> <MiniChip muted>
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} {effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
</MiniChip> </MiniChip>
}
>
<button
class="mini-chip mini-chip--muted mini-chip-button"
onClick={handleRoleChipTap}
>
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
</button>
</Show>
<MiniChip muted> <MiniChip muted>
{readySession()?.member.status {readySession()?.member.status
? memberStatusLabel(readySession()!.member.status) ? memberStatusLabel(readySession()!.member.status)
: copy().memberStatusActive} : copy().memberStatusActive}
</MiniChip> </MiniChip>
<Show when={testingRolePreview()}>
{(preview) => (
<MiniChip>{`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`}</MiniChip>
)}
</Show>
</div> </div>
<Show when={readySession()?.mode === 'live'}> <Show when={readySession()?.mode === 'live'}>
<Button <Button
@@ -2473,6 +2524,50 @@ function App() {
/> />
<section class="content-stack">{panel()}</section> <section class="content-stack">{panel()}</section>
<Modal
open={testingSurfaceOpen()}
title={copy().testingSurfaceTitle ?? ''}
description={copy().testingSurfaceBody}
closeLabel={copy().closeEditorAction}
onClose={() => setTestingSurfaceOpen(false)}
footer={
<div class="modal-action-row">
<Button variant="ghost" onClick={() => setTestingSurfaceOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button variant="secondary" onClick={() => setTestingRolePreview(null)}>
{copy().testingUseRealRoleAction ?? ''}
</Button>
</div>
}
>
<div class="testing-card">
<article class="testing-card__section">
<span>{copy().testingCurrentRoleLabel ?? ''}</span>
<strong>
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
</strong>
</article>
<article class="testing-card__section">
<span>{copy().testingPreviewRoleLabel ?? ''}</span>
<strong>
{testingRolePreview()
? testingRolePreview() === 'admin'
? copy().adminTag
: copy().residentTag
: copy().testingUseRealRoleAction}
</strong>
</article>
<div class="testing-card__actions">
<Button variant="secondary" onClick={() => setTestingRolePreview('admin')}>
{copy().testingPreviewAdminAction ?? ''}
</Button>
<Button variant="secondary" onClick={() => setTestingRolePreview('resident')}>
{copy().testingPreviewResidentAction ?? ''}
</Button>
</div>
</div>
</Modal>
<Modal <Modal
open={profileEditorOpen()} open={profileEditorOpen()}
title={copy().displayNameLabel} title={copy().displayNameLabel}

View File

@@ -0,0 +1,182 @@
import { For, Show } from 'solid-js'
import { cn } from '../../lib/cn'
import type { MiniAppDashboard } from '../../miniapp-api'
import { MiniChip, StatCard } from '../ui'
type Props = {
copy: Record<string, string | undefined>
dashboard: MiniAppDashboard
member: MiniAppDashboard['members'][number]
detail?: boolean
}
function majorStringToMinor(value: string): bigint {
const trimmed = value.trim()
const negative = trimmed.startsWith('-')
const normalized = negative ? trimmed.slice(1) : trimmed
const [whole = '0', fraction = ''] = normalized.split('.')
const major = BigInt(whole || '0')
const cents = BigInt((fraction.padEnd(2, '0').slice(0, 2) || '00').replace(/\D/g, '') || '0')
const minor = major * 100n + cents
return negative ? -minor : minor
}
function minorToMajorString(value: bigint): string {
const negative = value < 0n
const absolute = negative ? -value : value
const whole = absolute / 100n
const fraction = String(absolute % 100n).padStart(2, '0')
return `${negative ? '-' : ''}${whole.toString()}.${fraction}`
}
function sumMajorStrings(left: string, right: string): string {
return minorToMajorString(majorStringToMinor(left) + majorStringToMinor(right))
}
export function MemberBalanceCard(props: Props) {
const utilitiesAdjustedMajor = () =>
sumMajorStrings(props.member.utilityShareMajor, props.member.purchaseOffsetMajor)
const adjustmentClass = () => {
const value = majorStringToMinor(props.member.purchaseOffsetMajor)
if (value < 0n) {
return 'is-credit'
}
if (value > 0n) {
return 'is-due'
}
return 'is-settled'
}
return (
<article
class={cn(
'balance-item',
'balance-item--accent',
'balance-spotlight',
props.detail && 'balance-spotlight--detail'
)}
>
<header class="balance-spotlight__header">
<div class="balance-spotlight__copy">
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
<p>{props.copy.yourBalanceBody ?? ''}</p>
</div>
<div class="balance-spotlight__hero">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{props.member.remainingMajor} {props.dashboard.currency}
</strong>
<small>
{props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency}
</small>
</div>
</header>
<div class="balance-spotlight__stats">
<StatCard class="balance-spotlight__stat">
<span>{props.copy.totalDue ?? ''}</span>
<strong>
{props.member.netDueMajor} {props.dashboard.currency}
</strong>
</StatCard>
<StatCard class="balance-spotlight__stat">
<span>{props.copy.paidLabel ?? ''}</span>
<strong>
{props.member.paidMajor} {props.dashboard.currency}
</strong>
</StatCard>
<StatCard class="balance-spotlight__stat">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{props.member.remainingMajor} {props.dashboard.currency}
</strong>
</StatCard>
</div>
<div class="balance-spotlight__rows">
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.shareRent ?? ''}</span>
<strong>
{props.member.rentShareMajor} {props.dashboard.currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities ?? ''}</span>
<strong>
{props.member.utilityShareMajor} {props.dashboard.currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset ?? ''}</span>
<strong class={`balance-status ${adjustmentClass()}`}>
{props.member.purchaseOffsetMajor} {props.dashboard.currency}
</strong>
</div>
</article>
<Show when={props.dashboard.paymentBalanceAdjustmentPolicy === 'utilities'}>
<article class="balance-detail-row balance-detail-row--accent">
<div class="balance-detail-row__main">
<span>{props.copy.utilitiesAdjustedTotalLabel ?? ''}</span>
<strong>
{utilitiesAdjustedMajor()} {props.dashboard.currency}
</strong>
</div>
</article>
</Show>
</div>
<Show when={props.dashboard.rentSourceCurrency !== props.dashboard.currency}>
<section class="fx-panel">
<header class="fx-panel__header">
<strong>{props.copy.rentFxTitle ?? ''}</strong>
<Show when={props.dashboard.rentFxEffectiveDate}>
{(date) => (
<MiniChip muted>
{props.copy.fxEffectiveDateLabel ?? ''}: {date()}
</MiniChip>
)}
</Show>
</header>
<div class="fx-panel__grid">
<article class="fx-panel__cell">
<span>{props.copy.sourceAmountLabel ?? ''}</span>
<strong>
{props.dashboard.rentSourceAmountMajor} {props.dashboard.rentSourceCurrency}
</strong>
</article>
<article class="fx-panel__cell">
<span>{props.copy.settlementAmountLabel ?? ''}</span>
<strong>
{props.dashboard.rentDisplayAmountMajor} {props.dashboard.currency}
</strong>
</article>
</div>
</section>
</Show>
<Show when={props.detail && props.member.explanations.length > 0}>
<div class="balance-spotlight__meta">
<For each={props.member.explanations}>
{(explanation) => <MiniChip muted>{explanation}</MiniChip>}
</For>
</div>
</Show>
</article>
)
}

View File

@@ -25,6 +25,7 @@ export const demoTelegramUser: NonNullable<MiniAppSession['telegramUser']> = {
export const demoDashboard: MiniAppDashboard = { export const demoDashboard: MiniAppDashboard = {
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities',
totalDueMajor: '2410.00', totalDueMajor: '2410.00',
totalPaidMajor: '650.00', totalPaidMajor: '650.00',
totalRemainingMajor: '1760.00', totalRemainingMajor: '1760.00',

View File

@@ -52,9 +52,16 @@ export const dictionary = {
ledgerEntries: 'Ledger entries', ledgerEntries: 'Ledger entries',
pendingRequests: 'Pending requests', pendingRequests: 'Pending requests',
yourBalanceTitle: 'Your balance', yourBalanceTitle: 'Your balance',
yourBalanceBody: 'See your current cycle balance before and after shared household purchases.', yourBalanceBody:
'See rent, pure utilities, purchase balance adjustment, and what is still left to pay.',
cycleBillLabel: 'Cycle bill',
balanceAdjustmentLabel: 'Balance adjustment',
pureUtilitiesLabel: 'Pure utilities',
utilitiesAdjustedTotalLabel: 'Utilities after adjustment',
baseDue: 'Base due', baseDue: 'Base due',
finalDue: 'Final due', finalDue: 'Final due',
houseSnapshotTitle: 'House snapshot',
houseSnapshotBody: 'House totals stay secondary here so your own bill reads first.',
householdBalancesTitle: 'Household balances', householdBalancesTitle: 'Household balances',
householdBalancesBody: 'Everyones current split for this cycle.', householdBalancesBody: 'Everyones current split for this cycle.',
financeVisualsTitle: 'Visual balance split', financeVisualsTitle: 'Visual balance split',
@@ -80,10 +87,23 @@ export const dictionary = {
shareRent: 'Rent', shareRent: 'Rent',
shareUtilities: 'Utilities', shareUtilities: 'Utilities',
shareOffset: 'Shared buys', shareOffset: 'Shared buys',
rentFxTitle: 'House rent FX',
sourceAmountLabel: 'Source',
settlementAmountLabel: 'Settlement',
fxEffectiveDateLabel: 'Locked',
ledgerTitle: 'Included ledger', ledgerTitle: 'Included ledger',
emptyDashboard: 'No billing cycle is ready yet.', emptyDashboard: 'No billing cycle is ready yet.',
latestActivityTitle: 'Latest activity', latestActivityTitle: 'Latest activity',
latestActivityEmpty: 'Recent utility and purchase entries will appear here.', latestActivityEmpty: 'Recent utility and purchase entries will appear here.',
testingViewBadge: 'Testing view',
testingSurfaceTitle: 'Hidden QA view',
testingSurfaceBody:
'Preview admin or resident presentation without changing real permissions or saved roles.',
testingUseRealRoleAction: 'Use real role',
testingPreviewAdminAction: 'Preview admin',
testingPreviewResidentAction: 'Preview resident',
testingCurrentRoleLabel: 'Real access',
testingPreviewRoleLabel: 'Previewing',
purchaseReviewTitle: 'Purchases', purchaseReviewTitle: 'Purchases',
purchaseReviewBody: 'Edit or remove purchases if the bot recorded the wrong item.', purchaseReviewBody: 'Edit or remove purchases if the bot recorded the wrong item.',
purchaseSplitTitle: 'Split', purchaseSplitTitle: 'Split',
@@ -273,9 +293,16 @@ export const dictionary = {
ledgerEntries: 'Записи леджера', ledgerEntries: 'Записи леджера',
pendingRequests: 'Ожидают подтверждения', pendingRequests: 'Ожидают подтверждения',
yourBalanceTitle: 'Твой баланс', yourBalanceTitle: 'Твой баланс',
yourBalanceBody: 'Посмотри свой баланс за текущий цикл до и после поправки на общие покупки.', yourBalanceBody:
'Здесь отдельно видно аренду, чистую коммуналку, поправку по покупкам и то, что осталось оплатить.',
cycleBillLabel: 'Счёт за цикл',
balanceAdjustmentLabel: 'Поправка по балансу',
pureUtilitiesLabel: 'Чистая коммуналка',
utilitiesAdjustedTotalLabel: 'Коммуналка после зачёта',
baseDue: 'База к оплате', baseDue: 'База к оплате',
finalDue: 'Итог к оплате', finalDue: 'Итог к оплате',
houseSnapshotTitle: 'Сводка по дому',
houseSnapshotBody: 'Общая сводка дома остаётся второстепенной, чтобы твой счёт читался сразу.',
householdBalancesTitle: 'Баланс household', householdBalancesTitle: 'Баланс household',
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.', householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
financeVisualsTitle: 'Визуальный разбор баланса', financeVisualsTitle: 'Визуальный разбор баланса',
@@ -301,10 +328,23 @@ export const dictionary = {
shareRent: 'Аренда', shareRent: 'Аренда',
shareUtilities: 'Коммуналка', shareUtilities: 'Коммуналка',
shareOffset: 'Общие покупки', shareOffset: 'Общие покупки',
rentFxTitle: 'FX по аренде дома',
sourceAmountLabel: 'Исходник',
settlementAmountLabel: 'Расчёт',
fxEffectiveDateLabel: 'Зафиксировано',
ledgerTitle: 'Вошедшие операции', ledgerTitle: 'Вошедшие операции',
emptyDashboard: 'Пока нет готового billing cycle.', emptyDashboard: 'Пока нет готового billing cycle.',
latestActivityTitle: 'Последняя активность', latestActivityTitle: 'Последняя активность',
latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.', latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.',
testingViewBadge: 'Тестовый вид',
testingSurfaceTitle: 'Скрытый QA режим',
testingSurfaceBody:
'Позволяет посмотреть админский или обычный вид без изменения реальных прав и сохранённых ролей.',
testingUseRealRoleAction: 'Настоящая роль',
testingPreviewAdminAction: 'Вид админа',
testingPreviewResidentAction: 'Вид жителя',
testingCurrentRoleLabel: 'Реальный доступ',
testingPreviewRoleLabel: 'Сейчас показан',
purchaseReviewTitle: 'Покупки', purchaseReviewTitle: 'Покупки',
purchaseReviewBody: purchaseReviewBody:
'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.', 'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.',

View File

@@ -382,6 +382,10 @@ button {
border-color: rgb(247 179 137 / 0.28); border-color: rgb(247 179 137 / 0.28);
} }
.balance-item--muted {
background: rgb(255 255 255 / 0.02);
}
.profile-card { .profile-card {
gap: 10px; gap: 10px;
} }
@@ -438,6 +442,114 @@ button {
gap: 10px; gap: 10px;
} }
.balance-spotlight {
gap: 14px;
}
.balance-spotlight__header {
display: grid;
gap: 14px;
}
.balance-spotlight__copy {
display: grid;
gap: 8px;
}
.balance-spotlight__hero {
display: grid;
gap: 6px;
padding: 14px;
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 18px;
background: rgb(10 18 28 / 0.38);
}
.balance-spotlight__hero span,
.balance-detail-row__main span,
.fx-panel__cell span,
.testing-card__section span {
color: #c6c2bb;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.balance-spotlight__hero strong,
.balance-detail-row__main strong,
.fx-panel__cell strong,
.testing-card__section strong {
font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
}
.balance-spotlight__hero strong {
font-size: clamp(1.6rem, 5vw, 2.3rem);
line-height: 1;
}
.balance-spotlight__hero small {
color: #d6d3cc;
}
.balance-spotlight__stats,
.balance-spotlight__rows,
.balance-spotlight__meta,
.testing-card,
.testing-card__actions {
display: grid;
gap: 10px;
}
.balance-spotlight__stat {
min-height: 100%;
}
.balance-detail-row,
.fx-panel,
.testing-card__section {
display: grid;
gap: 10px;
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 18px;
padding: 14px;
background: rgb(255 255 255 / 0.03);
}
.balance-detail-row--accent {
border-color: rgb(111 211 192 / 0.34);
background: rgb(111 211 192 / 0.08);
}
.balance-detail-row__main {
display: grid;
gap: 6px;
}
.fx-panel {
background: linear-gradient(180deg, rgb(148 168 255 / 0.08), rgb(255 255 255 / 0.02));
}
.fx-panel__header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.fx-panel__grid {
display: grid;
gap: 10px;
}
.fx-panel__cell {
display: grid;
gap: 6px;
padding: 12px;
border-radius: 16px;
background: rgb(12 20 31 / 0.45);
}
.member-visual-list { .member-visual-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -771,6 +883,18 @@ button {
color: #dad5ce; color: #dad5ce;
} }
.mini-chip-button {
cursor: pointer;
}
.testing-card {
gap: 12px;
}
.testing-card__actions {
grid-template-columns: minmax(0, 1fr);
}
.modal-backdrop { .modal-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -981,6 +1105,21 @@ button {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
.balance-spotlight__header,
.balance-spotlight__stats,
.fx-panel__grid,
.testing-card__actions {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.balance-spotlight__header {
align-items: start;
}
.balance-spotlight__stats {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.purchase-chart { .purchase-chart {
grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); grid-template-columns: minmax(220px, 260px) minmax(0, 1fr);
align-items: center; align-items: center;

View File

@@ -88,6 +88,7 @@ export interface MiniAppTopicBinding {
export interface MiniAppDashboard { export interface MiniAppDashboard {
period: string period: string
currency: 'USD' | 'GEL' currency: 'USD' | 'GEL'
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
totalDueMajor: string totalDueMajor: string
totalPaidMajor: string totalPaidMajor: string
totalRemainingMajor: string totalRemainingMajor: string

View File

@@ -2,6 +2,7 @@ import { For, Show } from 'solid-js'
import { FinanceSummaryCards } from '../components/finance/finance-summary-cards' import { FinanceSummaryCards } from '../components/finance/finance-summary-cards'
import { FinanceVisuals } from '../components/finance/finance-visuals' import { FinanceVisuals } from '../components/finance/finance-visuals'
import { MemberBalanceCard } from '../components/finance/member-balance-card'
import type { MiniAppDashboard } from '../miniapp-api' import type { MiniAppDashboard } from '../miniapp-api'
type Props = { type Props = {
@@ -52,50 +53,21 @@ export function BalancesScreen(props: Props) {
<div class="balance-list"> <div class="balance-list">
<Show when={props.currentMemberLine}> <Show when={props.currentMemberLine}>
{(member) => ( {(member) => (
<article class="balance-item balance-item--accent"> <MemberBalanceCard
<header> copy={props.copy}
<strong>{props.copy.yourBalanceTitle ?? ''}</strong> dashboard={dashboard()}
<span> member={member()}
{member().netDueMajor} {dashboard().currency} detail
</span> />
</header>
<p>{props.copy.yourBalanceBody ?? ''}</p>
<div class="balance-breakdown">
<article class="stat-card">
<span>{props.copy.baseDue ?? ''}</span>
<strong>
{props.memberBaseDueMajor(member())} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.shareOffset ?? ''}</span>
<strong>
{member().purchaseOffsetMajor} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.finalDue ?? ''}</span>
<strong>
{member().netDueMajor} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.paidLabel ?? ''}</span>
<strong>
{member().paidMajor} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{member().remainingMajor} {dashboard().currency}
</strong>
</article>
</div>
</article>
)} )}
</Show> </Show>
<div class="summary-card-grid"> <article class="balance-item balance-item--wide balance-item--muted">
<header>
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
<span>{dashboard().period}</span>
</header>
<p>{props.copy.houseSnapshotBody ?? ''}</p>
<div class="summary-card-grid summary-card-grid--secondary">
<FinanceSummaryCards <FinanceSummaryCards
dashboard={dashboard()} dashboard={dashboard()}
utilityTotalMajor={props.utilityTotalMajor} utilityTotalMajor={props.utilityTotalMajor}
@@ -108,6 +80,7 @@ export function BalancesScreen(props: Props) {
}} }}
/> />
</div> </div>
</article>
<FinanceVisuals <FinanceVisuals
dashboard={dashboard()} dashboard={dashboard()}
memberVisuals={props.memberBalanceVisuals} memberVisuals={props.memberBalanceVisuals}
@@ -124,9 +97,10 @@ export function BalancesScreen(props: Props) {
purchaseShareLabel: props.copy.purchaseShareLabel ?? '' purchaseShareLabel: props.copy.purchaseShareLabel ?? ''
}} }}
/> />
<article class="balance-item"> <article class="balance-item balance-item--wide">
<header> <header>
<strong>{props.copy.householdBalancesTitle ?? ''}</strong> <strong>{props.copy.householdBalancesTitle ?? ''}</strong>
<span>{String(dashboard().members.length)}</span>
</header> </header>
<p>{props.copy.householdBalancesBody ?? ''}</p> <p>{props.copy.householdBalancesBody ?? ''}</p>
</article> </article>

View File

@@ -1,20 +1,15 @@
import { For, Show } from 'solid-js' import { Show } from 'solid-js'
import { FinanceSummaryCards } from '../components/finance/finance-summary-cards' import { FinanceSummaryCards } from '../components/finance/finance-summary-cards'
import { MemberBalanceCard } from '../components/finance/member-balance-card'
import type { MiniAppDashboard } from '../miniapp-api' import type { MiniAppDashboard } from '../miniapp-api'
type Props = { type Props = {
copy: Record<string, string | undefined> copy: Record<string, string | undefined>
dashboard: MiniAppDashboard | null dashboard: MiniAppDashboard | null
readyIsAdmin: boolean
pendingMembersCount: number
currentMemberLine: MiniAppDashboard['members'][number] | null currentMemberLine: MiniAppDashboard['members'][number] | null
utilityTotalMajor: string utilityTotalMajor: string
purchaseTotalMajor: string purchaseTotalMajor: string
memberBaseDueMajor: (member: MiniAppDashboard['members'][number]) => string
ledgerTitle: (entry: MiniAppDashboard['ledger'][number]) => string
ledgerPrimaryAmount: (entry: MiniAppDashboard['ledger'][number]) => string
ledgerSecondaryAmount: (entry: MiniAppDashboard['ledger'][number]) => string | null
} }
export function HomeScreen(props: Props) { export function HomeScreen(props: Props) {
@@ -23,30 +18,36 @@ export function HomeScreen(props: Props) {
when={props.dashboard} when={props.dashboard}
fallback={ fallback={
<div class="home-grid"> <div class="home-grid">
<div class="summary-card-grid"> <article class="balance-item balance-item--accent balance-spotlight">
<article class="stat-card"> <header class="balance-spotlight__header">
<div class="balance-spotlight__copy">
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
<p>{props.copy.yourBalanceBody ?? ''}</p>
</div>
<div class="balance-spotlight__hero">
<span>{props.copy.remainingLabel ?? ''}</span> <span>{props.copy.remainingLabel ?? ''}</span>
<strong></strong> <strong></strong>
</article>
<article class="stat-card">
<span>{props.copy.shareRent ?? ''}</span>
<strong></strong>
</article>
<article class="stat-card">
<span>{props.copy.shareUtilities ?? ''}</span>
<strong></strong>
</article>
<article class="stat-card">
<span>{props.copy.purchasesTitle ?? ''}</span>
<strong></strong>
</article>
</div> </div>
</header>
</article>
</div> </div>
} }
> >
{(dashboard) => ( {(dashboard) => (
<div class="home-grid"> <div class="home-grid">
<div class="summary-card-grid"> <Show when={props.currentMemberLine}>
{(member) => (
<MemberBalanceCard copy={props.copy} dashboard={dashboard()} member={member()} />
)}
</Show>
<article class="balance-item balance-item--wide balance-item--muted">
<header>
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
<span>{dashboard().period}</span>
</header>
<p>{props.copy.houseSnapshotBody ?? ''}</p>
<div class="summary-card-grid summary-card-grid--secondary">
<FinanceSummaryCards <FinanceSummaryCards
dashboard={dashboard()} dashboard={dashboard()}
utilityTotalMajor={props.utilityTotalMajor} utilityTotalMajor={props.utilityTotalMajor}
@@ -58,90 +59,7 @@ export function HomeScreen(props: Props) {
purchases: props.copy.purchasesTitle ?? '' purchases: props.copy.purchasesTitle ?? ''
}} }}
/> />
<Show when={props.readyIsAdmin}>
<article class="stat-card">
<span>{props.copy.pendingRequests ?? ''}</span>
<strong>{String(props.pendingMembersCount)}</strong>
</article>
</Show>
</div> </div>
<Show when={props.currentMemberLine}>
{(member) => (
<article class="balance-item balance-item--accent">
<header>
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
<span>
{member().remainingMajor} {dashboard().currency}
</span>
</header>
<p>
{props.copy.shareRent ?? ''}: {dashboard().rentSourceAmountMajor}{' '}
{dashboard().rentSourceCurrency}
{dashboard().rentSourceCurrency !== dashboard().currency
? ` -> ${dashboard().rentDisplayAmountMajor} ${dashboard().currency}`
: ''}
</p>
<div class="balance-breakdown">
<article class="stat-card">
<span>{props.copy.baseDue ?? ''}</span>
<strong>
{props.memberBaseDueMajor(member())} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.shareOffset ?? ''}</span>
<strong>
{member().purchaseOffsetMajor} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.finalDue ?? ''}</span>
<strong>
{member().netDueMajor} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.paidLabel ?? ''}</span>
<strong>
{member().paidMajor} {dashboard().currency}
</strong>
</article>
<article class="stat-card">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{member().remainingMajor} {dashboard().currency}
</strong>
</article>
</div>
</article>
)}
</Show>
<article class="balance-item balance-item--wide">
<header>
<strong>{props.copy.latestActivityTitle ?? ''}</strong>
</header>
{dashboard().ledger.length === 0 ? (
<p>{props.copy.latestActivityEmpty ?? ''}</p>
) : (
<div class="activity-list">
<For each={dashboard().ledger.slice(0, 3)}>
{(entry) => (
<article class="activity-row">
<header>
<strong>{props.ledgerTitle(entry)}</strong>
<span>{props.ledgerPrimaryAmount(entry)}</span>
</header>
<Show when={props.ledgerSecondaryAmount(entry)}>
{(secondary) => <p>{secondary()}</p>}
</Show>
<p>{entry.actorDisplayName ?? props.copy.ledgerActorFallback ?? ''}</p>
</article>
)}
</For>
</div>
)}
</article> </article>
</div> </div>
)} )}

View File

@@ -165,8 +165,10 @@ type Props = {
} }
export function HouseScreen(props: Props) { export function HouseScreen(props: Props) {
if (!props.readyIsAdmin) {
return ( return (
<Show
when={props.readyIsAdmin}
fallback={
<div class="balance-list"> <div class="balance-list">
<article class="balance-item"> <article class="balance-item">
<header> <header>
@@ -175,10 +177,8 @@ export function HouseScreen(props: Props) {
<p>{props.copy.residentHouseBody ?? ''}</p> <p>{props.copy.residentHouseBody ?? ''}</p>
</article> </article>
</div> </div>
)
} }
>
return (
<div class="admin-layout"> <div class="admin-layout">
<NavigationTabs <NavigationTabs
items={ items={
@@ -199,7 +199,9 @@ export function HouseScreen(props: Props) {
<article class="balance-item"> <article class="balance-item">
<header> <header>
<strong>{props.copy.billingCycleTitle ?? ''}</strong> <strong>{props.copy.billingCycleTitle ?? ''}</strong>
<span>{props.cycleState?.cycle?.period ?? props.copy.billingCycleEmpty ?? ''}</span> <span>
{props.cycleState?.cycle?.period ?? props.copy.billingCycleEmpty ?? ''}
</span>
</header> </header>
<p> <p>
{props.cycleState?.cycle {props.cycleState?.cycle
@@ -413,7 +415,9 @@ export function HouseScreen(props: Props) {
) )
} }
> >
<option value="utilities">{props.copy.paymentBalanceAdjustmentUtilities}</option> <option value="utilities">
{props.copy.paymentBalanceAdjustmentUtilities}
</option>
<option value="rent">{props.copy.paymentBalanceAdjustmentRent}</option> <option value="rent">{props.copy.paymentBalanceAdjustmentRent}</option>
<option value="separate">{props.copy.paymentBalanceAdjustmentSeparate}</option> <option value="separate">{props.copy.paymentBalanceAdjustmentSeparate}</option>
</select> </select>
@@ -562,7 +566,9 @@ export function HouseScreen(props: Props) {
</header> </header>
<p>{props.copy.utilityCategoryName ?? ''}</p> <p>{props.copy.utilityCategoryName ?? ''}</p>
<div class="ledger-compact-card__meta"> <div class="ledger-compact-card__meta">
<span class={`mini-chip ${category.isActive ? '' : 'mini-chip--muted'}`}> <span
class={`mini-chip ${category.isActive ? '' : 'mini-chip--muted'}`}
>
{category.isActive ? 'ON' : 'OFF'} {category.isActive ? 'ON' : 'OFF'}
</span> </span>
</div> </div>
@@ -656,7 +662,10 @@ export function HouseScreen(props: Props) {
} }
return ( return (
<div class="modal-action-row"> <div class="modal-action-row">
<Button variant="danger" onClick={() => void props.onDeleteUtilityBill(bill.id)}> <Button
variant="danger"
onClick={() => void props.onDeleteUtilityBill(bill.id)}
>
{props.deletingUtilityBillId === bill.id {props.deletingUtilityBillId === bill.id
? props.copy.deletingUtilityBill ? props.copy.deletingUtilityBill
: props.copy.deleteUtilityBillAction} : props.copy.deleteUtilityBillAction}
@@ -801,7 +810,9 @@ export function HouseScreen(props: Props) {
<select <select
value={draft.isActive ? 'true' : 'false'} value={draft.isActive ? 'true' : 'false'}
onChange={(event) => onChange={(event) =>
props.onEditingCategoryActiveChange(event.currentTarget.value === 'true') props.onEditingCategoryActiveChange(
event.currentTarget.value === 'true'
)
} }
> >
<option value="true">ON</option> <option value="true">ON</option>
@@ -929,7 +940,8 @@ export function HouseScreen(props: Props) {
props.memberDisplayNameDrafts[member.id]?.trim() ?? member.displayName props.memberDisplayNameDrafts[member.id]?.trim() ?? member.displayName
const nextStatus = props.memberStatusDrafts[member.id] ?? member.status const nextStatus = props.memberStatusDrafts[member.id] ?? member.status
const currentPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status) const currentPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status)
const nextPolicy = props.memberAbsencePolicyDrafts[member.id] ?? currentPolicy.policy const nextPolicy =
props.memberAbsencePolicyDrafts[member.id] ?? currentPolicy.policy
const nextWeight = Number( const nextWeight = Number(
props.rentWeightDrafts[member.id] ?? String(member.rentShareWeight) props.rentWeightDrafts[member.id] ?? String(member.rentShareWeight)
) )
@@ -1078,7 +1090,9 @@ export function HouseScreen(props: Props) {
<div class="balance-list admin-sublist"> <div class="balance-list admin-sublist">
<For each={['purchase', 'feedback', 'reminders', 'payments'] as const}> <For each={['purchase', 'feedback', 'reminders', 'payments'] as const}>
{(role) => { {(role) => {
const binding = props.adminSettings?.topics.find((topic) => topic.role === role) const binding = props.adminSettings?.topics.find(
(topic) => topic.role === role
)
return ( return (
<article class="ledger-item"> <article class="ledger-item">
@@ -1101,5 +1115,6 @@ export function HouseScreen(props: Props) {
</section> </section>
</Show> </Show>
</div> </div>
</Show>
) )
} }

View File

@@ -140,6 +140,7 @@ export interface FinanceDashboardLedgerEntry {
export interface FinanceDashboard { export interface FinanceDashboard {
period: string period: string
currency: CurrencyCode currency: CurrencyCode
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
totalDue: Money totalDue: Money
totalPaid: Money totalPaid: Money
totalRemaining: Money totalRemaining: Money
@@ -558,6 +559,7 @@ async function buildFinanceDashboard(
return { return {
period: cycle.period, period: cycle.period,
currency: cycle.currency, currency: cycle.currency,
paymentBalanceAdjustmentPolicy: settings.paymentBalanceAdjustmentPolicy ?? 'utilities',
totalDue: settlement.totalDue, totalDue: settlement.totalDue,
totalPaid: paymentRecords.reduce( totalPaid: paymentRecords.reduce(
(sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)), (sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)),

View File

@@ -112,6 +112,7 @@ describe('createPaymentConfirmationService', () => {
generateDashboard: async () => ({ generateDashboard: async () => ({
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities',
totalDue: Money.fromMajor('1030', 'GEL'), totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'), totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'), totalRemaining: Money.fromMajor('1030', 'GEL'),
@@ -174,6 +175,7 @@ describe('createPaymentConfirmationService', () => {
generateDashboard: async () => ({ generateDashboard: async () => ({
period: '2026-03', period: '2026-03',
currency: 'GEL', currency: 'GEL',
paymentBalanceAdjustmentPolicy: 'utilities',
totalDue: Money.fromMajor('1030', 'GEL'), totalDue: Money.fromMajor('1030', 'GEL'),
totalPaid: Money.zero('GEL'), totalPaid: Money.zero('GEL'),
totalRemaining: Money.fromMajor('1030', 'GEL'), totalRemaining: Money.fromMajor('1030', 'GEL'),