feat(miniapp): redesign balance and due-state flows

This commit is contained in:
2026-03-12 14:08:55 +04:00
parent 6053379f31
commit 0d2065fd5e
11 changed files with 434 additions and 206 deletions

View File

@@ -103,6 +103,7 @@ export function createMiniAppDashboardHandler(options: {
members: dashboard.members.map((line) => ({ members: dashboard.members.map((line) => ({
memberId: line.memberId, memberId: line.memberId,
displayName: line.displayName, displayName: line.displayName,
predictedUtilityShareMajor: line.predictedUtilityShare?.toMajorString() ?? null,
rentShareMajor: line.rentShare.toMajorString(), rentShareMajor: line.rentShare.toMajorString(),
utilityShareMajor: line.utilityShare.toMajorString(), utilityShareMajor: line.utilityShare.toMajorString(),
purchaseOffsetMajor: line.purchaseOffset.toMajorString(), purchaseOffsetMajor: line.purchaseOffset.toMajorString(),

View File

@@ -314,6 +314,7 @@ function App() {
status: 'loading' status: 'loading'
}) })
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home') const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
const [selectedBalanceMemberId, setSelectedBalanceMemberId] = createSignal<string | null>(null)
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null) const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([]) const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null) const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
@@ -444,6 +445,21 @@ function App() {
return data.members.find((member) => member.memberId === current.member.id) ?? null return data.members.find((member) => member.memberId === current.member.id) ?? null
}) })
const inspectedBalanceMember = createMemo(() => {
const data = dashboard()
if (!data) {
return null
}
const selected = selectedBalanceMemberId()
return (
data.members.find((member) => member.memberId === selected) ??
currentMemberLine() ??
data.members[0] ??
null
)
})
const purchaseLedger = createMemo(() => const purchaseLedger = createMemo(() =>
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'purchase') (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'purchase')
) )
@@ -2024,12 +2040,15 @@ function App() {
locale={locale()} locale={locale()}
dashboard={dashboard()} dashboard={dashboard()}
currentMemberLine={currentMemberLine()} currentMemberLine={currentMemberLine()}
inspectedMember={inspectedBalanceMember()}
selectedMemberId={inspectedBalanceMember()?.memberId ?? ''}
utilityTotalMajor={utilityTotalMajor()} utilityTotalMajor={utilityTotalMajor()}
purchaseTotalMajor={purchaseTotalMajor()} purchaseTotalMajor={purchaseTotalMajor()}
memberBalanceVisuals={memberBalanceVisuals()} memberBalanceVisuals={memberBalanceVisuals()}
purchaseChart={purchaseInvestmentChart()} purchaseChart={purchaseInvestmentChart()}
memberBaseDueMajor={memberBaseDueMajor} memberBaseDueMajor={memberBaseDueMajor}
memberRemainingClass={memberRemainingClass} memberRemainingClass={memberRemainingClass}
onSelectedMemberChange={setSelectedBalanceMemberId}
/> />
) )
case 'ledger': case 'ledger':
@@ -2468,8 +2487,10 @@ function App() {
locale={locale()} locale={locale()}
dashboard={dashboard()} dashboard={dashboard()}
currentMemberLine={currentMemberLine()} currentMemberLine={currentMemberLine()}
utilityTotalMajor={utilityTotalMajor()} onExplainBalance={() => {
purchaseTotalMajor={purchaseTotalMajor()} setSelectedBalanceMemberId(currentMemberLine()?.memberId ?? null)
setActiveNav('balances')
}}
/> />
) )
} }

View File

@@ -42,6 +42,7 @@ export const demoDashboard: MiniAppDashboard = {
{ {
memberId: 'demo-member', memberId: 'demo-member',
displayName: 'Stas', displayName: 'Stas',
predictedUtilityShareMajor: '78.00',
rentShareMajor: '603.75', rentShareMajor: '603.75',
utilityShareMajor: '78.00', utilityShareMajor: '78.00',
purchaseOffsetMajor: '-66.00', purchaseOffsetMajor: '-66.00',
@@ -53,6 +54,7 @@ export const demoDashboard: MiniAppDashboard = {
{ {
memberId: 'member-chorb', memberId: 'member-chorb',
displayName: 'Chorbanaut', displayName: 'Chorbanaut',
predictedUtilityShareMajor: '78.00',
rentShareMajor: '603.75', rentShareMajor: '603.75',
utilityShareMajor: '78.00', utilityShareMajor: '78.00',
purchaseOffsetMajor: '12.00', purchaseOffsetMajor: '12.00',
@@ -64,6 +66,7 @@ export const demoDashboard: MiniAppDashboard = {
{ {
memberId: 'member-el', memberId: 'member-el',
displayName: 'El', displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50', rentShareMajor: '1207.50',
utilityShareMajor: '0.00', utilityShareMajor: '0.00',
purchaseOffsetMajor: '54.00', purchaseOffsetMajor: '54.00',

View File

@@ -63,6 +63,7 @@ export const dictionary = {
payNowBody: '', payNowBody: '',
homeDueTitle: 'Due', homeDueTitle: 'Due',
homeSettledTitle: 'Settled', homeSettledTitle: 'Settled',
whyAction: 'Why?',
currentCycleLabel: 'Current cycle', currentCycleLabel: 'Current cycle',
cycleTotalLabel: 'Cycle total', cycleTotalLabel: 'Cycle total',
cycleBillLabel: 'Cycle bill', cycleBillLabel: 'Cycle bill',
@@ -75,8 +76,12 @@ export const dictionary = {
rentPaidLabel: 'Rent paid', rentPaidLabel: 'Rent paid',
utilitiesPaidLabel: 'Utilities paid', utilitiesPaidLabel: 'Utilities paid',
dueOnLabel: 'Due {date}', dueOnLabel: 'Due {date}',
dueTodayLabel: 'Due today',
overdueLabel: 'Overdue',
daysLeftLabel: '{count}d left',
upcomingLabel: 'Upcoming', upcomingLabel: 'Upcoming',
notBilledYetLabel: 'Not billed yet', notBilledYetLabel: 'Not billed yet',
expectedUtilitiesLabel: 'Expected utilities',
baseDue: 'Base due', baseDue: 'Base due',
finalDue: 'Final due', finalDue: 'Final due',
houseSnapshotTitle: 'House totals', houseSnapshotTitle: 'House totals',
@@ -86,6 +91,9 @@ export const dictionary = {
'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: 'Everyones current split for this cycle.', householdBalancesBody: 'Everyones current split for this cycle.',
inspectMemberTitle: 'Inspect member',
inspectMemberBody: 'Check another member balance without opening a long list.',
inspectMemberLabel: 'Member',
financeVisualsTitle: 'Visual balance split', financeVisualsTitle: 'Visual balance split',
financeVisualsBody: financeVisualsBody:
'Use the bars to see how rent, utilities, and shared-buy adjustments shape each member balance.', 'Use the bars to see how rent, utilities, and shared-buy adjustments shape each member balance.',
@@ -351,6 +359,7 @@ export const dictionary = {
payNowBody: '', payNowBody: '',
homeDueTitle: 'К оплате', homeDueTitle: 'К оплате',
homeSettledTitle: 'Закрыто', homeSettledTitle: 'Закрыто',
whyAction: 'Почему?',
currentCycleLabel: 'Текущий цикл', currentCycleLabel: 'Текущий цикл',
cycleTotalLabel: 'Всего за цикл', cycleTotalLabel: 'Всего за цикл',
cycleBillLabel: 'Счёт за цикл', cycleBillLabel: 'Счёт за цикл',
@@ -363,8 +372,12 @@ export const dictionary = {
rentPaidLabel: 'По аренде оплачено', rentPaidLabel: 'По аренде оплачено',
utilitiesPaidLabel: 'По коммуналке оплачено', utilitiesPaidLabel: 'По коммуналке оплачено',
dueOnLabel: 'Срок {date}', dueOnLabel: 'Срок {date}',
dueTodayLabel: 'Срок сегодня',
overdueLabel: 'Просрочено',
daysLeftLabel: 'Осталось {count} дн.',
upcomingLabel: 'Ещё не срок', upcomingLabel: 'Ещё не срок',
notBilledYetLabel: 'Ещё не начислено', notBilledYetLabel: 'Ещё не начислено',
expectedUtilitiesLabel: 'Ожидаемая коммуналка',
baseDue: 'База к оплате', baseDue: 'База к оплате',
finalDue: 'Итог к оплате', finalDue: 'Итог к оплате',
houseSnapshotTitle: 'Сводка по дому', houseSnapshotTitle: 'Сводка по дому',
@@ -374,6 +387,9 @@ export const dictionary = {
'На этом экране только разбор твоего текущего баланса. Более старые записи остаются в леджере.', 'На этом экране только разбор твоего текущего баланса. Более старые записи остаются в леджере.',
householdBalancesTitle: 'Баланс дома', householdBalancesTitle: 'Баланс дома',
householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.', householdBalancesBody: 'Текущий расклад по всем участникам за этот цикл.',
inspectMemberTitle: 'Посмотреть участника',
inspectMemberBody: 'Можно быстро проверить чужой баланс без длинного списка карточек.',
inspectMemberLabel: 'Участник',
financeVisualsTitle: 'Визуальный разбор баланса', financeVisualsTitle: 'Визуальный разбор баланса',
financeVisualsBody: financeVisualsBody:
'Полосы показывают, как аренда, коммуналка и поправка на общие покупки формируют баланс каждого участника.', 'Полосы показывают, как аренда, коммуналка и поправка на общие покупки формируют баланс каждого участника.',

View File

@@ -461,11 +461,16 @@ button:disabled {
.home-pay-card, .home-pay-card,
.home-pay-card__header, .home-pay-card__header,
.home-pay-card__copy, .home-pay-card__copy,
.home-pay-card__chips { .home-pay-card__chips,
.home-pay-card__actions {
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
.home-pay-card__actions {
justify-items: start;
}
.stat-card { .stat-card {
display: grid; display: grid;
gap: 8px; gap: 8px;
@@ -1186,6 +1191,10 @@ button:disabled {
gap: 16px; gap: 16px;
} }
.balance-section--secondary {
background: linear-gradient(180deg, rgb(255 255 255 / 0.04), rgb(255 255 255 / 0.02));
}
.balance-section__header { .balance-section__header {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1199,6 +1208,10 @@ button:disabled {
gap: 8px; gap: 8px;
} }
.balance-section__field {
min-width: min(220px, 100%);
}
.household-balance-list { .household-balance-list {
display: grid; display: grid;
gap: 12px; gap: 12px;
@@ -1208,6 +1221,22 @@ button:disabled {
margin-top: 12px; margin-top: 12px;
} }
.balance-detail-card,
.balance-detail-card__rows {
display: grid;
gap: 12px;
}
.balance-detail-card__header {
display: grid;
gap: 10px;
}
.balance-detail-card__copy {
display: grid;
gap: 6px;
}
.app-context-row { .app-context-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1248,10 +1277,23 @@ button:disabled {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.home-pay-card__header {
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.balance-spotlight__hero {
grid-column: 1 / -1;
}
.balance-spotlight__header { .balance-spotlight__header {
align-items: start; align-items: start;
} }
.home-pay-card__actions {
justify-items: end;
}
.balance-spotlight__stats { .balance-spotlight__stats {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
} }
@@ -1298,6 +1340,10 @@ button:disabled {
.member-editor-actions__grid { .member-editor-actions__grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.balance-detail-card__rows {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 759px) { @media (max-width: 759px) {

View File

@@ -156,3 +156,17 @@ export function compareTodayToPeriodDay(
return 0 return 0
} }
export function daysUntilPeriodDay(period: string, day: number, timezone: string): number | null {
const parsed = parsePeriod(period)
const today = formatTodayParts(timezone)
if (!parsed || !today) {
return null
}
const safeDay = Math.max(1, Math.min(day, daysInMonth(parsed.year, parsed.month)))
const dueValue = Date.UTC(parsed.year, parsed.month - 1, safeDay)
const todayValue = Date.UTC(today.year, today.month - 1, today.day)
return Math.round((dueValue - todayValue) / 86_400_000)
}

View File

@@ -110,6 +110,7 @@ export interface MiniAppDashboard {
members: { members: {
memberId: string memberId: string
displayName: string displayName: string
predictedUtilityShareMajor: string | null
rentShareMajor: string rentShareMajor: string
utilityShareMajor: string utilityShareMajor: string
purchaseOffsetMajor: string purchaseOffsetMajor: string

View File

@@ -1,8 +1,9 @@
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 { FinanceVisuals } from '../components/finance/finance-visuals' import { FinanceVisuals } from '../components/finance/finance-visuals'
import { MemberBalanceCard } from '../components/finance/member-balance-card' import { MemberBalanceCard } from '../components/finance/member-balance-card'
import { Field } from '../components/ui'
import { formatCyclePeriod } from '../lib/dates' import { formatCyclePeriod } from '../lib/dates'
import type { MiniAppDashboard } from '../miniapp-api' import type { MiniAppDashboard } from '../miniapp-api'
@@ -11,6 +12,8 @@ type Props = {
locale: 'en' | 'ru' locale: 'en' | 'ru'
dashboard: MiniAppDashboard | null dashboard: MiniAppDashboard | null
currentMemberLine: MiniAppDashboard['members'][number] | null currentMemberLine: MiniAppDashboard['members'][number] | null
inspectedMember: MiniAppDashboard['members'][number] | null
selectedMemberId: string
utilityTotalMajor: string utilityTotalMajor: string
purchaseTotalMajor: string purchaseTotalMajor: string
memberBalanceVisuals: { memberBalanceVisuals: {
@@ -39,6 +42,7 @@ type Props = {
} }
memberBaseDueMajor: (member: MiniAppDashboard['members'][number]) => string memberBaseDueMajor: (member: MiniAppDashboard['members'][number]) => string
memberRemainingClass: (member: MiniAppDashboard['members'][number]) => string memberRemainingClass: (member: MiniAppDashboard['members'][number]) => string
onSelectedMemberChange: (memberId: string) => void
} }
export function BalancesScreen(props: Props) { export function BalancesScreen(props: Props) {
@@ -64,13 +68,110 @@ export function BalancesScreen(props: Props) {
/> />
)} )}
</Show> </Show>
<article class="balance-item balance-item--muted">
<header> <section class="balance-item balance-item--wide balance-section balance-section--secondary">
<strong>{props.copy.balanceScreenScopeTitle ?? ''}</strong> <header class="balance-section__header">
<span>{formatCyclePeriod(dashboard().period, props.locale)}</span> <div class="balance-section__copy">
<strong>{props.copy.inspectMemberTitle ?? ''}</strong>
<p>{props.copy.inspectMemberBody ?? ''}</p>
</div>
<Field label={props.copy.inspectMemberLabel ?? ''} class="balance-section__field">
<select
value={props.selectedMemberId}
onChange={(event) => props.onSelectedMemberChange(event.currentTarget.value)}
>
{dashboard().members.map((member) => (
<option value={member.memberId}>{member.displayName}</option>
))}
</select>
</Field>
</header> </header>
<p>{props.copy.balanceScreenScopeBody ?? ''}</p>
</article> <Show when={props.inspectedMember}>
{(member) => (
<article class="balance-detail-card">
<header class="balance-detail-card__header">
<div class="balance-detail-card__copy">
<strong>{member().displayName}</strong>
<small>{formatCyclePeriod(dashboard().period, props.locale)}</small>
</div>
<span class={`balance-status ${props.memberRemainingClass(member())}`}>
{member().remainingMajor} {dashboard().currency}
</span>
</header>
<div class="balance-detail-card__rows">
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.baseDue ?? ''}</span>
<strong>
{props.memberBaseDueMajor(member())} {dashboard().currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.shareRent ?? ''}</span>
<strong>
{member().rentShareMajor} {dashboard().currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.shareUtilities ?? ''}</span>
<strong>
{member().utilityShareMajor} {dashboard().currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.shareOffset ?? ''}</span>
<strong>
{member().purchaseOffsetMajor} {dashboard().currency}
</strong>
</div>
</article>
<article class="balance-detail-row">
<div class="balance-detail-row__main">
<span>{props.copy.paidLabel ?? ''}</span>
<strong>
{member().paidMajor} {dashboard().currency}
</strong>
</div>
</article>
<article class="balance-detail-row balance-detail-row--accent">
<div class="balance-detail-row__main">
<span>{props.copy.remainingLabel ?? ''}</span>
<strong>
{member().remainingMajor} {dashboard().currency}
</strong>
</div>
</article>
</div>
</article>
)}
</Show>
</section>
<FinanceVisuals
dashboard={dashboard()}
memberVisuals={props.memberBalanceVisuals}
purchaseChart={props.purchaseChart}
remainingClass={props.memberRemainingClass}
labels={{
financeVisualsTitle: props.copy.financeVisualsTitle ?? '',
financeVisualsBody: props.copy.financeVisualsBody ?? '',
membersCount: props.copy.membersCount ?? '',
purchaseInvestmentsTitle: props.copy.purchaseInvestmentsTitle ?? '',
purchaseInvestmentsBody: props.copy.purchaseInvestmentsBody ?? '',
purchaseInvestmentsEmpty: props.copy.purchaseInvestmentsEmpty ?? '',
purchaseTotalLabel: props.copy.purchaseTotalLabel ?? '',
purchaseShareLabel: props.copy.purchaseShareLabel ?? ''
}}
/>
<article class="balance-item balance-item--wide balance-item--muted"> <article class="balance-item balance-item--wide balance-item--muted">
<header> <header>
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong> <strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
@@ -91,70 +192,6 @@ export function BalancesScreen(props: Props) {
/> />
</div> </div>
</article> </article>
<FinanceVisuals
dashboard={dashboard()}
memberVisuals={props.memberBalanceVisuals}
purchaseChart={props.purchaseChart}
remainingClass={props.memberRemainingClass}
labels={{
financeVisualsTitle: props.copy.financeVisualsTitle ?? '',
financeVisualsBody: props.copy.financeVisualsBody ?? '',
membersCount: props.copy.membersCount ?? '',
purchaseInvestmentsTitle: props.copy.purchaseInvestmentsTitle ?? '',
purchaseInvestmentsBody: props.copy.purchaseInvestmentsBody ?? '',
purchaseInvestmentsEmpty: props.copy.purchaseInvestmentsEmpty ?? '',
purchaseTotalLabel: props.copy.purchaseTotalLabel ?? '',
purchaseShareLabel: props.copy.purchaseShareLabel ?? ''
}}
/>
<section class="balance-item balance-item--wide balance-section">
<header class="balance-section__header">
<div class="balance-section__copy">
<strong>{props.copy.householdBalancesTitle ?? ''}</strong>
<p>{props.copy.householdBalancesBody ?? ''}</p>
</div>
<span class="mini-chip mini-chip--muted">
{String(dashboard().members.length)} {props.copy.membersCount ?? ''}
</span>
</header>
<div class="household-balance-list">
<For each={dashboard().members}>
{(member) => (
<article class="ledger-compact-card household-balance-list__card">
<div class="ledger-compact-card__main">
<header>
<strong>{member.displayName}</strong>
<span class={`balance-status ${props.memberRemainingClass(member)}`}>
{member.remainingMajor} {dashboard().currency}
</span>
</header>
<div class="ledger-compact-card__meta">
<span class="mini-chip mini-chip--muted">
{props.copy.baseDue ?? ''}: {props.memberBaseDueMajor(member)}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.shareRent ?? ''}: {member.rentShareMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.shareUtilities ?? ''}: {member.utilityShareMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.shareOffset ?? ''}: {member.purchaseOffsetMajor}{' '}
{dashboard().currency}
</span>
<span class="mini-chip mini-chip--muted">
{props.copy.paidLabel ?? ''}: {member.paidMajor} {dashboard().currency}
</span>
</div>
</div>
</article>
)}
</For>
</div>
</section>
</div> </div>
)} )}
</Show> </Show>

View File

@@ -1,7 +1,12 @@
import { Show } from 'solid-js' import { Show } from 'solid-js'
import { FinanceSummaryCards } from '../components/finance/finance-summary-cards' import { Button } from '../components/ui'
import { compareTodayToPeriodDay, formatCyclePeriod, formatPeriodDay } from '../lib/dates' import {
compareTodayToPeriodDay,
daysUntilPeriodDay,
formatCyclePeriod,
formatPeriodDay
} from '../lib/dates'
import { majorStringToMinor, minorToMajorString, sumMajorStrings } from '../lib/money' import { majorStringToMinor, minorToMajorString, sumMajorStrings } from '../lib/money'
import type { MiniAppDashboard } from '../miniapp-api' import type { MiniAppDashboard } from '../miniapp-api'
@@ -10,10 +15,11 @@ type Props = {
locale: 'en' | 'ru' locale: 'en' | 'ru'
dashboard: MiniAppDashboard | null dashboard: MiniAppDashboard | null
currentMemberLine: MiniAppDashboard['members'][number] | null currentMemberLine: MiniAppDashboard['members'][number] | null
utilityTotalMajor: string onExplainBalance: () => void
purchaseTotalMajor: string
} }
type HomeMode = 'upcoming' | 'due' | 'settled'
export function HomeScreen(props: Props) { export function HomeScreen(props: Props) {
const rentPaidMajor = () => { const rentPaidMajor = () => {
if (!props.dashboard || !props.currentMemberLine) { if (!props.dashboard || !props.currentMemberLine) {
@@ -94,6 +100,14 @@ export function HomeScreen(props: Props) {
: props.currentMemberLine.utilityShareMajor : props.currentMemberLine.utilityShareMajor
} }
const predictedUtilitiesMajor = () => {
if (!props.currentMemberLine) {
return null
}
return props.currentMemberLine.predictedUtilityShareMajor
}
const separateBalanceMajor = () => { const separateBalanceMajor = () => {
if ( if (
!props.currentMemberLine || !props.currentMemberLine ||
@@ -105,13 +119,9 @@ export function HomeScreen(props: Props) {
return props.currentMemberLine.purchaseOffsetMajor return props.currentMemberLine.purchaseOffsetMajor
} }
const heroState = () => { const homeMode = (): HomeMode => {
if (!props.dashboard || !props.currentMemberLine) { if (!props.dashboard || !props.currentMemberLine) {
return { return 'upcoming'
title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '',
label: props.copy.remainingLabel ?? '',
amountMajor: '—'
}
} }
const remainingMinor = majorStringToMinor(props.currentMemberLine.remainingMajor) const remainingMinor = majorStringToMinor(props.currentMemberLine.remainingMajor)
@@ -126,6 +136,7 @@ export function HomeScreen(props: Props) {
props.dashboard.utilitiesDueDay, props.dashboard.utilitiesDueDay,
props.dashboard.timezone props.dashboard.timezone
) )
const hasDueNow = const hasDueNow =
(rentStatus !== null && (rentStatus !== null &&
rentStatus >= 0 && rentStatus >= 0 &&
@@ -137,50 +148,71 @@ export function HomeScreen(props: Props) {
majorStringToMinor(separateBalanceMajor() ?? '0.00') > 0n) majorStringToMinor(separateBalanceMajor() ?? '0.00') > 0n)
if (remainingMinor === 0n && paidMinor > 0n) { if (remainingMinor === 0n && paidMinor > 0n) {
return { return 'settled'
title: props.copy.homeSettledTitle ?? '',
label: props.copy.paidThisCycleLabel ?? props.copy.paidLabel ?? '',
amountMajor: props.currentMemberLine.paidMajor
}
} }
if (hasDueNow) { return hasDueNow ? 'due' : 'upcoming'
}
const heroState = () => {
if (!props.dashboard || !props.currentMemberLine) {
return { return {
title: props.copy.homeDueTitle ?? props.copy.payNowTitle ?? '', title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '',
label: props.copy.remainingLabel ?? '', label: props.copy.remainingLabel ?? '',
amountMajor: props.currentMemberLine.remainingMajor amountMajor: '—'
} }
} }
return { switch (homeMode()) {
title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '', case 'settled':
label: props.copy.cycleTotalLabel ?? props.copy.totalDue ?? '', return {
amountMajor: props.currentMemberLine.netDueMajor title: props.copy.homeSettledTitle ?? '',
label: props.copy.paidThisCycleLabel ?? props.copy.paidLabel ?? '',
amountMajor: props.currentMemberLine.paidMajor
}
case 'due':
return {
title: props.copy.homeDueTitle ?? props.copy.payNowTitle ?? '',
label: props.copy.remainingLabel ?? '',
amountMajor: props.currentMemberLine.remainingMajor
}
default:
return {
title: props.copy.payNowTitle ?? props.copy.yourBalanceTitle ?? '',
label: props.copy.cycleTotalLabel ?? props.copy.totalDue ?? '',
amountMajor: props.currentMemberLine.netDueMajor
}
} }
} }
const dueLabel = (kind: 'rent' | 'utilities') => { const dayCountLabel = (daysLeft: number | null) => {
if (daysLeft === null) {
return null
}
if (daysLeft < 0) {
return props.copy.overdueLabel ?? ''
}
if (daysLeft === 0) {
return props.copy.dueTodayLabel ?? ''
}
return (props.copy.daysLeftLabel ?? '').replace('{count}', String(daysLeft))
}
const scheduleLabel = (kind: 'rent' | 'utilities') => {
if (!props.dashboard) { if (!props.dashboard) {
return null return null
} }
const day = kind === 'rent' ? props.dashboard.rentDueDay : props.dashboard.utilitiesDueDay const day = kind === 'rent' ? props.dashboard.rentDueDay : props.dashboard.utilitiesDueDay
const comparison = compareTodayToPeriodDay(
props.dashboard.period,
day,
props.dashboard.timezone
)
const date = formatPeriodDay(props.dashboard.period, day, props.locale) const date = formatPeriodDay(props.dashboard.period, day, props.locale)
const template = const daysLeft = daysUntilPeriodDay(props.dashboard.period, day, props.dashboard.timezone)
comparison !== null && comparison < 0 const dayLabel = dayCountLabel(daysLeft)
? (props.copy.upcomingLabel ?? '') const dueLabel = (props.copy.dueOnLabel ?? '').replace('{date}', date)
: (props.copy.dueOnLabel ?? '').replace('{date}', date)
if (comparison !== null && comparison < 0) { return dayLabel ? `${dueLabel} · ${dayLabel}` : dueLabel
return `${template}${template.length > 0 ? ' ' : ''}${date}`.trim()
}
return template
} }
return ( return (
@@ -192,7 +224,6 @@ export function HomeScreen(props: Props) {
<header class="balance-spotlight__header"> <header class="balance-spotlight__header">
<div class="balance-spotlight__copy"> <div class="balance-spotlight__copy">
<strong>{props.copy.yourBalanceTitle ?? ''}</strong> <strong>{props.copy.yourBalanceTitle ?? ''}</strong>
<p>{props.copy.yourBalanceBody ?? ''}</p>
</div> </div>
<div class="balance-spotlight__hero"> <div class="balance-spotlight__hero">
<span>{props.copy.remainingLabel ?? ''}</span> <span>{props.copy.remainingLabel ?? ''}</span>
@@ -213,6 +244,11 @@ export function HomeScreen(props: Props) {
<strong>{heroState().title}</strong> <strong>{heroState().title}</strong>
<small>{formatCyclePeriod(dashboard().period, props.locale)}</small> <small>{formatCyclePeriod(dashboard().period, props.locale)}</small>
</div> </div>
<div class="home-pay-card__actions">
<Button variant="ghost" onClick={props.onExplainBalance}>
{props.copy.whyAction ?? ''}
</Button>
</div>
<div class="balance-spotlight__hero"> <div class="balance-spotlight__hero">
<span>{heroState().label}</span> <span>{heroState().label}</span>
<strong> <strong>
@@ -221,70 +257,129 @@ export function HomeScreen(props: Props) {
</div> </div>
</header> </header>
<div class="balance-spotlight__stats"> <Show
<article class="stat-card balance-spotlight__stat"> when={homeMode() === 'upcoming'}
<span>{props.copy.paidLabel ?? ''}</span> fallback={
<strong> <div class="balance-spotlight__stats">
{member().paidMajor} {dashboard().currency} <article class="stat-card balance-spotlight__stat">
</strong> <span>{props.copy.paidLabel ?? ''}</span>
</article> <strong>
<article class="stat-card balance-spotlight__stat"> {member().paidMajor} {dashboard().currency}
<span>{props.copy.remainingLabel ?? ''}</span> </strong>
<strong> </article>
{member().remainingMajor} {dashboard().currency} <article class="stat-card balance-spotlight__stat">
</strong> <span>{props.copy.remainingLabel ?? ''}</span>
</article> <strong>
</div> {member().remainingMajor} {dashboard().currency}
</strong>
</article>
</div>
}
>
<div class="balance-spotlight__stats">
<article class="stat-card balance-spotlight__stat">
<span>{props.copy.shareRent ?? ''}</span>
<strong>{scheduleLabel('rent')}</strong>
</article>
<article class="stat-card balance-spotlight__stat">
<span>{props.copy.shareUtilities ?? ''}</span>
<strong>{scheduleLabel('utilities')}</strong>
</article>
</div>
</Show>
<div class="balance-spotlight__rows"> <div class="balance-spotlight__rows">
<article class="balance-detail-row"> <article class="balance-detail-row">
<div class="balance-detail-row__main"> <div class="balance-detail-row__main">
<span> <span>{props.copy.shareRent ?? ''}</span>
{dashboard().paymentBalanceAdjustmentPolicy === 'rent'
? props.copy.rentAdjustedTotalLabel
: props.copy.shareRent}
</span>
<strong> <strong>
{rentDueMajor()} {dashboard().currency} {member().rentShareMajor} {dashboard().currency}
</strong> </strong>
<small>{dueLabel('rent')}</small> <small>{scheduleLabel('rent')}</small>
</div> </div>
<span class="mini-chip mini-chip--muted"> <Show when={homeMode() !== 'upcoming'}>
{props.copy.rentPaidLabel ?? props.copy.paidLabel}: {rentPaidMajor()}{' '} <span class="mini-chip mini-chip--muted">
{dashboard().currency} {props.copy.rentPaidLabel ?? props.copy.paidLabel}: {rentPaidMajor()}{' '}
</span> {dashboard().currency}
</span>
</Show>
</article> </article>
<article class="balance-detail-row"> <article class="balance-detail-row">
<div class="balance-detail-row__main"> <div class="balance-detail-row__main">
<span> <span>
{dashboard().paymentBalanceAdjustmentPolicy === 'utilities' {homeMode() === 'upcoming'
? props.copy.utilitiesAdjustedTotalLabel ? (props.copy.expectedUtilitiesLabel ?? props.copy.shareUtilities)
: (props.copy.utilitiesBalanceLabel ?? props.copy.shareUtilities)} : (props.copy.pureUtilitiesLabel ?? props.copy.shareUtilities)}
</span> </span>
<strong> <strong>
{utilitiesDueMajor() !== null {homeMode() === 'upcoming'
? `${utilitiesDueMajor()} ${dashboard().currency}` ? predictedUtilitiesMajor()
: (props.copy.notBilledYetLabel ?? '')} ? `${predictedUtilitiesMajor()} ${dashboard().currency}`
: (props.copy.notBilledYetLabel ?? '')
: utilitiesDueMajor()
? `${member().utilityShareMajor} ${dashboard().currency}`
: (props.copy.notBilledYetLabel ?? '')}
</strong> </strong>
<small> <small>{scheduleLabel('utilities')}</small>
{utilitiesDueMajor() !== null
? dueLabel('utilities')
: dueLabel('utilities')}
</small>
</div> </div>
<span class="mini-chip mini-chip--muted"> <Show
{props.copy.utilitiesPaidLabel ?? props.copy.paidLabel}:{' '} when={
{utilitiesPaidMajor()} {dashboard().currency} homeMode() !== 'upcoming' || majorStringToMinor(utilitiesPaidMajor()) > 0n
</span> }
>
<span class="mini-chip mini-chip--muted">
{props.copy.utilitiesPaidLabel ?? props.copy.paidLabel}:{' '}
{utilitiesPaidMajor()} {dashboard().currency}
</span>
</Show>
</article> </article>
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'separate'}> <article class="balance-detail-row">
<article class="balance-detail-row"> <div class="balance-detail-row__main">
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset}</span>
<strong>
{member().purchaseOffsetMajor} {dashboard().currency}
</strong>
<small>{props.copy.currentCycleLabel ?? ''}</small>
</div>
</article>
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'rent'}>
<article class="balance-detail-row balance-detail-row--accent">
<div class="balance-detail-row__main"> <div class="balance-detail-row__main">
<span>{props.copy.balanceAdjustmentLabel ?? props.copy.shareOffset}</span> <span>{props.copy.rentAdjustedTotalLabel ?? ''}</span>
<strong> <strong>
{separateBalanceMajor()} {dashboard().currency} {adjustedRentMajor()} {dashboard().currency}
</strong>
</div>
</article>
</Show>
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'utilities'}>
<article class="balance-detail-row balance-detail-row--accent">
<div class="balance-detail-row__main">
<span>{props.copy.utilitiesAdjustedTotalLabel ?? ''}</span>
<strong>
{homeMode() === 'upcoming'
? predictedUtilitiesMajor()
? `${sumMajorStrings(
predictedUtilitiesMajor() ?? '0.00',
member().purchaseOffsetMajor
)} ${dashboard().currency}`
: (props.copy.notBilledYetLabel ?? '')
: `${adjustedUtilitiesMajor()} ${dashboard().currency}`}
</strong>
</div>
</article>
</Show>
<Show when={dashboard().paymentBalanceAdjustmentPolicy === 'separate'}>
<article class="balance-detail-row balance-detail-row--accent">
<div class="balance-detail-row__main">
<span>{props.copy.finalDue ?? props.copy.remainingLabel}</span>
<strong>
{member().remainingMajor} {dashboard().currency}
</strong> </strong>
</div> </div>
</article> </article>
@@ -293,27 +388,6 @@ export function HomeScreen(props: Props) {
</article> </article>
)} )}
</Show> </Show>
<article class="balance-item balance-item--wide balance-item--muted">
<header>
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
<span>{formatCyclePeriod(dashboard().period, props.locale)}</span>
</header>
<p>{props.copy.houseSnapshotBody ?? ''}</p>
<div class="summary-card-grid summary-card-grid--secondary">
<FinanceSummaryCards
dashboard={dashboard()}
utilityTotalMajor={props.utilityTotalMajor}
purchaseTotalMajor={props.purchaseTotalMajor}
labels={{
remaining: props.copy.remainingLabel ?? '',
rent: props.copy.shareRent ?? '',
utilities: props.copy.shareUtilities ?? '',
purchases: props.copy.purchasesTitle ?? ''
}}
/>
</div>
</article>
</div> </div>
)} )}
</Show> </Show>

View File

@@ -184,6 +184,7 @@ function HouseSection(props: {
<button <button
class="admin-disclosure__summary" class="admin-disclosure__summary"
type="button" type="button"
aria-expanded={open()}
onClick={() => setOpen((current) => !current)} onClick={() => setOpen((current) => !current)}
> >
<div class="admin-disclosure__copy"> <div class="admin-disclosure__copy">
@@ -296,12 +297,39 @@ export function HouseScreen(props: Props) {
> >
<section class="admin-section"> <section class="admin-section">
<div class="admin-grid"> <div class="admin-grid">
<article class="balance-item"> <article class="balance-item admin-card--wide">
<header> <header>
<strong>{props.copy.householdNameLabel ?? ''}</strong> <strong>
<span>{props.householdName}</span> {props.copy.householdSettingsTitle ?? props.copy.houseSectionGeneral ?? ''}
</strong>
</header> </header>
<p>{props.copy.householdNameHint ?? ''}</p> <div class="settings-grid">
<div class="settings-field">
<span>{props.copy.householdNameLabel ?? ''}</span>
<div class="settings-field__value">{props.householdName}</div>
</div>
<div class="settings-field">
<span>{props.copy.householdLanguage ?? ''}</span>
<div class="locale-switch__buttons locale-switch__buttons--inline">
<button
classList={{ 'is-active': props.householdDefaultLocale === 'en' }}
type="button"
disabled={props.savingHouseholdLocale}
onClick={() => void props.onChangeHouseholdLocale('en')}
>
EN
</button>
<button
classList={{ 'is-active': props.householdDefaultLocale === 'ru' }}
type="button"
disabled={props.savingHouseholdLocale}
onClick={() => void props.onChangeHouseholdLocale('ru')}
>
RU
</button>
</div>
</div>
</div>
<div class="panel-toolbar"> <div class="panel-toolbar">
<Button variant="secondary" onClick={props.onOpenBillingSettingsModal}> <Button variant="secondary" onClick={props.onOpenBillingSettingsModal}>
<SettingsIcon /> <SettingsIcon />
@@ -310,31 +338,6 @@ export function HouseScreen(props: Props) {
</div> </div>
</article> </article>
<article class="balance-item">
<header>
<strong>{props.copy.householdLanguage ?? ''}</strong>
<span>{props.householdDefaultLocale.toUpperCase()}</span>
</header>
<div class="locale-switch__buttons locale-switch__buttons--inline">
<button
classList={{ 'is-active': props.householdDefaultLocale === 'en' }}
type="button"
disabled={props.savingHouseholdLocale}
onClick={() => void props.onChangeHouseholdLocale('en')}
>
EN
</button>
<button
classList={{ 'is-active': props.householdDefaultLocale === 'ru' }}
type="button"
disabled={props.savingHouseholdLocale}
onClick={() => void props.onChangeHouseholdLocale('ru')}
>
RU
</button>
</div>
</article>
<article class="balance-item"> <article class="balance-item">
<header> <header>
<strong>{props.copy.manageProfileAction ?? ''}</strong> <strong>{props.copy.manageProfileAction ?? ''}</strong>

View File

@@ -106,6 +106,7 @@ export interface FinanceDashboardMemberLine {
status?: 'active' | 'away' | 'left' status?: 'active' | 'away' | 'left'
absencePolicy?: HouseholdMemberAbsencePolicy absencePolicy?: HouseholdMemberAbsencePolicy
absencePolicyEffectiveFromPeriod?: string | null absencePolicyEffectiveFromPeriod?: string | null
predictedUtilityShare?: Money | null
rentShare: Money rentShare: Money
utilityShare: Money utilityShare: Money
purchaseOffset: Money purchaseOffset: Money
@@ -321,6 +322,16 @@ async function buildFinanceDashboard(
dependencies.repository.listUtilityBillsForCycle(cycle.id) dependencies.repository.listUtilityBillsForCycle(cycle.id)
]) ])
const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id) const paymentRecords = await dependencies.repository.listPaymentRecordsForCycle(cycle.id)
const previousCycle = await dependencies.repository.getCycleByPeriod(period.previous().toString())
const previousSnapshotLines = previousCycle
? await dependencies.repository.getSettlementSnapshotLines(previousCycle.id)
: []
const previousUtilityShareByMemberId = new Map(
previousSnapshotLines.map((line) => [
line.memberId,
Money.fromMinor(line.utilityShareMinor, cycle.currency)
])
)
const convertedRent = await convertIntoCycleCurrency(dependencies, { const convertedRent = await convertIntoCycleCurrency(dependencies, {
cycle, cycle,
@@ -476,6 +487,7 @@ async function buildFinanceDashboard(
absencePolicy: resolvedAbsencePolicies.get(line.memberId.toString())?.policy ?? 'resident', absencePolicy: resolvedAbsencePolicies.get(line.memberId.toString())?.policy ?? 'resident',
absencePolicyEffectiveFromPeriod: absencePolicyEffectiveFromPeriod:
resolvedAbsencePolicies.get(line.memberId.toString())?.effectiveFromPeriod ?? null, resolvedAbsencePolicies.get(line.memberId.toString())?.effectiveFromPeriod ?? null,
predictedUtilityShare: previousUtilityShareByMemberId.get(line.memberId.toString()) ?? null,
rentShare: line.rentShare, rentShare: line.rentShare,
utilityShare: line.utilityShare, utilityShare: line.utilityShare,
purchaseOffset: line.purchaseOffset, purchaseOffset: line.purchaseOffset,