feat(miniapp): improve billing settings controls

This commit is contained in:
2026-03-12 11:37:57 +04:00
parent 4c19ee798d
commit a50b826b40
7 changed files with 358 additions and 25 deletions

View File

@@ -180,14 +180,14 @@ export const dictionary = {
billingCycleStatus: 'Current cycle currency: {currency}',
billingCycleOpenHint: 'Open a cycle before entering rent and utility bills.',
billingCyclePeriod: 'Cycle period',
manageCycleAction: 'Manage cycle',
manageCycleAction: 'Edit cycle rent',
cycleEditorBody:
'Keep the billing cycle controls in one focused editor instead of a long page.',
'Change the rent for the current cycle only. Keep billing settings for the long-term default.',
openCycleAction: 'Open cycle',
openingCycle: 'Opening cycle…',
closeCycleAction: 'Close cycle',
closingCycle: 'Closing cycle…',
saveCycleRentAction: 'Save current cycle rent',
saveCycleRentAction: 'Save cycle rent',
savingCycleRent: 'Saving rent…',
utilityCategoryLabel: 'Utility category',
utilityAmount: 'Utility amount',
@@ -197,14 +197,27 @@ export const dictionary = {
deleteUtilityBillAction: 'Delete',
deletingUtilityBill: 'Deleting utility bill…',
utilityBillsEmpty: 'No utility bills recorded for this cycle yet.',
currencyLabel: 'Currency',
rentAmount: 'Rent amount',
defaultRentAmount: 'Default rent',
defaultRentHint:
'New current cycles start from this rent unless you override a specific month.',
currentCycleRentLabel: 'Current cycle rent',
currentCycleRentHint:
'Only use this when the current month needs a different rent from the default.',
currentCycleRentEmpty: 'No rent saved for this cycle yet.',
currentCycleUsesDefaultRent: 'Using the default rent',
currentCycleOverrideRent: 'Cycle override active',
rentDueDay: 'Rent due day',
rentWarningDay: 'Rent warning day',
utilitiesDueDay: 'Utilities due day',
utilitiesReminderDay: 'Utilities reminder day',
timezone: 'Timezone',
timezoneHint: 'Use an IANA timezone like Asia/Tbilisi.',
timezoneInvalidHint: 'Pick a valid IANA timezone such as Asia/Tbilisi.',
manageSettingsAction: 'Manage settings',
billingSettingsEditorBody: 'Household billing defaults live here when you need to change them.',
billingSettingsEditorBody:
'Household defaults live here. New current cycles start from these values.',
saveSettingsAction: 'Save settings',
savingSettings: 'Saving settings…',
utilityCategoriesTitle: 'Utility categories',
@@ -441,18 +454,19 @@ export const dictionary = {
paymentBalanceAdjustmentUtilities: 'Зачитывать через коммуналку',
paymentBalanceAdjustmentRent: 'Зачитывать через аренду',
paymentBalanceAdjustmentSeparate: 'Держать покупки отдельно',
billingCycleTitle: 'Текущий billing cycle',
billingCycleTitle: 'Текущий расчётный цикл',
billingCycleEmpty: 'Нет открытого цикла',
billingCycleStatus: 'Валюта текущего цикла: {currency}',
billingCycleOpenHint: 'Открой цикл перед тем, как вносить аренду и коммунальные счета.',
billingCyclePeriod: 'Период цикла',
manageCycleAction: 'Управлять циклом',
cycleEditorBody: 'Все действия по циклу собраны в отдельном окне, а не растянуты по странице.',
manageCycleAction: 'Изменить аренду цикла',
cycleEditorBody:
'Меняй аренду только для текущего цикла. Долгосрочное значение по умолчанию остаётся в настройках.',
openCycleAction: 'Открыть цикл',
openingCycle: 'Открываем цикл…',
closeCycleAction: 'Закрыть цикл',
closingCycle: 'Закрываем цикл…',
saveCycleRentAction: 'Сохранить аренду для цикла',
saveCycleRentAction: 'Сохранить аренду цикла',
savingCycleRent: 'Сохраняем аренду…',
utilityCategoryLabel: 'Категория коммуналки',
utilityAmount: 'Сумма коммуналки',
@@ -462,14 +476,27 @@ export const dictionary = {
deleteUtilityBillAction: 'Удалить',
deletingUtilityBill: 'Удаляем счёт…',
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
currencyLabel: 'Валюта',
rentAmount: 'Сумма аренды',
defaultRentAmount: 'Аренда по умолчанию',
defaultRentHint:
'Новые текущие циклы стартуют с этой суммой, если для конкретного месяца нет переопределения.',
currentCycleRentLabel: 'Аренда текущего цикла',
currentCycleRentHint:
'Используй это только когда в текущем месяце аренда отличается от значения по умолчанию.',
currentCycleRentEmpty: 'Для этого цикла аренда пока не задана.',
currentCycleUsesDefaultRent: 'Используется аренда по умолчанию',
currentCycleOverrideRent: 'Есть переопределение для цикла',
rentDueDay: 'День оплаты аренды',
rentWarningDay: 'День напоминания по аренде',
utilitiesDueDay: 'День оплаты коммуналки',
utilitiesReminderDay: 'День напоминания по коммуналке',
timezone: 'Часовой пояс',
timezoneHint: 'Используй IANA-таймзону, например Asia/Tbilisi.',
timezoneInvalidHint: 'Выбери корректную IANA-таймзону, например Asia/Tbilisi.',
manageSettingsAction: 'Управлять настройками',
billingSettingsEditorBody: 'Основные правила биллинга собраны в отдельном окне.',
billingSettingsEditorBody:
'Здесь живут значения по умолчанию для household. Новые текущие циклы стартуют отсюда.',
saveSettingsAction: 'Сохранить настройки',
savingSettings: 'Сохраняем настройки…',
utilityCategoriesTitle: 'Категории коммуналки',
@@ -491,11 +518,10 @@ export const dictionary = {
adminsTitle: 'Админы',
adminsBody:
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
displayNameLabel: 'Имя в household',
displayNameLabel: 'Имя в доме',
displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.',
manageProfileAction: 'Редактировать профиль',
profileEditorBody:
'Своё имя для household лучше менять в отдельном окне, а не на самой странице.',
profileEditorBody: 'Своё имя для дома лучше менять в отдельном окне, а не на самой странице.',
memberEditorBody: 'Статус, политика и админские действия по участнику собраны в одном окне.',
editMemberAction: 'Редактировать участника',
saveMemberChangesAction: 'Сохранить изменения',

View File

@@ -858,6 +858,11 @@ button {
line-height: 1.2;
}
.settings-field input[aria-invalid='true'] {
border-color: rgb(247 115 115 / 0.45);
background: rgb(247 115 115 / 0.08);
}
.settings-field select {
appearance: none;
-webkit-appearance: none;
@@ -967,6 +972,21 @@ button {
cursor: pointer;
}
.timezone-suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.timezone-chip {
border: 1px solid transparent;
}
.timezone-chip--active {
border-color: rgb(247 179 137 / 0.44);
background: rgb(247 179 137 / 0.18);
}
.testing-card {
gap: 12px;
}

View File

@@ -0,0 +1,93 @@
const CURATED_TIMEZONES = [
'Asia/Tbilisi',
'Europe/Berlin',
'Europe/London',
'Europe/Paris',
'Europe/Warsaw',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Asia/Dubai',
'Asia/Bangkok',
'Asia/Tokyo',
'Australia/Sydney'
] as const
const CURATED_TIMEZONE_SET = new Set<string>(CURATED_TIMEZONES)
type IntlWithSupportedValues = typeof Intl & {
supportedValuesOf?: (key: 'timeZone') => readonly string[]
}
function supportedTimezones(): readonly string[] {
const withSupportedValues = Intl as IntlWithSupportedValues
if (typeof withSupportedValues.supportedValuesOf === 'function') {
try {
const supported = withSupportedValues.supportedValuesOf('timeZone')
if (supported.length > 0) {
return supported
}
} catch {
// Ignore and fall back to the curated list below.
}
}
return CURATED_TIMEZONES
}
const TIMEZONES = [
...CURATED_TIMEZONES,
...supportedTimezones()
.filter((timezone) => !CURATED_TIMEZONE_SET.has(timezone))
.sort((left, right) => left.localeCompare(right))
]
export function canonicalizeTimezone(value: string): string | null {
const trimmed = value.trim()
if (trimmed.length === 0) {
return null
}
try {
return new Intl.DateTimeFormat('en-US', {
timeZone: trimmed
}).resolvedOptions().timeZone
} catch {
return null
}
}
export function isValidTimezone(value: string): boolean {
return canonicalizeTimezone(value) !== null
}
export function searchTimezones(query: string, limit = 10): readonly string[] {
const trimmed = query.trim()
if (trimmed.length === 0) {
return TIMEZONES.slice(0, limit)
}
const normalized = trimmed.toLowerCase()
const matches = TIMEZONES.filter((timezone) => timezone.toLowerCase().includes(normalized))
if (matches.length === 0) {
return TIMEZONES.slice(0, limit)
}
matches.sort((left, right) => {
const leftStartsWith = left.toLowerCase().startsWith(normalized)
const rightStartsWith = right.toLowerCase().startsWith(normalized)
if (leftStartsWith !== rightStartsWith) {
return leftStartsWith ? -1 : 1
}
return left.localeCompare(right)
})
return matches.slice(0, limit)
}

View File

@@ -1,4 +1,4 @@
import { For, Show, type JSX } from 'solid-js'
import { For, Show, createMemo, type JSX } from 'solid-js'
import {
Button,
@@ -13,6 +13,7 @@ import {
TrashIcon
} from '../components/ui'
import { formatCyclePeriod, formatFriendlyDate } from '../lib/dates'
import { isValidTimezone, searchTimezones } from '../lib/timezones'
import type {
MiniAppAdminCycleState,
MiniAppAdminSettingsPayload,
@@ -205,8 +206,59 @@ export function HouseScreen(props: Props) {
return parsed
}
function parseAmountMinor(value: string): bigint | null {
const trimmed = value.trim()
if (!/^\d+(?:\.\d{0,2})?$/.test(trimmed)) {
return null
}
const [whole, fraction] = trimmed.split('.')
return BigInt(whole ?? '0') * 100n + BigInt(((fraction ?? '') + '00').slice(0, 2))
}
const enabledLabel = () => props.copy.onLabel ?? 'ON'
const disabledLabel = () => props.copy.offLabel ?? 'OFF'
const timezoneSuggestions = createMemo(() => searchTimezones(props.billingForm.timezone, 8))
const timezoneValid = createMemo(() => isValidTimezone(props.billingForm.timezone))
const defaultRentMinor = createMemo(() => parseAmountMinor(props.billingForm.rentAmountMajor))
const cycleRentMinor = createMemo(() =>
props.cycleState?.rentRule ? BigInt(props.cycleState.rentRule.amountMinor) : null
)
const currentCycleUsesDefaultRent = createMemo(() => {
if (!props.cycleState?.rentRule) {
return false
}
return (
defaultRentMinor() === cycleRentMinor() &&
props.cycleState.rentRule.currency === props.billingForm.rentCurrency
)
})
const currentCycleRentStatus = createMemo(() => {
if (!props.cycleState?.rentRule) {
return props.copy.currentCycleRentEmpty ?? '—'
}
return currentCycleUsesDefaultRent()
? (props.copy.currentCycleUsesDefaultRent ?? '')
: (props.copy.currentCycleOverrideRent ?? '')
})
const defaultRentSummary = createMemo(() =>
defaultRentMinor() === null
? '—'
: `${props.billingForm.rentAmountMajor.trim()} ${props.billingForm.rentCurrency}`
)
const currentCycleRentSummary = createMemo(() => {
if (!props.cycleState?.rentRule) {
return props.copy.currentCycleRentEmpty ?? '—'
}
return `${props.minorToMajorString(BigInt(props.cycleState.rentRule.amountMinor))} ${
props.cycleState.rentRule.currency
}`
})
return (
<Show
@@ -315,14 +367,23 @@ export function HouseScreen(props: Props) {
)
: props.copy.billingCycleOpenHint}
</p>
<div class="ledger-compact-card__meta">
<span class="mini-chip">
{props.copy.defaultRentAmount ?? props.copy.rentAmount ?? ''}:{' '}
{defaultRentSummary()}
</span>
<span class="mini-chip mini-chip--muted">{currentCycleRentStatus()}</span>
</div>
<p>
{props.copy.currentCycleRentLabel ?? props.copy.rentAmount ?? ''}:{' '}
{currentCycleRentSummary()}
</p>
<Show when={props.dashboard}>
{(data) => (
<p>
{props.copy.shareRent ?? ''}: {data().rentSourceAmountMajor}{' '}
{data().rentSourceCurrency}
{data().rentSourceCurrency !== data().currency
? ` -> ${data().rentDisplayAmountMajor} ${data().currency}`
: ''}
? `${data().rentSourceAmountMajor} ${data().rentSourceCurrency} = ${data().rentDisplayAmountMajor} ${data().currency}`
: `${data().rentDisplayAmountMajor} ${data().currency}`}
</p>
)}
</Show>
@@ -406,13 +467,16 @@ export function HouseScreen(props: Props) {
>
{props.cycleState?.cycle ? (
<div class="editor-grid">
<Field label={props.copy.rentAmount ?? ''}>
<Field
label={props.copy.currentCycleRentLabel ?? props.copy.rentAmount ?? ''}
hint={props.copy.currentCycleRentHint ?? ''}
>
<input
value={props.cycleForm.rentAmountMajor}
onInput={(event) => props.onCycleRentAmountChange(event.currentTarget.value)}
/>
</Field>
<Field label={props.copy.shareRent ?? ''}>
<Field label={props.copy.currencyLabel ?? props.copy.settlementCurrency ?? ''}>
<select
value={props.cycleForm.rentCurrency}
onChange={(event) =>
@@ -451,7 +515,7 @@ export function HouseScreen(props: Props) {
</Button>
<Button
variant="primary"
disabled={props.savingBillingSettings}
disabled={props.savingBillingSettings || !timezoneValid()}
onClick={() => void props.onSaveBillingSettings()}
>
{props.savingBillingSettings
@@ -503,13 +567,16 @@ export function HouseScreen(props: Props) {
<option value="separate">{props.copy.paymentBalanceAdjustmentSeparate}</option>
</select>
</Field>
<Field label={props.copy.rentAmount ?? ''}>
<Field
label={props.copy.defaultRentAmount ?? props.copy.rentAmount ?? ''}
hint={props.copy.defaultRentHint ?? ''}
>
<input
value={props.billingForm.rentAmountMajor}
onInput={(event) => props.onBillingRentAmountChange(event.currentTarget.value)}
/>
</Field>
<Field label={props.copy.shareRent ?? ''}>
<Field label={props.copy.currencyLabel ?? props.copy.settlementCurrency ?? ''}>
<select
value={props.billingForm.rentCurrency}
onChange={(event) =>
@@ -572,11 +639,43 @@ export function HouseScreen(props: Props) {
}
/>
</Field>
<Field label={props.copy.timezone ?? ''} wide>
<Field
label={props.copy.timezone ?? ''}
hint={
timezoneValid()
? (props.copy.timezoneHint ?? '')
: (props.copy.timezoneInvalidHint ?? '')
}
wide
>
<input
aria-invalid={!timezoneValid()}
list="billing-timezone-options"
placeholder="Asia/Tbilisi"
value={props.billingForm.timezone}
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
/>
<datalist id="billing-timezone-options">
<For each={timezoneSuggestions()}>
{(timezone) => <option value={timezone}>{timezone}</option>}
</For>
</datalist>
<div class="timezone-suggestions">
<For each={timezoneSuggestions()}>
{(timezone) => (
<button
class="mini-chip mini-chip-button timezone-chip"
classList={{
'timezone-chip--active': props.billingForm.timezone === timezone
}}
type="button"
onClick={() => props.onBillingTimezoneChange(timezone)}
>
{timezone}
</button>
)}
</For>
</div>
</Field>
<Field label={props.copy.assistantToneLabel ?? ''} wide>
<input