diff --git a/apps/bot/src/dm-assistant.test.ts b/apps/bot/src/dm-assistant.test.ts index c4e1c7e..33ac94a 100644 --- a/apps/bot/src/dm-assistant.test.ts +++ b/apps/bot/src/dm-assistant.test.ts @@ -283,6 +283,7 @@ function createFinanceService(): FinanceCommandService { generateDashboard: async () => ({ period: '2026-03', currency: 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities', totalDue: Money.fromMajor('1000.00', 'GEL'), totalPaid: Money.fromMajor('500.00', 'GEL'), totalRemaining: Money.fromMajor('500.00', 'GEL'), diff --git a/apps/bot/src/finance-commands.test.ts b/apps/bot/src/finance-commands.test.ts index 87ac8fb..5191cd5 100644 --- a/apps/bot/src/finance-commands.test.ts +++ b/apps/bot/src/finance-commands.test.ts @@ -124,6 +124,7 @@ function createDashboard(): NonNullable< return { period: '2026-03', currency: 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities', totalDue: Money.fromMajor('400', 'GEL'), totalPaid: Money.fromMajor('100', 'GEL'), totalRemaining: Money.fromMajor('300', 'GEL'), diff --git a/apps/bot/src/miniapp-dashboard.test.ts b/apps/bot/src/miniapp-dashboard.test.ts index 2e3cfb0..160c4c8 100644 --- a/apps/bot/src/miniapp-dashboard.test.ts +++ b/apps/bot/src/miniapp-dashboard.test.ts @@ -310,6 +310,7 @@ describe('createMiniAppDashboardHandler', () => { dashboard: { period: '2026-03', currency: 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities', totalDueMajor: '2010.00', totalPaidMajor: '500.00', totalRemainingMajor: '1510.00', diff --git a/apps/bot/src/miniapp-dashboard.ts b/apps/bot/src/miniapp-dashboard.ts index 1bd2638..1c2de71 100644 --- a/apps/bot/src/miniapp-dashboard.ts +++ b/apps/bot/src/miniapp-dashboard.ts @@ -88,6 +88,7 @@ export function createMiniAppDashboardHandler(options: { dashboard: { period: dashboard.period, currency: dashboard.currency, + paymentBalanceAdjustmentPolicy: dashboard.paymentBalanceAdjustmentPolicy, totalDueMajor: dashboard.totalDue.toMajorString(), totalPaidMajor: dashboard.totalPaid.toMajorString(), totalRemainingMajor: dashboard.totalRemaining.toMajorString(), diff --git a/apps/bot/src/payment-topic-ingestion.test.ts b/apps/bot/src/payment-topic-ingestion.test.ts index b4d72ab..54455be 100644 --- a/apps/bot/src/payment-topic-ingestion.test.ts +++ b/apps/bot/src/payment-topic-ingestion.test.ts @@ -168,6 +168,7 @@ function createFinanceService(): FinanceCommandService { generateDashboard: async () => ({ period: '2026-03', currency: 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities', totalDue: Money.fromMajor('1000', 'GEL'), totalPaid: Money.zero('GEL'), totalRemaining: Money.fromMajor('1000', 'GEL'), diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index 171295e..29b8150 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -121,6 +121,8 @@ type PaymentDraft = { currency: 'USD' | 'GEL' } +type TestingRolePreview = 'admin' | 'resident' + const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const const demoSession: Extract = { @@ -380,6 +382,9 @@ function App() { const [addingUtilityBillOpen, setAddingUtilityBillOpen] = createSignal(false) const [addingPaymentOpen, setAddingPaymentOpen] = createSignal(false) const [profileEditorOpen, setProfileEditorOpen] = createSignal(false) + const [testingSurfaceOpen, setTestingSurfaceOpen] = createSignal(false) + const [roleChipTapHistory, setRoleChipTapHistory] = createSignal([]) + const [testingRolePreview, setTestingRolePreview] = createSignal(null) const [addingPayment, setAddingPayment] = createSignal(false) const [billingForm, setBillingForm] = createSignal({ settlementCurrency: 'GEL' as 'USD' | 'GEL', @@ -421,6 +426,23 @@ function App() { const current = session() 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 current = readySession() 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( status: 'active' | 'away' | 'left' ): MiniAppMemberAbsencePolicy { @@ -1948,7 +1988,7 @@ function App() { ) } @@ -2439,14 +2473,31 @@ function App() { {readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge} - - {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} - + + {effectiveIsAdmin() ? copy().adminTag : copy().residentTag} + + } + > + + {readySession()?.member.status ? memberStatusLabel(readySession()!.member.status) : copy().memberStatusActive} + + {(preview) => ( + {`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`} + )} + + + + } + > +
+
+ {copy().testingCurrentRoleLabel ?? ''} + + {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} + +
+
+ {copy().testingPreviewRoleLabel ?? ''} + + {testingRolePreview() + ? testingRolePreview() === 'admin' + ? copy().adminTag + : copy().residentTag + : copy().testingUseRealRoleAction} + +
+
+ + +
+
+ + 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 ( +
+
+
+ {props.copy.yourBalanceTitle ?? ''} +

{props.copy.yourBalanceBody ?? ''}

+
+
+ {props.copy.remainingLabel ?? ''} + + {props.member.remainingMajor} {props.dashboard.currency} + + + {props.copy.totalDue ?? ''}: {props.member.netDueMajor} {props.dashboard.currency} + +
+
+ +
+ + {props.copy.totalDue ?? ''} + + {props.member.netDueMajor} {props.dashboard.currency} + + + + {props.copy.paidLabel ?? ''} + + {props.member.paidMajor} {props.dashboard.currency} + + + + {props.copy.remainingLabel ?? ''} + + {props.member.remainingMajor} {props.dashboard.currency} + + +
+ +
+
+
+ {props.copy.shareRent ?? ''} + + {props.member.rentShareMajor} {props.dashboard.currency} + +
+
+ +
+
+ {props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities ?? ''} + + {props.member.utilityShareMajor} {props.dashboard.currency} + +
+
+ +
+
+ {props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset ?? ''} + + {props.member.purchaseOffsetMajor} {props.dashboard.currency} + +
+
+ + +
+
+ {props.copy.utilitiesAdjustedTotalLabel ?? ''} + + {utilitiesAdjustedMajor()} {props.dashboard.currency} + +
+
+
+
+ + +
+
+ {props.copy.rentFxTitle ?? ''} + + {(date) => ( + + {props.copy.fxEffectiveDateLabel ?? ''}: {date()} + + )} + +
+ +
+
+ {props.copy.sourceAmountLabel ?? ''} + + {props.dashboard.rentSourceAmountMajor} {props.dashboard.rentSourceCurrency} + +
+
+ {props.copy.settlementAmountLabel ?? ''} + + {props.dashboard.rentDisplayAmountMajor} {props.dashboard.currency} + +
+
+
+
+ + 0}> +
+ + {(explanation) => {explanation}} + +
+
+
+ ) +} diff --git a/apps/miniapp/src/demo/miniapp-demo.ts b/apps/miniapp/src/demo/miniapp-demo.ts index cfb3abb..826f41f 100644 --- a/apps/miniapp/src/demo/miniapp-demo.ts +++ b/apps/miniapp/src/demo/miniapp-demo.ts @@ -25,6 +25,7 @@ export const demoTelegramUser: NonNullable = { export const demoDashboard: MiniAppDashboard = { period: '2026-03', currency: 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities', totalDueMajor: '2410.00', totalPaidMajor: '650.00', totalRemainingMajor: '1760.00', diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index b8153a2..7940127 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -52,9 +52,16 @@ export const dictionary = { ledgerEntries: 'Ledger entries', pendingRequests: 'Pending requests', 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', finalDue: 'Final due', + houseSnapshotTitle: 'House snapshot', + houseSnapshotBody: 'House totals stay secondary here so your own bill reads first.', householdBalancesTitle: 'Household balances', householdBalancesBody: 'Everyone’s current split for this cycle.', financeVisualsTitle: 'Visual balance split', @@ -80,10 +87,23 @@ export const dictionary = { shareRent: 'Rent', shareUtilities: 'Utilities', shareOffset: 'Shared buys', + rentFxTitle: 'House rent FX', + sourceAmountLabel: 'Source', + settlementAmountLabel: 'Settlement', + fxEffectiveDateLabel: 'Locked', ledgerTitle: 'Included ledger', emptyDashboard: 'No billing cycle is ready yet.', latestActivityTitle: 'Latest activity', 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', purchaseReviewBody: 'Edit or remove purchases if the bot recorded the wrong item.', purchaseSplitTitle: 'Split', @@ -273,9 +293,16 @@ export const dictionary = { ledgerEntries: 'Записи леджера', pendingRequests: 'Ожидают подтверждения', yourBalanceTitle: 'Твой баланс', - yourBalanceBody: 'Посмотри свой баланс за текущий цикл до и после поправки на общие покупки.', + yourBalanceBody: + 'Здесь отдельно видно аренду, чистую коммуналку, поправку по покупкам и то, что осталось оплатить.', + cycleBillLabel: 'Счёт за цикл', + balanceAdjustmentLabel: 'Поправка по балансу', + pureUtilitiesLabel: 'Чистая коммуналка', + utilitiesAdjustedTotalLabel: 'Коммуналка после зачёта', baseDue: 'База к оплате', finalDue: 'Итог к оплате', + houseSnapshotTitle: 'Сводка по дому', + houseSnapshotBody: 'Общая сводка дома остаётся второстепенной, чтобы твой счёт читался сразу.', householdBalancesTitle: 'Баланс household', householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.', financeVisualsTitle: 'Визуальный разбор баланса', @@ -301,10 +328,23 @@ export const dictionary = { shareRent: 'Аренда', shareUtilities: 'Коммуналка', shareOffset: 'Общие покупки', + rentFxTitle: 'FX по аренде дома', + sourceAmountLabel: 'Исходник', + settlementAmountLabel: 'Расчёт', + fxEffectiveDateLabel: 'Зафиксировано', ledgerTitle: 'Вошедшие операции', emptyDashboard: 'Пока нет готового billing cycle.', latestActivityTitle: 'Последняя активность', latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.', + testingViewBadge: 'Тестовый вид', + testingSurfaceTitle: 'Скрытый QA режим', + testingSurfaceBody: + 'Позволяет посмотреть админский или обычный вид без изменения реальных прав и сохранённых ролей.', + testingUseRealRoleAction: 'Настоящая роль', + testingPreviewAdminAction: 'Вид админа', + testingPreviewResidentAction: 'Вид жителя', + testingCurrentRoleLabel: 'Реальный доступ', + testingPreviewRoleLabel: 'Сейчас показан', purchaseReviewTitle: 'Покупки', purchaseReviewBody: 'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 39978cb..caad749 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -382,6 +382,10 @@ button { border-color: rgb(247 179 137 / 0.28); } +.balance-item--muted { + background: rgb(255 255 255 / 0.02); +} + .profile-card { gap: 10px; } @@ -438,6 +442,114 @@ button { 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 { display: grid; gap: 12px; @@ -771,6 +883,18 @@ button { color: #dad5ce; } +.mini-chip-button { + cursor: pointer; +} + +.testing-card { + gap: 12px; +} + +.testing-card__actions { + grid-template-columns: minmax(0, 1fr); +} + .modal-backdrop { position: fixed; inset: 0; @@ -981,6 +1105,21 @@ button { 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 { grid-template-columns: minmax(220px, 260px) minmax(0, 1fr); align-items: center; diff --git a/apps/miniapp/src/miniapp-api.ts b/apps/miniapp/src/miniapp-api.ts index 8b85218..70a40ca 100644 --- a/apps/miniapp/src/miniapp-api.ts +++ b/apps/miniapp/src/miniapp-api.ts @@ -88,6 +88,7 @@ export interface MiniAppTopicBinding { export interface MiniAppDashboard { period: string currency: 'USD' | 'GEL' + paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate' totalDueMajor: string totalPaidMajor: string totalRemainingMajor: string diff --git a/apps/miniapp/src/screens/balances-screen.tsx b/apps/miniapp/src/screens/balances-screen.tsx index 6e1935b..c78d93a 100644 --- a/apps/miniapp/src/screens/balances-screen.tsx +++ b/apps/miniapp/src/screens/balances-screen.tsx @@ -2,6 +2,7 @@ import { For, Show } from 'solid-js' import { FinanceSummaryCards } from '../components/finance/finance-summary-cards' import { FinanceVisuals } from '../components/finance/finance-visuals' +import { MemberBalanceCard } from '../components/finance/member-balance-card' import type { MiniAppDashboard } from '../miniapp-api' type Props = { @@ -52,62 +53,34 @@ export function BalancesScreen(props: Props) {
{(member) => ( -
-
- {props.copy.yourBalanceTitle ?? ''} - - {member().netDueMajor} {dashboard().currency} - -
-

{props.copy.yourBalanceBody ?? ''}

-
-
- {props.copy.baseDue ?? ''} - - {props.memberBaseDueMajor(member())} {dashboard().currency} - -
-
- {props.copy.shareOffset ?? ''} - - {member().purchaseOffsetMajor} {dashboard().currency} - -
-
- {props.copy.finalDue ?? ''} - - {member().netDueMajor} {dashboard().currency} - -
-
- {props.copy.paidLabel ?? ''} - - {member().paidMajor} {dashboard().currency} - -
-
- {props.copy.remainingLabel ?? ''} - - {member().remainingMajor} {dashboard().currency} - -
-
-
+ )}
-
- -
+
+
+ {props.copy.houseSnapshotTitle ?? ''} + {dashboard().period} +
+

{props.copy.houseSnapshotBody ?? ''}

+
+ +
+
-
+
{props.copy.householdBalancesTitle ?? ''} + {String(dashboard().members.length)}

{props.copy.householdBalancesBody ?? ''}

diff --git a/apps/miniapp/src/screens/home-screen.tsx b/apps/miniapp/src/screens/home-screen.tsx index 3752973..0406d90 100644 --- a/apps/miniapp/src/screens/home-screen.tsx +++ b/apps/miniapp/src/screens/home-screen.tsx @@ -1,20 +1,15 @@ -import { For, Show } from 'solid-js' +import { Show } from 'solid-js' import { FinanceSummaryCards } from '../components/finance/finance-summary-cards' +import { MemberBalanceCard } from '../components/finance/member-balance-card' import type { MiniAppDashboard } from '../miniapp-api' type Props = { copy: Record dashboard: MiniAppDashboard | null - readyIsAdmin: boolean - pendingMembersCount: number currentMemberLine: MiniAppDashboard['members'][number] | null utilityTotalMajor: 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) { @@ -23,125 +18,48 @@ export function HomeScreen(props: Props) { when={props.dashboard} fallback={
-
-
- {props.copy.remainingLabel ?? ''} - -
-
- {props.copy.shareRent ?? ''} - -
-
- {props.copy.shareUtilities ?? ''} - -
-
- {props.copy.purchasesTitle ?? ''} - -
-
+
+
+
+ {props.copy.yourBalanceTitle ?? ''} +

{props.copy.yourBalanceBody ?? ''}

+
+
+ {props.copy.remainingLabel ?? ''} + +
+
+
} > {(dashboard) => (
-
- - -
- {props.copy.pendingRequests ?? ''} - {String(props.pendingMembersCount)} -
-
-
- {(member) => ( -
-
- {props.copy.yourBalanceTitle ?? ''} - - {member().remainingMajor} {dashboard().currency} - -
-

- {props.copy.shareRent ?? ''}: {dashboard().rentSourceAmountMajor}{' '} - {dashboard().rentSourceCurrency} - {dashboard().rentSourceCurrency !== dashboard().currency - ? ` -> ${dashboard().rentDisplayAmountMajor} ${dashboard().currency}` - : ''} -

-
-
- {props.copy.baseDue ?? ''} - - {props.memberBaseDueMajor(member())} {dashboard().currency} - -
-
- {props.copy.shareOffset ?? ''} - - {member().purchaseOffsetMajor} {dashboard().currency} - -
-
- {props.copy.finalDue ?? ''} - - {member().netDueMajor} {dashboard().currency} - -
-
- {props.copy.paidLabel ?? ''} - - {member().paidMajor} {dashboard().currency} - -
-
- {props.copy.remainingLabel ?? ''} - - {member().remainingMajor} {dashboard().currency} - -
-
-
+ )}
-
+
- {props.copy.latestActivityTitle ?? ''} + {props.copy.houseSnapshotTitle ?? ''} + {dashboard().period}
- {dashboard().ledger.length === 0 ? ( -

{props.copy.latestActivityEmpty ?? ''}

- ) : ( -
- - {(entry) => ( -
-
- {props.ledgerTitle(entry)} - {props.ledgerPrimaryAmount(entry)} -
- - {(secondary) =>

{secondary()}

} -
-

{entry.actorDisplayName ?? props.copy.ledgerActorFallback ?? ''}

-
- )} -
-
- )} +

{props.copy.houseSnapshotBody ?? ''}

+
+ +
)} diff --git a/apps/miniapp/src/screens/house-screen.tsx b/apps/miniapp/src/screens/house-screen.tsx index ba83fc3..3c388e5 100644 --- a/apps/miniapp/src/screens/house-screen.tsx +++ b/apps/miniapp/src/screens/house-screen.tsx @@ -165,367 +165,418 @@ type Props = { } export function HouseScreen(props: Props) { - if (!props.readyIsAdmin) { - return ( -
-
-
- {props.copy.residentHouseTitle ?? ''} -
-

{props.copy.residentHouseBody ?? ''}

-
-
- ) - } - return ( -
- + +
+
+ {props.copy.residentHouseTitle ?? ''} +
+

{props.copy.residentHouseBody ?? ''}

+
+
+ } + > +
+ - -
-
-
-
- {props.copy.billingCycleTitle ?? ''} - {props.cycleState?.cycle?.period ?? props.copy.billingCycleEmpty ?? ''} -
-

- {props.cycleState?.cycle - ? (props.copy.billingCycleStatus ?? '').replace( - '{currency}', - props.cycleState?.cycle?.currency ?? props.billingForm.settlementCurrency - ) - : props.copy.billingCycleOpenHint} -

- - {(data) => ( -

- {props.copy.shareRent ?? ''}: {data().rentSourceAmountMajor}{' '} - {data().rentSourceCurrency} - {data().rentSourceCurrency !== data().currency - ? ` -> ${data().rentDisplayAmountMajor} ${data().currency}` - : ''} -

- )} -
-
- - - + ? (props.copy.billingCycleStatus ?? '').replace( + '{currency}', + props.cycleState?.cycle?.currency ?? props.billingForm.settlementCurrency + ) + : props.copy.billingCycleOpenHint} +

+ + {(data) => ( +

+ {props.copy.shareRent ?? ''}: {data().rentSourceAmountMajor}{' '} + {data().rentSourceCurrency} + {data().rentSourceCurrency !== data().currency + ? ` -> ${data().rentDisplayAmountMajor} ${data().currency}` + : ''} +

+ )}
-
-
- -
-
- {props.copy.billingSettingsTitle ?? ''} - {props.billingForm.settlementCurrency} -
-

- {props.billingForm.paymentBalanceAdjustmentPolicy === 'utilities' - ? props.copy.paymentBalanceAdjustmentUtilities - : props.billingForm.paymentBalanceAdjustmentPolicy === 'rent' - ? props.copy.paymentBalanceAdjustmentRent - : props.copy.paymentBalanceAdjustmentSeparate} -

-
- - {props.copy.rentAmount ?? ''}: {props.billingForm.rentAmountMajor || '—'}{' '} - {props.billingForm.rentCurrency} - - - {props.copy.timezone ?? ''}: {props.billingForm.timezone} - -
-
- -
-
- -
-
- {props.copy.householdLanguage ?? ''} - {props.householdDefaultLocale.toUpperCase()} -
-
- - -
-
-
- - - + +
+
+ +
+
+ {props.copy.billingSettingsTitle ?? ''} + {props.billingForm.settlementCurrency} +
+

+ {props.billingForm.paymentBalanceAdjustmentPolicy === 'utilities' + ? props.copy.paymentBalanceAdjustmentUtilities + : props.billingForm.paymentBalanceAdjustmentPolicy === 'rent' + ? props.copy.paymentBalanceAdjustmentRent + : props.copy.paymentBalanceAdjustmentSeparate} +

+
+ + {props.copy.rentAmount ?? ''}: {props.billingForm.rentAmountMajor || '—'}{' '} + {props.billingForm.rentCurrency} + + + {props.copy.timezone ?? ''}: {props.billingForm.timezone} + +
+
+ +
+
+ +
+
+ {props.copy.householdLanguage ?? ''} + {props.householdDefaultLocale.toUpperCase()} +
+
+ + EN + + +
+
+
+ + + + + ) : ( + + ) + } + > + {props.cycleState?.cycle ? ( +
+ + props.onCycleRentAmountChange(event.currentTarget.value)} + /> + + + +
) : ( +
+ + props.onCyclePeriodChange(event.currentTarget.value)} + /> + + +
{props.billingForm.settlementCurrency}
+
+
+ )} +
+ - - ) - } - > - {props.cycleState?.cycle ? ( + } + >
+ + + + + + props.onCycleRentAmountChange(event.currentTarget.value)} + value={props.billingForm.rentAmountMajor} + onInput={(event) => props.onBillingRentAmountChange(event.currentTarget.value)} /> -
- ) : ( -
- + props.onCyclePeriodChange(event.currentTarget.value)} + type="number" + min="1" + max="31" + value={String(props.billingForm.rentDueDay)} + onInput={(event) => + props.onBillingRentDueDayChange(Number(event.currentTarget.value)) + } /> - -
{props.billingForm.settlementCurrency}
+ + + props.onBillingRentWarningDayChange(Number(event.currentTarget.value)) + } + /> + + + + props.onBillingUtilitiesDueDayChange(Number(event.currentTarget.value)) + } + /> + + + + props.onBillingUtilitiesReminderDayChange(Number(event.currentTarget.value)) + } + /> + + + props.onBillingTimezoneChange(event.currentTarget.value)} + />
- )} -
- - - - - } - > -
- - - - - - - - props.onBillingRentAmountChange(event.currentTarget.value)} - /> - - - - - - - props.onBillingRentDueDayChange(Number(event.currentTarget.value)) - } - /> - - - - props.onBillingRentWarningDayChange(Number(event.currentTarget.value)) - } - /> - - - - props.onBillingUtilitiesDueDayChange(Number(event.currentTarget.value)) - } - /> - - - - props.onBillingUtilitiesReminderDayChange(Number(event.currentTarget.value)) - } - /> - - - props.onBillingTimezoneChange(event.currentTarget.value)} - /> - -
-
- -
+ + + - -
-
-
-
- {props.copy.utilityLedgerTitle ?? ''} - {props.cycleForm.utilityCurrency} -
-

{props.copy.utilityBillsEditorBody ?? ''}

-
- -
-
- {props.cycleState?.utilityBills.length ? ( - - {(bill) => ( + +
+
+
+
+ {props.copy.utilityLedgerTitle ?? ''} + {props.cycleForm.utilityCurrency} +
+

{props.copy.utilityBillsEditorBody ?? ''}

+
+ +
+
+ {props.cycleState?.utilityBills.length ? ( + + {(bill) => ( +
+
+
+ {bill.billName} + {bill.createdAt.slice(0, 10)} +
+

{props.copy.utilityCategoryName ?? ''}

+
+ + {props.minorToMajorString(BigInt(bill.amountMinor))} {bill.currency} + +
+
+
+ props.onOpenUtilityBillEditor(bill.id)} + > + + +
+
+ )} +
+ ) : ( +

{props.copy.utilityBillsEmpty ?? ''}

+ )} +
+
+ +
+
+ {props.copy.utilityCategoriesTitle ?? ''} + {String(props.adminSettings?.categories.length ?? 0)} +
+

{props.copy.utilityCategoriesBody ?? ''}

+
+ +
+
+ + {(category) => (
- {bill.billName} - {bill.createdAt.slice(0, 10)} + {category.name} + {category.isActive ? 'ON' : 'OFF'}

{props.copy.utilityCategoryName ?? ''}

- - {props.minorToMajorString(BigInt(bill.amountMinor))} {bill.currency} + + {category.isActive ? 'ON' : 'OFF'}
props.onOpenUtilityBillEditor(bill.id)} + label={props.copy.editCategoryAction ?? ''} + onClick={() => props.onOpenCategoryEditor(category.slug)} > @@ -533,573 +584,537 @@ export function HouseScreen(props: Props) {
)}
- ) : ( -

{props.copy.utilityBillsEmpty ?? ''}

- )} -
-
- -
-
- {props.copy.utilityCategoriesTitle ?? ''} - {String(props.adminSettings?.categories.length ?? 0)} -
-

{props.copy.utilityCategoriesBody ?? ''}

-
- -
-
- - {(category) => ( -
-
-
- {category.name} - {category.isActive ? 'ON' : 'OFF'} -
-

{props.copy.utilityCategoryName ?? ''}

-
- - {category.isActive ? 'ON' : 'OFF'} - -
-
-
- props.onOpenCategoryEditor(category.slug)} - > - - -
-
- )} -
-
-
-
- - - -
- } - > -
- - - - - props.onCycleUtilityAmountChange(event.currentTarget.value)} - /> - - - - +
+
- - { - const bill = props.editingUtilityBill - if (!bill) { - return null - } - return ( - - ) - })()} - > - {(() => { - const bill = props.editingUtilityBill - if (!bill) { - return null - } - const draft = props.utilityBillDrafts[bill.id] ?? { - billName: bill.billName, - amountMajor: props.minorToMajorString(BigInt(bill.amountMinor)), - currency: bill.currency - } - - return ( -
- - - props.onUtilityBillNameChange(bill.id, bill, event.currentTarget.value) - } - /> - - - - props.onUtilityBillAmountChange(bill.id, bill, event.currentTarget.value) - } - /> - - - - -
- ) - })()} -
- { - const category = props.editingCategory - const isNew = props.editingCategorySlug === '__new__' - return ( + - - ) - })()} - > - {props.editingCategorySlug === '__new__' ? ( + } + >
- + + + + props.onNewCategoryNameChange(event.currentTarget.value)} + value={props.cycleForm.utilityAmountMajor} + onInput={(event) => props.onCycleUtilityAmountChange(event.currentTarget.value)} /> + + +
- ) : ( - (() => { - const category = props.editingCategory - const draft = props.editingCategoryDraft - if (!category || !draft) { +
+ { + const bill = props.editingUtilityBill + if (!bill) { return null } + return ( + + ) + })()} + > + {(() => { + const bill = props.editingUtilityBill + if (!bill) { + return null + } + const draft = props.utilityBillDrafts[bill.id] ?? { + billName: bill.billName, + amountMajor: props.minorToMajorString(BigInt(bill.amountMinor)), + currency: bill.currency + } + return (
- props.onEditingCategoryNameChange(event.currentTarget.value) + props.onUtilityBillNameChange(bill.id, bill, event.currentTarget.value) } /> - + + + props.onUtilityBillAmountChange(bill.id, bill, event.currentTarget.value) + } + /> + +
) - })() - )} -
-
-
- - -
-
-
-
- {props.copy.adminsTitle ?? ''} - {String(props.adminSettings?.members.length ?? 0)} -
-
- - {(member) => ( -
-
-
- {member.displayName} - - {member.isAdmin ? props.copy.adminTag : props.copy.residentTag} - -
-

{props.memberStatusLabel(member.status)}

-
- - {props.copy.rentWeightLabel}: {member.rentShareWeight} - - - {(() => { - const policy = props.resolvedMemberAbsencePolicy( - member.id, - member.status - ).policy - return policy === 'away_rent_only' - ? props.copy.absencePolicyAwayRentOnly - : policy === 'away_rent_and_utilities' - ? props.copy.absencePolicyAwayRentAndUtilities - : policy === 'inactive' - ? props.copy.absencePolicyInactive - : props.copy.absencePolicyResident - })()} - -
-
-
- props.onOpenMemberEditor(member.id)} - > - - -
-
- )} -
-
-
- -
-
- {props.copy.pendingMembersTitle ?? ''} - {String(props.pendingMembers.length)} -
-

{props.copy.pendingMembersBody ?? ''}

- {props.pendingMembers.length === 0 ? ( -

{props.copy.pendingMembersEmpty ?? ''}

+ })()} + + { + const category = props.editingCategory + const isNew = props.editingCategorySlug === '__new__' + return ( + + ) + })()} + > + {props.editingCategorySlug === '__new__' ? ( +
+ + props.onNewCategoryNameChange(event.currentTarget.value)} + /> + +
) : ( -
- - {(member) => ( -
-
- {member.displayName} - {member.telegramUserId} -
-

- {member.username - ? (props.copy.pendingMemberHandle ?? '').replace( - '{username}', - member.username - ) - : (member.languageCode ?? 'Telegram')} -

-
+
+ + +
+
+
+
+ {props.copy.adminsTitle ?? ''} + {String(props.adminSettings?.members.length ?? 0)} +
+
+ + {(member) => ( +
+
+
+ {member.displayName} + + {member.isAdmin ? props.copy.adminTag : props.copy.residentTag} + +
+

{props.memberStatusLabel(member.status)}

+
+ + {props.copy.rentWeightLabel}: {member.rentShareWeight} + + + {(() => { + const policy = props.resolvedMemberAbsencePolicy( + member.id, + member.status + ).policy + return policy === 'away_rent_only' + ? props.copy.absencePolicyAwayRentOnly + : policy === 'away_rent_and_utilities' + ? props.copy.absencePolicyAwayRentAndUtilities + : policy === 'inactive' + ? props.copy.absencePolicyInactive + : props.copy.absencePolicyResident + })()} + +
+
+
+ props.onOpenMemberEditor(member.id)} + > + + +
)}
- )} -
-
- { - const member = props.editingMember - if (!member) { - return null - } + - const nextDisplayName = - props.memberDisplayNameDrafts[member.id]?.trim() ?? member.displayName - const nextStatus = props.memberStatusDrafts[member.id] ?? member.status - const currentPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status) - const nextPolicy = props.memberAbsencePolicyDrafts[member.id] ?? currentPolicy.policy - const nextWeight = Number( - props.rentWeightDrafts[member.id] ?? String(member.rentShareWeight) - ) - const hasNameChange = - nextDisplayName.length >= 2 && nextDisplayName !== member.displayName - const hasStatusChange = nextStatus !== member.status - const hasPolicyChange = nextStatus === 'away' && nextPolicy !== currentPolicy.policy - const hasWeightChange = - Number.isInteger(nextWeight) && - nextWeight > 0 && - nextWeight !== member.rentShareWeight - const canSave = - props.savingMemberEditorId !== member.id && - (hasNameChange || hasStatusChange || hasPolicyChange || hasWeightChange) - - return ( -
- -
- - - - +
+
+ {props.copy.pendingMembersTitle ?? ''} + {String(props.pendingMembers.length)} +
+

{props.copy.pendingMembersBody ?? ''}

+ {props.pendingMembers.length === 0 ? ( +

{props.copy.pendingMembersEmpty ?? ''}

+ ) : ( +
+ + {(member) => ( +
+
+ {member.displayName} + {member.telegramUserId} +
+

+ {member.username + ? (props.copy.pendingMemberHandle ?? '').replace( + '{username}', + member.username + ) + : (member.languageCode ?? 'Telegram')} +

+ +
+ )} +
-
- ) - })()} - > - {(() => { - const member = props.editingMember - if (!member) { - return null - } + )} + +
+ { + const member = props.editingMember + if (!member) { + return null + } - const resolvedPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status) + const nextDisplayName = + props.memberDisplayNameDrafts[member.id]?.trim() ?? member.displayName + const nextStatus = props.memberStatusDrafts[member.id] ?? member.status + const currentPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status) + const nextPolicy = + props.memberAbsencePolicyDrafts[member.id] ?? currentPolicy.policy + const nextWeight = Number( + props.rentWeightDrafts[member.id] ?? String(member.rentShareWeight) + ) + const hasNameChange = + nextDisplayName.length >= 2 && nextDisplayName !== member.displayName + const hasStatusChange = nextStatus !== member.status + const hasPolicyChange = nextStatus === 'away' && nextPolicy !== currentPolicy.policy + const hasWeightChange = + Number.isInteger(nextWeight) && + nextWeight > 0 && + nextWeight !== member.rentShareWeight + const canSave = + props.savingMemberEditorId !== member.id && + (hasNameChange || hasStatusChange || hasPolicyChange || hasWeightChange) - return ( -
- - - props.onMemberDisplayNameDraftChange(member.id, event.currentTarget.value) - } - /> - - - - - +
+ + + + +
+
+ ) + })()} + > + {(() => { + const member = props.editingMember + if (!member) { + return null + } + + const resolvedPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status) + + return ( +
+ + + props.onMemberDisplayNameDraftChange(member.id, event.currentTarget.value) + } + /> + + + - props.onMemberAbsencePolicyDraftChange( - member.id, - event.currentTarget.value as MiniAppMemberAbsencePolicy - ) + } + > + + + + + + - - - - - - - - - props.onRentWeightDraftChange(member.id, event.currentTarget.value) - } - /> - + + + + + props.onRentWeightDraftChange(member.id, event.currentTarget.value) + } + /> + +
+ ) + })()} +
+
+
+ + +
+
+
+
+ {props.copy.topicBindingsTitle ?? ''} + {String(props.adminSettings?.topics.length ?? 0)}/4 +
+
+ + {(role) => { + const binding = props.adminSettings?.topics.find( + (topic) => topic.role === role + ) + + return ( +
+
+ {props.topicRoleLabel(role)} + {binding ? props.copy.topicBound : props.copy.topicUnbound} +
+

+ {binding + ? `${binding.topicName ?? `Topic #${binding.telegramThreadId}`} · #${binding.telegramThreadId}` + : props.copy.topicUnbound} +

+
+ ) + }} +
- ) - })()} - -
-
- - -
-
-
-
- {props.copy.topicBindingsTitle ?? ''} - {String(props.adminSettings?.topics.length ?? 0)}/4 -
-
- - {(role) => { - const binding = props.adminSettings?.topics.find((topic) => topic.role === role) - - return ( -
-
- {props.topicRoleLabel(role)} - {binding ? props.copy.topicBound : props.copy.topicUnbound} -
-

- {binding - ? `${binding.topicName ?? `Topic #${binding.telegramThreadId}`} · #${binding.telegramThreadId}` - : props.copy.topicUnbound} -

-
- ) - }} -
-
-
-
-
-
- + + + + + + ) } diff --git a/packages/application/src/finance-command-service.ts b/packages/application/src/finance-command-service.ts index 52e75ba..67a30f4 100644 --- a/packages/application/src/finance-command-service.ts +++ b/packages/application/src/finance-command-service.ts @@ -140,6 +140,7 @@ export interface FinanceDashboardLedgerEntry { export interface FinanceDashboard { period: string currency: CurrencyCode + paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate' totalDue: Money totalPaid: Money totalRemaining: Money @@ -558,6 +559,7 @@ async function buildFinanceDashboard( return { period: cycle.period, currency: cycle.currency, + paymentBalanceAdjustmentPolicy: settings.paymentBalanceAdjustmentPolicy ?? 'utilities', totalDue: settlement.totalDue, totalPaid: paymentRecords.reduce( (sum, payment) => sum.add(Money.fromMinor(payment.amountMinor, payment.currency)), diff --git a/packages/application/src/payment-confirmation-service.test.ts b/packages/application/src/payment-confirmation-service.test.ts index 9b060ae..eac5ad7 100644 --- a/packages/application/src/payment-confirmation-service.test.ts +++ b/packages/application/src/payment-confirmation-service.test.ts @@ -112,6 +112,7 @@ describe('createPaymentConfirmationService', () => { generateDashboard: async () => ({ period: '2026-03', currency: 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities', totalDue: Money.fromMajor('1030', 'GEL'), totalPaid: Money.zero('GEL'), totalRemaining: Money.fromMajor('1030', 'GEL'), @@ -174,6 +175,7 @@ describe('createPaymentConfirmationService', () => { generateDashboard: async () => ({ period: '2026-03', currency: 'GEL', + paymentBalanceAdjustmentPolicy: 'utilities', totalDue: Money.fromMajor('1030', 'GEL'), totalPaid: Money.zero('GEL'), totalRemaining: Money.fromMajor('1030', 'GEL'),