fix(miniapp): refresh billing state and clean up controls

This commit is contained in:
2026-03-12 04:27:46 +04:00
parent 9afa9fc845
commit 6e49cd1dfd
9 changed files with 166 additions and 30 deletions

View File

@@ -431,6 +431,7 @@ function App() {
const editingPaymentEntry = createMemo(
() => paymentLedger().find((entry) => entry.id === editingPaymentId()) ?? null
)
const defaultPaymentMemberId = createMemo(() => adminSettings()?.members[0]?.id ?? '')
const editingUtilityBill = createMemo(
() => cycleState()?.utilityBills.find((bill) => bill.id === editingUtilityBillId()) ?? null
)
@@ -777,7 +778,10 @@ function App() {
})
setPaymentForm((current) => ({
...current,
memberId: current.memberId || payload.members[0]?.id || '',
memberId:
(current.memberId && payload.members.some((member) => member.id === current.memberId)
? current.memberId
: payload.members[0]?.id) ?? '',
currency: payload.settings.settlementCurrency
}))
} catch (error) {
@@ -1214,6 +1218,7 @@ function App() {
rentCurrency: settings.rentCurrency,
utilityCurrency: settings.settlementCurrency
}))
await refreshHouseholdData(initData, true, true)
setBillingSettingsOpen(false)
} finally {
setSavingBillingSettings(false)
@@ -1241,6 +1246,7 @@ function App() {
period: state.cycle?.period ?? current.period,
utilityCurrency: billingForm().settlementCurrency
}))
await refreshHouseholdData(initData, true, true)
setCycleRentOpen(false)
} finally {
setOpeningCycle(false)
@@ -1268,6 +1274,7 @@ function App() {
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
await refreshHouseholdData(initData, true, true)
setCycleRentOpen(false)
} finally {
setSavingCycleRent(false)
@@ -1304,6 +1311,7 @@ function App() {
...current,
utilityAmountMajor: ''
}))
await refreshHouseholdData(initData, true, true)
setAddingUtilityBillOpen(false)
} finally {
setSavingUtilityBill(false)
@@ -1337,6 +1345,7 @@ function App() {
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
await refreshHouseholdData(initData, true, true)
setEditingUtilityBillId(null)
} finally {
setSavingUtilityBillId(null)
@@ -1356,6 +1365,7 @@ function App() {
const state = await deleteMiniAppUtilityBill(initData, billId)
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
await refreshHouseholdData(initData, true, true)
setEditingUtilityBillId((current) => (current === billId ? null : current))
} finally {
setDeletingUtilityBillId(null)
@@ -1437,11 +1447,12 @@ function App() {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const draft = paymentForm()
const memberId = draft.memberId.trim() || defaultPaymentMemberId()
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
draft.memberId.trim().length === 0 ||
memberId.length === 0 ||
draft.amountMajor.trim().length === 0
) {
return
@@ -1450,9 +1461,13 @@ function App() {
setAddingPayment(true)
try {
await addMiniAppPayment(initData, draft)
await addMiniAppPayment(initData, {
...draft,
memberId
})
setPaymentForm((current) => ({
...current,
memberId,
amountMajor: ''
}))
await refreshHouseholdData(initData, true, true)
@@ -1582,7 +1597,11 @@ function App() {
}
}
async function handleSaveRentWeight(memberId: string, closeEditor = true) {
async function handleSaveRentWeight(
memberId: string,
closeEditor = true,
refreshAfterSave = true
) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextWeight = Number(rentWeightDrafts()[memberId] ?? '')
@@ -1612,6 +1631,9 @@ function App() {
...current,
[member.id]: String(member.rentShareWeight)
}))
if (refreshAfterSave) {
await refreshHouseholdData(initData, true, true)
}
if (closeEditor) {
setEditingMemberId(null)
}
@@ -1620,7 +1642,11 @@ function App() {
}
}
async function handleSaveMemberStatus(memberId: string, closeEditor = true) {
async function handleSaveMemberStatus(
memberId: string,
closeEditor = true,
refreshAfterSave = true
) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextStatus = memberStatusDrafts()[memberId]
@@ -1651,6 +1677,9 @@ function App() {
resolvedMemberAbsencePolicy(member.id, member.status).policy ??
defaultAbsencePolicyForStatus(member.status)
}))
if (refreshAfterSave) {
await refreshHouseholdData(initData, true, true)
}
if (closeEditor) {
setEditingMemberId(null)
}
@@ -1659,7 +1688,11 @@ function App() {
}
}
async function handleSaveMemberAbsencePolicy(memberId: string, closeEditor = true) {
async function handleSaveMemberAbsencePolicy(
memberId: string,
closeEditor = true,
refreshAfterSave = true
) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const member = adminSettings()?.members.find((entry) => entry.id === memberId)
@@ -1702,6 +1735,9 @@ function App() {
...current,
[memberId]: savedPolicy.policy
}))
if (refreshAfterSave) {
await refreshHouseholdData(initData, true, true)
}
if (closeEditor) {
setEditingMemberId(null)
}
@@ -1736,6 +1772,7 @@ function App() {
const hasNameChange = nextDisplayName !== member.displayName
const hasStatusChange = nextStatus !== member.status
const hasWeightChange = nextWeight !== member.rentShareWeight
const requiresDashboardRefresh = hasStatusChange || wantsAwayPolicySave || hasWeightChange
if (!hasNameChange && !hasStatusChange && !wantsAwayPolicySave && !hasWeightChange) {
return
@@ -1749,15 +1786,22 @@ function App() {
}
if (hasStatusChange) {
await handleSaveMemberStatus(memberId, false)
await handleSaveMemberStatus(memberId, false, false)
}
if (wantsAwayPolicySave) {
await handleSaveMemberAbsencePolicy(memberId, false)
await handleSaveMemberAbsencePolicy(memberId, false, false)
}
if (hasWeightChange) {
await handleSaveRentWeight(memberId, false)
await handleSaveRentWeight(memberId, false, false)
}
if (requiresDashboardRefresh) {
const initData = webApp?.initData?.trim()
if (initData) {
await refreshHouseholdData(initData, true, true)
}
}
setEditingMemberId(null)
@@ -1800,6 +1844,7 @@ function App() {
return (
<BalancesScreen
copy={copy()}
locale={locale()}
dashboard={dashboard()}
currentMemberLine={currentMemberLine()}
/>
@@ -1808,6 +1853,7 @@ function App() {
return (
<LedgerScreen
copy={copy()}
locale={locale()}
dashboard={dashboard()}
readyIsAdmin={effectiveIsAdmin()}
adminMembers={adminSettings()?.members ?? []}
@@ -1875,7 +1921,14 @@ function App() {
)
}))
}
onOpenAddPayment={() => setAddingPaymentOpen(true)}
onOpenAddPayment={() => {
setPaymentForm((current) => ({
...current,
memberId: current.memberId.trim() || defaultPaymentMemberId(),
currency: adminSettings()?.settings.settlementCurrency ?? current.currency
}))
setAddingPaymentOpen(true)
}}
onCloseAddPayment={() => setAddingPaymentOpen(false)}
onAddPayment={handleAddPayment}
onPaymentFormMemberChange={(value) =>
@@ -1936,6 +1989,7 @@ function App() {
return (
<HouseScreen
copy={copy()}
locale={locale()}
readyIsAdmin={effectiveIsAdmin()}
householdDefaultLocale={readySession()?.member.householdDefaultLocale ?? 'en'}
dashboard={dashboard()}
@@ -2219,6 +2273,7 @@ function App() {
return (
<HomeScreen
copy={copy()}
locale={locale()}
dashboard={dashboard()}
currentMemberLine={currentMemberLine()}
utilityTotalMajor={utilityTotalMajor()}

View File

@@ -1,12 +1,14 @@
import { Show } from 'solid-js'
import { cn } from '../../lib/cn'
import { formatFriendlyDate } from '../../lib/dates'
import { majorStringToMinor, sumMajorStrings } from '../../lib/money'
import type { MiniAppDashboard } from '../../miniapp-api'
import { MiniChip, StatCard } from '../ui'
type Props = {
copy: Record<string, string | undefined>
locale: 'en' | 'ru'
dashboard: MiniAppDashboard
member: MiniAppDashboard['members'][number]
detail?: boolean
@@ -123,7 +125,8 @@ export function MemberBalanceCard(props: Props) {
<Show when={props.dashboard.rentFxEffectiveDate}>
{(date) => (
<MiniChip muted>
{props.copy.fxEffectiveDateLabel ?? ''}: {date()}
{props.copy.fxEffectiveDateLabel ?? ''}:{' '}
{formatFriendlyDate(date(), props.locale)}
</MiniChip>
)}
</Show>

View File

@@ -124,3 +124,15 @@ export function XIcon(props: IconProps) {
</svg>
)
}
export function TrashIcon(props: IconProps) {
return (
<svg {...iconProps(props)}>
<path d="M4 7h16" />
<path d="M10 11v6" />
<path d="M14 11v6" />
<path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" />
<path d="M9 4h6l1 3H8l1-3Z" />
</svg>
)
}

View File

@@ -8,7 +8,7 @@ export const dictionary = {
loadingBody: 'Validating Telegram session and membership…',
loadingBadge: 'Secure session',
demoBadge: 'Demo mode',
liveBadge: 'Live household',
liveBadge: 'Connected home',
joinTitle: 'Welcome to your household',
joinBody:
'You are not a member of {household} yet. Send a join request and wait for admin approval.',
@@ -134,11 +134,11 @@ export const dictionary = {
paymentAmount: 'Payment amount',
paymentMember: 'Member',
paymentSaveAction: 'Save payment',
paymentDeleteAction: 'Delete payment',
paymentDeleteAction: 'Delete',
paymentEditorBody: 'Review the payment record in one focused editor.',
deletingPayment: 'Deleting payment…',
purchaseSaveAction: 'Save purchase',
purchaseDeleteAction: 'Delete purchase',
purchaseDeleteAction: 'Delete',
deletingPurchase: 'Deleting purchase…',
savingPurchase: 'Saving purchase…',
editEntryAction: 'Edit entry',
@@ -189,7 +189,7 @@ export const dictionary = {
addUtilityBillAction: 'Add utility bill',
savingUtilityBill: 'Saving utility bill…',
saveUtilityBillAction: 'Save utility bill',
deleteUtilityBillAction: 'Delete utility bill',
deleteUtilityBillAction: 'Delete',
deletingUtilityBill: 'Deleting utility bill…',
utilityBillsEmpty: 'No utility bills recorded for this cycle yet.',
rentAmount: 'Rent amount',
@@ -270,7 +270,7 @@ export const dictionary = {
loadingBody: 'Проверяем Telegram-сессию и членство…',
loadingBadge: 'Защищённая сессия',
demoBadge: 'Демо режим',
liveBadge: 'Живой household',
liveBadge: 'Подключённый дом',
joinTitle: 'Добро пожаловать домой',
joinBody:
'Ты пока не участник {household}. Отправь заявку на вступление и дождись подтверждения админа.',
@@ -397,11 +397,11 @@ export const dictionary = {
paymentAmount: 'Сумма оплаты',
paymentMember: 'Участник',
paymentSaveAction: 'Сохранить оплату',
paymentDeleteAction: 'Удалить оплату',
paymentDeleteAction: 'Удалить',
paymentEditorBody: 'Проверь оплату в отдельном редакторе.',
deletingPayment: 'Удаляем оплату…',
purchaseSaveAction: 'Сохранить покупку',
purchaseDeleteAction: 'Удалить покупку',
purchaseDeleteAction: 'Удалить',
deletingPurchase: 'Удаляем покупку…',
savingPurchase: 'Сохраняем покупку…',
editEntryAction: 'Редактировать запись',
@@ -450,7 +450,7 @@ export const dictionary = {
addUtilityBillAction: 'Добавить коммунальный счёт',
savingUtilityBill: 'Сохраняем счёт…',
saveUtilityBillAction: 'Сохранить счёт',
deleteUtilityBillAction: 'Удалить счёт',
deleteUtilityBillAction: 'Удалить',
deletingUtilityBill: 'Удаляем счёт…',
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
rentAmount: 'Сумма аренды',

View File

@@ -0,0 +1,38 @@
import type { Locale } from '../i18n'
function localeTag(locale: Locale): string {
return locale === 'ru' ? 'ru-RU' : 'en-US'
}
export function formatFriendlyDate(value: string, locale: Locale): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return value
}
const includeYear = date.getUTCFullYear() !== new Date().getUTCFullYear()
return new Intl.DateTimeFormat(localeTag(locale), {
month: 'long',
day: 'numeric',
...(includeYear ? { year: 'numeric' } : {})
}).format(date)
}
export function formatCyclePeriod(period: string, locale: Locale): string {
const [yearValue, monthValue] = period.split('-')
const year = Number.parseInt(yearValue ?? '', 10)
const month = Number.parseInt(monthValue ?? '', 10)
if (!Number.isInteger(year) || !Number.isInteger(month) || month < 1 || month > 12) {
return period
}
const date = new Date(Date.UTC(year, month - 1, 1))
const includeYear = year !== new Date().getUTCFullYear()
return new Intl.DateTimeFormat(localeTag(locale), {
month: 'long',
...(includeYear ? { year: 'numeric' } : {})
}).format(date)
}

View File

@@ -1,10 +1,12 @@
import { Show } from 'solid-js'
import { MemberBalanceCard } from '../components/finance/member-balance-card'
import { formatCyclePeriod } from '../lib/dates'
import type { MiniAppDashboard } from '../miniapp-api'
type Props = {
copy: Record<string, string | undefined>
locale: 'en' | 'ru'
dashboard: MiniAppDashboard | null
currentMemberLine: MiniAppDashboard['members'][number] | null
}
@@ -25,6 +27,7 @@ export function BalancesScreen(props: Props) {
{(member) => (
<MemberBalanceCard
copy={props.copy}
locale={props.locale}
dashboard={dashboard()}
member={member()}
detail
@@ -34,7 +37,7 @@ export function BalancesScreen(props: Props) {
<article class="balance-item balance-item--muted">
<header>
<strong>{props.copy.balanceScreenScopeTitle ?? ''}</strong>
<span>{dashboard().period}</span>
<span>{formatCyclePeriod(dashboard().period, props.locale)}</span>
</header>
<p>{props.copy.balanceScreenScopeBody ?? ''}</p>
</article>

View File

@@ -1,11 +1,13 @@
import { Show } from 'solid-js'
import { FinanceSummaryCards } from '../components/finance/finance-summary-cards'
import { formatCyclePeriod } from '../lib/dates'
import { sumMajorStrings } from '../lib/money'
import type { MiniAppDashboard } from '../miniapp-api'
type Props = {
copy: Record<string, string | undefined>
locale: 'en' | 'ru'
dashboard: MiniAppDashboard | null
currentMemberLine: MiniAppDashboard['members'][number] | null
utilityTotalMajor: string
@@ -85,7 +87,7 @@ export function HomeScreen(props: Props) {
</article>
<article class="stat-card balance-spotlight__stat">
<span>{props.copy.currentCycleLabel ?? ''}</span>
<strong>{dashboard().period}</strong>
<strong>{formatCyclePeriod(dashboard().period, props.locale)}</strong>
</article>
</div>
@@ -122,7 +124,7 @@ export function HomeScreen(props: Props) {
<article class="balance-item balance-item--wide balance-item--muted">
<header>
<strong>{props.copy.houseSnapshotTitle ?? ''}</strong>
<span>{dashboard().period}</span>
<span>{formatCyclePeriod(dashboard().period, props.locale)}</span>
</header>
<p>{props.copy.houseSnapshotBody ?? ''}</p>
<div class="summary-card-grid summary-card-grid--secondary">

View File

@@ -9,8 +9,10 @@ import {
Modal,
PencilIcon,
PlusIcon,
SettingsIcon
SettingsIcon,
TrashIcon
} from '../components/ui'
import { formatCyclePeriod, formatFriendlyDate } from '../lib/dates'
import type {
MiniAppAdminCycleState,
MiniAppAdminSettingsPayload,
@@ -50,6 +52,7 @@ type CycleForm = {
type Props = {
copy: Record<string, string | undefined>
locale: 'en' | 'ru'
readyIsAdmin: boolean
householdDefaultLocale: 'en' | 'ru'
dashboard: MiniAppDashboard | null
@@ -226,7 +229,9 @@ export function HouseScreen(props: Props) {
<header>
<strong>{props.copy.billingCycleTitle ?? ''}</strong>
<span>
{props.cycleState?.cycle?.period ?? props.copy.billingCycleEmpty ?? ''}
{props.cycleState?.cycle?.period
? formatCyclePeriod(props.cycleState.cycle.period, props.locale)
: (props.copy.billingCycleEmpty ?? '')}
</span>
</header>
<p>
@@ -565,7 +570,7 @@ export function HouseScreen(props: Props) {
<div class="ledger-compact-card__main">
<header>
<strong>{bill.billName}</strong>
<span>{bill.createdAt.slice(0, 10)}</span>
<span>{formatFriendlyDate(bill.createdAt, props.locale)}</span>
</header>
<p>{props.copy.utilityCategoryName ?? ''}</p>
<div class="ledger-compact-card__meta">
@@ -714,6 +719,7 @@ export function HouseScreen(props: Props) {
variant="danger"
onClick={() => void props.onDeleteUtilityBill(bill.id)}
>
<TrashIcon />
{props.deletingUtilityBillId === bill.id
? props.copy.deletingUtilityBill
: props.copy.deleteUtilityBillAction}
@@ -1085,7 +1091,7 @@ export function HouseScreen(props: Props) {
resolvedPolicy.effectiveFromPeriod
? (props.copy.absencePolicyEffectiveFrom ?? '').replace(
'{period}',
resolvedPolicy.effectiveFromPeriod
formatCyclePeriod(resolvedPolicy.effectiveFromPeriod, props.locale)
)
: (props.copy.absencePolicyHint ?? '')
}

View File

@@ -1,6 +1,7 @@
import { For, Show } from 'solid-js'
import { Button, Field, IconButton, Modal, PencilIcon } from '../components/ui'
import { Button, Field, IconButton, Modal, PencilIcon, PlusIcon, TrashIcon } from '../components/ui'
import { formatFriendlyDate } from '../lib/dates'
import type { MiniAppAdminSettingsPayload, MiniAppDashboard } from '../miniapp-api'
type PurchaseDraft = {
@@ -23,6 +24,7 @@ type PaymentDraft = {
type Props = {
copy: Record<string, string | undefined>
locale: 'en' | 'ru'
dashboard: MiniAppDashboard | null
readyIsAdmin: boolean
adminMembers: readonly MiniAppAdminSettingsPayload['members'][number][]
@@ -147,7 +149,11 @@ export function LedgerScreen(props: Props) {
<div class="ledger-compact-card__main">
<header>
<strong>{entry.title}</strong>
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
<span>
{entry.occurredAt
? formatFriendlyDate(entry.occurredAt, props.locale)
: '—'}
</span>
</header>
<p>{entry.actorDisplayName ?? props.copy.ledgerActorFallback ?? ''}</p>
<div class="ledger-compact-card__meta">
@@ -194,6 +200,7 @@ export function LedgerScreen(props: Props) {
return (
<div class="modal-action-row">
<Button variant="danger" onClick={() => void props.onDeletePurchase(entry.id)}>
<TrashIcon />
{props.deletingPurchaseId === entry.id
? props.copy.deletingPurchase
: props.copy.purchaseDeleteAction}
@@ -394,6 +401,7 @@ export function LedgerScreen(props: Props) {
<p>{props.copy.paymentsAdminBody ?? ''}</p>
<div class="panel-toolbar">
<Button variant="secondary" onClick={props.onOpenAddPayment}>
<PlusIcon />
{props.copy.paymentsAddAction ?? ''}
</Button>
</div>
@@ -408,7 +416,11 @@ export function LedgerScreen(props: Props) {
<div class="ledger-compact-card__main">
<header>
<strong>{props.paymentMemberName(entry)}</strong>
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
<span>
{entry.occurredAt
? formatFriendlyDate(entry.occurredAt, props.locale)
: '—'}
</span>
</header>
<p>{props.ledgerTitle(entry)}</p>
<div class="ledger-compact-card__meta">
@@ -449,7 +461,11 @@ export function LedgerScreen(props: Props) {
</Button>
<Button
variant="primary"
disabled={props.addingPayment || props.paymentForm.amountMajor.trim().length === 0}
disabled={
props.addingPayment ||
props.paymentForm.memberId.trim().length === 0 ||
props.paymentForm.amountMajor.trim().length === 0
}
onClick={() => void props.onAddPayment()}
>
{props.addingPayment ? props.copy.addingPayment : props.copy.paymentsAddAction}
@@ -514,6 +530,7 @@ export function LedgerScreen(props: Props) {
return (
<div class="modal-action-row">
<Button variant="danger" onClick={() => void props.onDeletePayment(entry.id)}>
<TrashIcon />
{props.deletingPaymentId === entry.id
? props.copy.deletingPayment
: props.copy.paymentDeleteAction}