mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(miniapp): improve billing settings controls
This commit is contained in:
@@ -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: 'Сохранить изменения',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
93
apps/miniapp/src/lib/timezones.ts
Normal file
93
apps/miniapp/src/lib/timezones.ts
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user