mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(miniapp): redesign member balance surfaces
This commit is contained in:
@@ -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<SessionState, { status: 'ready' }> = {
|
||||
@@ -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<number[]>([])
|
||||
const [testingRolePreview, setTestingRolePreview] = createSignal<TestingRolePreview | null>(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() {
|
||||
<LedgerScreen
|
||||
copy={copy()}
|
||||
dashboard={dashboard()}
|
||||
readyIsAdmin={readySession()?.member.isAdmin === true}
|
||||
readyIsAdmin={effectiveIsAdmin()}
|
||||
adminMembers={adminSettings()?.members ?? []}
|
||||
purchaseEntries={purchaseLedger()}
|
||||
utilityEntries={utilityLedger()}
|
||||
@@ -2075,7 +2115,7 @@ function App() {
|
||||
return (
|
||||
<HouseScreen
|
||||
copy={copy()}
|
||||
readyIsAdmin={readySession()?.member.isAdmin === true}
|
||||
readyIsAdmin={effectiveIsAdmin()}
|
||||
householdDefaultLocale={readySession()?.member.householdDefaultLocale ?? 'en'}
|
||||
dashboard={dashboard()}
|
||||
adminSettings={adminSettings()}
|
||||
@@ -2343,15 +2383,9 @@ function App() {
|
||||
<HomeScreen
|
||||
copy={copy()}
|
||||
dashboard={dashboard()}
|
||||
readyIsAdmin={Boolean(readySession()?.member.isAdmin)}
|
||||
pendingMembersCount={pendingMembers().length}
|
||||
currentMemberLine={currentMemberLine()}
|
||||
utilityTotalMajor={utilityTotalMajor()}
|
||||
purchaseTotalMajor={purchaseTotalMajor()}
|
||||
memberBaseDueMajor={memberBaseDueMajor}
|
||||
ledgerTitle={ledgerTitle}
|
||||
ledgerPrimaryAmount={ledgerPrimaryAmount}
|
||||
ledgerSecondaryAmount={ledgerSecondaryAmount}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -2439,14 +2473,31 @@ function App() {
|
||||
<MiniChip>
|
||||
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
|
||||
</MiniChip>
|
||||
<MiniChip muted>
|
||||
{readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
|
||||
</MiniChip>
|
||||
<Show
|
||||
when={readySession()?.member.isAdmin}
|
||||
fallback={
|
||||
<MiniChip muted>
|
||||
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
|
||||
</MiniChip>
|
||||
}
|
||||
>
|
||||
<button
|
||||
class="mini-chip mini-chip--muted mini-chip-button"
|
||||
onClick={handleRoleChipTap}
|
||||
>
|
||||
{effectiveIsAdmin() ? copy().adminTag : copy().residentTag}
|
||||
</button>
|
||||
</Show>
|
||||
<MiniChip muted>
|
||||
{readySession()?.member.status
|
||||
? memberStatusLabel(readySession()!.member.status)
|
||||
: copy().memberStatusActive}
|
||||
</MiniChip>
|
||||
<Show when={testingRolePreview()}>
|
||||
{(preview) => (
|
||||
<MiniChip>{`${copy().testingViewBadge ?? ''}: ${preview() === 'admin' ? copy().adminTag : copy().residentTag}`}</MiniChip>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={readySession()?.mode === 'live'}>
|
||||
<Button
|
||||
@@ -2473,6 +2524,50 @@ function App() {
|
||||
/>
|
||||
|
||||
<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
|
||||
open={profileEditorOpen()}
|
||||
title={copy().displayNameLabel}
|
||||
|
||||
182
apps/miniapp/src/components/finance/member-balance-card.tsx
Normal file
182
apps/miniapp/src/components/finance/member-balance-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export const demoTelegramUser: NonNullable<MiniAppSession['telegramUser']> = {
|
||||
export const demoDashboard: MiniAppDashboard = {
|
||||
period: '2026-03',
|
||||
currency: 'GEL',
|
||||
paymentBalanceAdjustmentPolicy: 'utilities',
|
||||
totalDueMajor: '2410.00',
|
||||
totalPaidMajor: '650.00',
|
||||
totalRemainingMajor: '1760.00',
|
||||
|
||||
@@ -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:
|
||||
'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
<div class="balance-list">
|
||||
<Show when={props.currentMemberLine}>
|
||||
{(member) => (
|
||||
<article class="balance-item balance-item--accent">
|
||||
<header>
|
||||
<strong>{props.copy.yourBalanceTitle ?? ''}</strong>
|
||||
<span>
|
||||
{member().netDueMajor} {dashboard().currency}
|
||||
</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>
|
||||
<MemberBalanceCard
|
||||
copy={props.copy}
|
||||
dashboard={dashboard()}
|
||||
member={member()}
|
||||
detail
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<div class="summary-card-grid">
|
||||
<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 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
|
||||
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>
|
||||
<FinanceVisuals
|
||||
dashboard={dashboard()}
|
||||
memberVisuals={props.memberBalanceVisuals}
|
||||
@@ -124,9 +97,10 @@ export function BalancesScreen(props: Props) {
|
||||
purchaseShareLabel: props.copy.purchaseShareLabel ?? ''
|
||||
}}
|
||||
/>
|
||||
<article class="balance-item">
|
||||
<article class="balance-item balance-item--wide">
|
||||
<header>
|
||||
<strong>{props.copy.householdBalancesTitle ?? ''}</strong>
|
||||
<span>{String(dashboard().members.length)}</span>
|
||||
</header>
|
||||
<p>{props.copy.householdBalancesBody ?? ''}</p>
|
||||
</article>
|
||||
|
||||
@@ -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<string, string | undefined>
|
||||
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={
|
||||
<div class="home-grid">
|
||||
<div class="summary-card-grid">
|
||||
<article class="stat-card">
|
||||
<span>{props.copy.remainingLabel ?? ''}</span>
|
||||
<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>
|
||||
<article class="balance-item balance-item--accent balance-spotlight">
|
||||
<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>—</strong>
|
||||
</div>
|
||||
</header>
|
||||
</article>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{(dashboard) => (
|
||||
<div class="home-grid">
|
||||
<div class="summary-card-grid">
|
||||
<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 ?? ''
|
||||
}}
|
||||
/>
|
||||
<Show when={props.readyIsAdmin}>
|
||||
<article class="stat-card">
|
||||
<span>{props.copy.pendingRequests ?? ''}</span>
|
||||
<strong>{String(props.pendingMembersCount)}</strong>
|
||||
</article>
|
||||
</Show>
|
||||
</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>
|
||||
<MemberBalanceCard copy={props.copy} dashboard={dashboard()} member={member()} />
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<article class="balance-item balance-item--wide">
|
||||
<article class="balance-item balance-item--wide balance-item--muted">
|
||||
<header>
|
||||
<strong>{props.copy.latestActivityTitle ?? ''}</strong>
|
||||
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
|
||||
<span>{dashboard().period}</span>
|
||||
</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>
|
||||
)}
|
||||
<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>
|
||||
)}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user