mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
1146 lines
48 KiB
TypeScript
1146 lines
48 KiB
TypeScript
import { For, Show } from 'solid-js'
|
|
|
|
import {
|
|
Button,
|
|
CalendarIcon,
|
|
Field,
|
|
IconButton,
|
|
Modal,
|
|
PencilIcon,
|
|
PlusIcon,
|
|
SettingsIcon
|
|
} from '../components/ui'
|
|
import { NavigationTabs } from '../components/layout/navigation-tabs'
|
|
import type {
|
|
MiniAppAdminCycleState,
|
|
MiniAppAdminSettingsPayload,
|
|
MiniAppDashboard,
|
|
MiniAppMemberAbsencePolicy,
|
|
MiniAppPendingMember
|
|
} from '../miniapp-api'
|
|
|
|
type HouseSectionKey = 'billing' | 'utilities' | 'members' | 'topics'
|
|
|
|
type UtilityBillDraft = {
|
|
billName: string
|
|
amountMajor: string
|
|
currency: 'USD' | 'GEL'
|
|
}
|
|
|
|
type BillingForm = {
|
|
settlementCurrency: 'USD' | 'GEL'
|
|
paymentBalanceAdjustmentPolicy: 'utilities' | 'rent' | 'separate'
|
|
rentAmountMajor: string
|
|
rentCurrency: 'USD' | 'GEL'
|
|
rentDueDay: number
|
|
rentWarningDay: number
|
|
utilitiesDueDay: number
|
|
utilitiesReminderDay: number
|
|
timezone: string
|
|
}
|
|
|
|
type CycleForm = {
|
|
period: string
|
|
rentCurrency: 'USD' | 'GEL'
|
|
utilityCurrency: 'USD' | 'GEL'
|
|
rentAmountMajor: string
|
|
utilityCategorySlug: string
|
|
utilityAmountMajor: string
|
|
}
|
|
|
|
type Props = {
|
|
copy: Record<string, string | undefined>
|
|
readyIsAdmin: boolean
|
|
householdDefaultLocale: 'en' | 'ru'
|
|
dashboard: MiniAppDashboard | null
|
|
adminSettings: MiniAppAdminSettingsPayload | null
|
|
cycleState: MiniAppAdminCycleState | null
|
|
pendingMembers: readonly MiniAppPendingMember[]
|
|
activeHouseSection: HouseSectionKey
|
|
onChangeHouseSection: (section: HouseSectionKey) => void
|
|
billingForm: BillingForm
|
|
cycleForm: CycleForm
|
|
newCategoryName: string
|
|
cycleRentOpen: boolean
|
|
billingSettingsOpen: boolean
|
|
addingUtilityBillOpen: boolean
|
|
editingUtilityBill: MiniAppAdminCycleState['utilityBills'][number] | null
|
|
editingUtilityBillId: string | null
|
|
utilityBillDrafts: Record<string, UtilityBillDraft>
|
|
editingCategorySlug: string | null
|
|
editingCategory: MiniAppAdminSettingsPayload['categories'][number] | null
|
|
editingCategoryDraft: {
|
|
name: string
|
|
isActive: boolean
|
|
} | null
|
|
editingMember: MiniAppAdminSettingsPayload['members'][number] | null
|
|
memberDisplayNameDrafts: Record<string, string>
|
|
memberStatusDrafts: Record<string, 'active' | 'away' | 'left'>
|
|
memberAbsencePolicyDrafts: Record<string, MiniAppMemberAbsencePolicy>
|
|
rentWeightDrafts: Record<string, string>
|
|
openingCycle: boolean
|
|
closingCycle: boolean
|
|
savingCycleRent: boolean
|
|
savingBillingSettings: boolean
|
|
savingUtilityBill: boolean
|
|
savingUtilityBillId: string | null
|
|
deletingUtilityBillId: string | null
|
|
savingCategorySlug: string | null
|
|
approvingTelegramUserId: string | null
|
|
savingMemberEditorId: string | null
|
|
promotingMemberId: string | null
|
|
savingHouseholdLocale: boolean
|
|
minorToMajorString: (value: bigint) => string
|
|
memberStatusLabel: (status: 'active' | 'away' | 'left') => string
|
|
topicRoleLabel: (role: 'purchase' | 'feedback' | 'reminders' | 'payments') => string
|
|
resolvedMemberAbsencePolicy: (
|
|
memberId: string,
|
|
status: 'active' | 'away' | 'left'
|
|
) => {
|
|
policy: MiniAppMemberAbsencePolicy
|
|
effectiveFromPeriod: string | null
|
|
}
|
|
onChangeHouseholdLocale: (locale: 'en' | 'ru') => Promise<void>
|
|
onOpenCycleModal: () => void
|
|
onCloseCycleModal: () => void
|
|
onSaveCycleRent: () => Promise<void>
|
|
onOpenCycle: () => Promise<void>
|
|
onCloseCycle: () => Promise<void>
|
|
onCycleRentAmountChange: (value: string) => void
|
|
onCycleRentCurrencyChange: (value: 'USD' | 'GEL') => void
|
|
onCyclePeriodChange: (value: string) => void
|
|
onOpenBillingSettingsModal: () => void
|
|
onCloseBillingSettingsModal: () => void
|
|
onSaveBillingSettings: () => Promise<void>
|
|
onBillingSettlementCurrencyChange: (value: 'USD' | 'GEL') => void
|
|
onBillingAdjustmentPolicyChange: (value: 'utilities' | 'rent' | 'separate') => void
|
|
onBillingRentAmountChange: (value: string) => void
|
|
onBillingRentCurrencyChange: (value: 'USD' | 'GEL') => void
|
|
onBillingRentDueDayChange: (value: number | null) => void
|
|
onBillingRentWarningDayChange: (value: number | null) => void
|
|
onBillingUtilitiesDueDayChange: (value: number | null) => void
|
|
onBillingUtilitiesReminderDayChange: (value: number | null) => void
|
|
onBillingTimezoneChange: (value: string) => void
|
|
onOpenAddUtilityBill: () => void
|
|
onCloseAddUtilityBill: () => void
|
|
onAddUtilityBill: () => Promise<void>
|
|
onCycleUtilityCategoryChange: (value: string) => void
|
|
onCycleUtilityAmountChange: (value: string) => void
|
|
onCycleUtilityCurrencyChange: (value: 'USD' | 'GEL') => void
|
|
onOpenUtilityBillEditor: (billId: string) => void
|
|
onCloseUtilityBillEditor: () => void
|
|
onDeleteUtilityBill: (billId: string) => Promise<void>
|
|
onSaveUtilityBill: (billId: string) => Promise<void>
|
|
onUtilityBillNameChange: (
|
|
billId: string,
|
|
bill: MiniAppAdminCycleState['utilityBills'][number],
|
|
value: string
|
|
) => void
|
|
onUtilityBillAmountChange: (
|
|
billId: string,
|
|
bill: MiniAppAdminCycleState['utilityBills'][number],
|
|
value: string
|
|
) => void
|
|
onUtilityBillCurrencyChange: (
|
|
billId: string,
|
|
bill: MiniAppAdminCycleState['utilityBills'][number],
|
|
value: 'USD' | 'GEL'
|
|
) => void
|
|
onOpenCategoryEditor: (slug: string) => void
|
|
onCloseCategoryEditor: () => void
|
|
onNewCategoryNameChange: (value: string) => void
|
|
onSaveNewCategory: () => Promise<void>
|
|
onSaveExistingCategory: () => Promise<void>
|
|
onEditingCategoryNameChange: (value: string) => void
|
|
onEditingCategoryActiveChange: (value: boolean) => void
|
|
onOpenMemberEditor: (memberId: string) => void
|
|
onCloseMemberEditor: () => void
|
|
onApprovePendingMember: (telegramUserId: string) => Promise<void>
|
|
onMemberDisplayNameDraftChange: (memberId: string, value: string) => void
|
|
onMemberStatusDraftChange: (memberId: string, value: 'active' | 'away' | 'left') => void
|
|
onMemberAbsencePolicyDraftChange: (memberId: string, value: MiniAppMemberAbsencePolicy) => void
|
|
onRentWeightDraftChange: (memberId: string, value: string) => void
|
|
onSaveMemberChanges: (memberId: string) => Promise<void>
|
|
onPromoteMember: (memberId: string) => Promise<void>
|
|
}
|
|
|
|
export function HouseScreen(props: Props) {
|
|
function parseBillingDayInput(value: string): number | null {
|
|
const trimmed = value.trim()
|
|
if (trimmed.length === 0) {
|
|
return null
|
|
}
|
|
|
|
const parsed = Number.parseInt(trimmed, 10)
|
|
if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 1 || parsed > 31) {
|
|
return null
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
const enabledLabel = () => props.copy.onLabel ?? 'ON'
|
|
const disabledLabel = () => props.copy.offLabel ?? 'OFF'
|
|
|
|
return (
|
|
<Show
|
|
when={props.readyIsAdmin}
|
|
fallback={
|
|
<div class="balance-list">
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{props.copy.residentHouseTitle ?? ''}</strong>
|
|
</header>
|
|
<p>{props.copy.residentHouseBody ?? ''}</p>
|
|
</article>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="admin-layout">
|
|
<NavigationTabs
|
|
items={
|
|
[
|
|
{ key: 'billing', label: props.copy.houseSectionBilling ?? '' },
|
|
{ key: 'utilities', label: props.copy.houseSectionUtilities ?? '' },
|
|
{ key: 'members', label: props.copy.houseSectionMembers ?? '' },
|
|
{ key: 'topics', label: props.copy.houseSectionTopics ?? '' }
|
|
] as const
|
|
}
|
|
active={props.activeHouseSection}
|
|
onChange={props.onChangeHouseSection}
|
|
/>
|
|
|
|
<Show when={props.activeHouseSection === 'billing'}>
|
|
<section class="admin-section">
|
|
<div class="admin-grid">
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{props.copy.billingCycleTitle ?? ''}</strong>
|
|
<span>
|
|
{props.cycleState?.cycle?.period ?? props.copy.billingCycleEmpty ?? ''}
|
|
</span>
|
|
</header>
|
|
<p>
|
|
{props.cycleState?.cycle
|
|
? (props.copy.billingCycleStatus ?? '').replace(
|
|
'{currency}',
|
|
props.cycleState?.cycle?.currency ?? props.billingForm.settlementCurrency
|
|
)
|
|
: props.copy.billingCycleOpenHint}
|
|
</p>
|
|
<Show when={props.dashboard}>
|
|
{(data) => (
|
|
<p>
|
|
{props.copy.shareRent ?? ''}: {data().rentSourceAmountMajor}{' '}
|
|
{data().rentSourceCurrency}
|
|
{data().rentSourceCurrency !== data().currency
|
|
? ` -> ${data().rentDisplayAmountMajor} ${data().currency}`
|
|
: ''}
|
|
</p>
|
|
)}
|
|
</Show>
|
|
<div class="panel-toolbar">
|
|
<Button variant="secondary" onClick={props.onOpenCycleModal}>
|
|
<CalendarIcon />
|
|
{props.cycleState?.cycle
|
|
? (props.copy.manageCycleAction ?? '')
|
|
: (props.copy.openCycleAction ?? '')}
|
|
</Button>
|
|
<Show when={props.cycleState?.cycle}>
|
|
<Button
|
|
variant="ghost"
|
|
disabled={props.closingCycle}
|
|
onClick={() => void props.onCloseCycle()}
|
|
>
|
|
{props.closingCycle ? props.copy.closingCycle : props.copy.closeCycleAction}
|
|
</Button>
|
|
</Show>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{props.copy.billingSettingsTitle ?? ''}</strong>
|
|
<span>{props.billingForm.settlementCurrency}</span>
|
|
</header>
|
|
<p>
|
|
{props.billingForm.paymentBalanceAdjustmentPolicy === 'utilities'
|
|
? props.copy.paymentBalanceAdjustmentUtilities
|
|
: props.billingForm.paymentBalanceAdjustmentPolicy === 'rent'
|
|
? props.copy.paymentBalanceAdjustmentRent
|
|
: props.copy.paymentBalanceAdjustmentSeparate}
|
|
</p>
|
|
<div class="ledger-compact-card__meta">
|
|
<span class="mini-chip">
|
|
{props.copy.rentAmount ?? ''}: {props.billingForm.rentAmountMajor || '—'}{' '}
|
|
{props.billingForm.rentCurrency}
|
|
</span>
|
|
<span class="mini-chip mini-chip--muted">
|
|
{props.copy.timezone ?? ''}: {props.billingForm.timezone}
|
|
</span>
|
|
</div>
|
|
<div class="panel-toolbar">
|
|
<Button variant="secondary" onClick={props.onOpenBillingSettingsModal}>
|
|
<SettingsIcon />
|
|
{props.copy.manageSettingsAction ?? ''}
|
|
</Button>
|
|
</div>
|
|
</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>
|
|
</div>
|
|
<Modal
|
|
open={props.cycleRentOpen}
|
|
title={props.copy.billingCycleTitle ?? ''}
|
|
description={props.copy.cycleEditorBody ?? ''}
|
|
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
onClose={props.onCloseCycleModal}
|
|
footer={
|
|
props.cycleState?.cycle ? (
|
|
<div class="modal-action-row modal-action-row--single">
|
|
<Button variant="ghost" onClick={props.onCloseCycleModal}>
|
|
{props.copy.closeEditorAction ?? ''}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={
|
|
props.savingCycleRent || props.cycleForm.rentAmountMajor.trim().length === 0
|
|
}
|
|
onClick={() => void props.onSaveCycleRent()}
|
|
>
|
|
{props.savingCycleRent
|
|
? props.copy.savingCycleRent
|
|
: props.copy.saveCycleRentAction}
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div class="modal-action-row modal-action-row--single">
|
|
<Button variant="ghost" onClick={props.onCloseCycleModal}>
|
|
{props.copy.closeEditorAction ?? ''}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={props.openingCycle}
|
|
onClick={() => void props.onOpenCycle()}
|
|
>
|
|
{props.openingCycle ? props.copy.openingCycle : props.copy.openCycleAction}
|
|
</Button>
|
|
</div>
|
|
)
|
|
}
|
|
>
|
|
{props.cycleState?.cycle ? (
|
|
<div class="editor-grid">
|
|
<Field label={props.copy.rentAmount ?? ''}>
|
|
<input
|
|
value={props.cycleForm.rentAmountMajor}
|
|
onInput={(event) => props.onCycleRentAmountChange(event.currentTarget.value)}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.shareRent ?? ''}>
|
|
<select
|
|
value={props.cycleForm.rentCurrency}
|
|
onChange={(event) =>
|
|
props.onCycleRentCurrencyChange(event.currentTarget.value as 'USD' | 'GEL')
|
|
}
|
|
>
|
|
<option value="USD">USD</option>
|
|
<option value="GEL">GEL</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
) : (
|
|
<div class="editor-grid">
|
|
<Field label={props.copy.billingCyclePeriod ?? ''}>
|
|
<input
|
|
value={props.cycleForm.period}
|
|
onInput={(event) => props.onCyclePeriodChange(event.currentTarget.value)}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.settlementCurrency ?? ''}>
|
|
<div class="settings-field__value">{props.billingForm.settlementCurrency}</div>
|
|
</Field>
|
|
</div>
|
|
)}
|
|
</Modal>
|
|
<Modal
|
|
open={props.billingSettingsOpen}
|
|
title={props.copy.billingSettingsTitle ?? ''}
|
|
description={props.copy.billingSettingsEditorBody ?? ''}
|
|
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
onClose={props.onCloseBillingSettingsModal}
|
|
footer={
|
|
<div class="modal-action-row modal-action-row--single">
|
|
<Button variant="ghost" onClick={props.onCloseBillingSettingsModal}>
|
|
{props.copy.closeEditorAction ?? ''}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={props.savingBillingSettings}
|
|
onClick={() => void props.onSaveBillingSettings()}
|
|
>
|
|
{props.savingBillingSettings
|
|
? props.copy.savingSettings
|
|
: props.copy.saveSettingsAction}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="editor-grid">
|
|
<Field label={props.copy.settlementCurrency ?? ''}>
|
|
<select
|
|
value={props.billingForm.settlementCurrency}
|
|
onChange={(event) =>
|
|
props.onBillingSettlementCurrencyChange(
|
|
event.currentTarget.value as 'USD' | 'GEL'
|
|
)
|
|
}
|
|
>
|
|
<option value="GEL">GEL</option>
|
|
<option value="USD">USD</option>
|
|
</select>
|
|
</Field>
|
|
<Field label={props.copy.paymentBalanceAdjustmentPolicy ?? ''} wide>
|
|
<select
|
|
value={props.billingForm.paymentBalanceAdjustmentPolicy}
|
|
onChange={(event) =>
|
|
props.onBillingAdjustmentPolicyChange(
|
|
event.currentTarget.value as 'utilities' | 'rent' | 'separate'
|
|
)
|
|
}
|
|
>
|
|
<option value="utilities">
|
|
{props.copy.paymentBalanceAdjustmentUtilities}
|
|
</option>
|
|
<option value="rent">{props.copy.paymentBalanceAdjustmentRent}</option>
|
|
<option value="separate">{props.copy.paymentBalanceAdjustmentSeparate}</option>
|
|
</select>
|
|
</Field>
|
|
<Field label={props.copy.rentAmount ?? ''}>
|
|
<input
|
|
value={props.billingForm.rentAmountMajor}
|
|
onInput={(event) => props.onBillingRentAmountChange(event.currentTarget.value)}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.shareRent ?? ''}>
|
|
<select
|
|
value={props.billingForm.rentCurrency}
|
|
onChange={(event) =>
|
|
props.onBillingRentCurrencyChange(event.currentTarget.value as 'USD' | 'GEL')
|
|
}
|
|
>
|
|
<option value="USD">USD</option>
|
|
<option value="GEL">GEL</option>
|
|
</select>
|
|
</Field>
|
|
<Field label={props.copy.rentDueDay ?? ''}>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="31"
|
|
value={String(props.billingForm.rentDueDay)}
|
|
onInput={(event) =>
|
|
props.onBillingRentDueDayChange(
|
|
parseBillingDayInput(event.currentTarget.value)
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.rentWarningDay ?? ''}>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="31"
|
|
value={String(props.billingForm.rentWarningDay)}
|
|
onInput={(event) =>
|
|
props.onBillingRentWarningDayChange(
|
|
parseBillingDayInput(event.currentTarget.value)
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.utilitiesDueDay ?? ''}>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="31"
|
|
value={String(props.billingForm.utilitiesDueDay)}
|
|
onInput={(event) =>
|
|
props.onBillingUtilitiesDueDayChange(
|
|
parseBillingDayInput(event.currentTarget.value)
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.utilitiesReminderDay ?? ''}>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max="31"
|
|
value={String(props.billingForm.utilitiesReminderDay)}
|
|
onInput={(event) =>
|
|
props.onBillingUtilitiesReminderDayChange(
|
|
parseBillingDayInput(event.currentTarget.value)
|
|
)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.timezone ?? ''} wide>
|
|
<input
|
|
value={props.billingForm.timezone}
|
|
onInput={(event) => props.onBillingTimezoneChange(event.currentTarget.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
</section>
|
|
</Show>
|
|
|
|
<Show when={props.activeHouseSection === 'utilities'}>
|
|
<section class="admin-section">
|
|
<div class="admin-grid">
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{props.copy.utilityLedgerTitle ?? ''}</strong>
|
|
<span>{props.cycleForm.utilityCurrency}</span>
|
|
</header>
|
|
<p>{props.copy.utilityBillsEditorBody ?? ''}</p>
|
|
<div class="panel-toolbar">
|
|
<Button variant="secondary" onClick={props.onOpenAddUtilityBill}>
|
|
<PlusIcon />
|
|
{props.copy.addUtilityBillAction ?? ''}
|
|
</Button>
|
|
</div>
|
|
<div class="ledger-list">
|
|
{props.cycleState?.utilityBills.length ? (
|
|
<For each={props.cycleState?.utilityBills ?? []}>
|
|
{(bill) => (
|
|
<article class="ledger-compact-card">
|
|
<div class="ledger-compact-card__main">
|
|
<header>
|
|
<strong>{bill.billName}</strong>
|
|
<span>{bill.createdAt.slice(0, 10)}</span>
|
|
</header>
|
|
<p>{props.copy.utilityCategoryName ?? ''}</p>
|
|
<div class="ledger-compact-card__meta">
|
|
<span class="mini-chip">
|
|
{props.minorToMajorString(BigInt(bill.amountMinor))} {bill.currency}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="ledger-compact-card__actions">
|
|
<IconButton
|
|
label={props.copy.editUtilityBillAction ?? ''}
|
|
onClick={() => props.onOpenUtilityBillEditor(bill.id)}
|
|
>
|
|
<PencilIcon />
|
|
</IconButton>
|
|
</div>
|
|
</article>
|
|
)}
|
|
</For>
|
|
) : (
|
|
<p>{props.copy.utilityBillsEmpty ?? ''}</p>
|
|
)}
|
|
</div>
|
|
</article>
|
|
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{props.copy.utilityCategoriesTitle ?? ''}</strong>
|
|
<span>{String(props.adminSettings?.categories.length ?? 0)}</span>
|
|
</header>
|
|
<p>{props.copy.utilityCategoriesBody ?? ''}</p>
|
|
<div class="panel-toolbar">
|
|
<Button variant="secondary" onClick={() => props.onOpenCategoryEditor('__new__')}>
|
|
<PlusIcon />
|
|
{props.copy.addCategoryAction ?? ''}
|
|
</Button>
|
|
</div>
|
|
<div class="ledger-list">
|
|
<For each={props.adminSettings?.categories ?? []}>
|
|
{(category) => (
|
|
<article class="ledger-compact-card">
|
|
<div class="ledger-compact-card__main">
|
|
<header>
|
|
<strong>{category.name}</strong>
|
|
<span>{category.isActive ? enabledLabel() : disabledLabel()}</span>
|
|
</header>
|
|
<p>{props.copy.utilityCategoryName ?? ''}</p>
|
|
<div class="ledger-compact-card__meta">
|
|
<span
|
|
class={`mini-chip ${category.isActive ? '' : 'mini-chip--muted'}`}
|
|
>
|
|
{category.isActive ? enabledLabel() : disabledLabel()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="ledger-compact-card__actions">
|
|
<IconButton
|
|
label={props.copy.editCategoryAction ?? ''}
|
|
onClick={() => props.onOpenCategoryEditor(category.slug)}
|
|
>
|
|
<PencilIcon />
|
|
</IconButton>
|
|
</div>
|
|
</article>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
<Modal
|
|
open={props.addingUtilityBillOpen}
|
|
title={props.copy.addUtilityBillAction ?? ''}
|
|
description={props.copy.utilityBillCreateBody ?? ''}
|
|
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
onClose={props.onCloseAddUtilityBill}
|
|
footer={
|
|
<div class="modal-action-row modal-action-row--single">
|
|
<Button variant="ghost" onClick={props.onCloseAddUtilityBill}>
|
|
{props.copy.closeEditorAction ?? ''}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={
|
|
props.savingUtilityBill ||
|
|
props.cycleForm.utilityAmountMajor.trim().length === 0
|
|
}
|
|
onClick={() => void props.onAddUtilityBill()}
|
|
>
|
|
{props.savingUtilityBill
|
|
? props.copy.savingUtilityBill
|
|
: props.copy.addUtilityBillAction}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="editor-grid">
|
|
<Field label={props.copy.utilityCategoryLabel ?? ''}>
|
|
<select
|
|
value={props.cycleForm.utilityCategorySlug}
|
|
onChange={(event) =>
|
|
props.onCycleUtilityCategoryChange(event.currentTarget.value)
|
|
}
|
|
>
|
|
<For
|
|
each={(props.adminSettings?.categories ?? []).filter(
|
|
(category) => category.isActive
|
|
)}
|
|
>
|
|
{(category) => <option value={category.slug}>{category.name}</option>}
|
|
</For>
|
|
</select>
|
|
</Field>
|
|
<Field label={props.copy.utilityAmount ?? ''}>
|
|
<input
|
|
value={props.cycleForm.utilityAmountMajor}
|
|
onInput={(event) => props.onCycleUtilityAmountChange(event.currentTarget.value)}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.settlementCurrency ?? ''}>
|
|
<select
|
|
value={props.cycleForm.utilityCurrency}
|
|
onChange={(event) =>
|
|
props.onCycleUtilityCurrencyChange(event.currentTarget.value as 'USD' | 'GEL')
|
|
}
|
|
>
|
|
<option value="GEL">GEL</option>
|
|
<option value="USD">USD</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
<Modal
|
|
open={Boolean(props.editingUtilityBill)}
|
|
title={props.copy.utilityLedgerTitle ?? ''}
|
|
description={props.copy.utilityBillEditorBody ?? ''}
|
|
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
onClose={props.onCloseUtilityBillEditor}
|
|
footer={(() => {
|
|
const bill = props.editingUtilityBill
|
|
if (!bill) {
|
|
return null
|
|
}
|
|
return (
|
|
<div class="modal-action-row">
|
|
<Button
|
|
variant="danger"
|
|
onClick={() => void props.onDeleteUtilityBill(bill.id)}
|
|
>
|
|
{props.deletingUtilityBillId === bill.id
|
|
? props.copy.deletingUtilityBill
|
|
: props.copy.deleteUtilityBillAction}
|
|
</Button>
|
|
<div class="modal-action-row__primary">
|
|
<Button variant="ghost" onClick={props.onCloseUtilityBillEditor}>
|
|
{props.copy.closeEditorAction ?? ''}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={props.savingUtilityBillId === bill.id}
|
|
onClick={() => void props.onSaveUtilityBill(bill.id)}
|
|
>
|
|
{props.savingUtilityBillId === bill.id
|
|
? props.copy.savingUtilityBill
|
|
: props.copy.saveUtilityBillAction}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
>
|
|
{(() => {
|
|
const bill = props.editingUtilityBill
|
|
if (!bill) {
|
|
return null
|
|
}
|
|
const draft = props.utilityBillDrafts[bill.id] ?? {
|
|
billName: bill.billName,
|
|
amountMajor: props.minorToMajorString(BigInt(bill.amountMinor)),
|
|
currency: bill.currency
|
|
}
|
|
|
|
return (
|
|
<div class="editor-grid">
|
|
<Field label={props.copy.utilityCategoryName ?? ''} wide>
|
|
<input
|
|
value={draft.billName}
|
|
onInput={(event) =>
|
|
props.onUtilityBillNameChange(bill.id, bill, event.currentTarget.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.utilityAmount ?? ''}>
|
|
<input
|
|
value={draft.amountMajor}
|
|
onInput={(event) =>
|
|
props.onUtilityBillAmountChange(bill.id, bill, event.currentTarget.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.settlementCurrency ?? ''}>
|
|
<select
|
|
value={draft.currency}
|
|
onChange={(event) =>
|
|
props.onUtilityBillCurrencyChange(
|
|
bill.id,
|
|
bill,
|
|
event.currentTarget.value as 'USD' | 'GEL'
|
|
)
|
|
}
|
|
>
|
|
<option value="GEL">GEL</option>
|
|
<option value="USD">USD</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
)
|
|
})()}
|
|
</Modal>
|
|
<Modal
|
|
open={Boolean(props.editingCategorySlug)}
|
|
title={
|
|
props.editingCategorySlug === '__new__'
|
|
? (props.copy.addCategoryAction ?? '')
|
|
: (props.copy.utilityCategoriesTitle ?? '')
|
|
}
|
|
description={
|
|
props.editingCategorySlug === '__new__'
|
|
? (props.copy.categoryCreateBody ?? '')
|
|
: (props.copy.categoryEditorBody ?? '')
|
|
}
|
|
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
onClose={props.onCloseCategoryEditor}
|
|
footer={(() => {
|
|
const category = props.editingCategory
|
|
const isNew = props.editingCategorySlug === '__new__'
|
|
return (
|
|
<div class="modal-action-row modal-action-row--single">
|
|
<Button variant="ghost" onClick={props.onCloseCategoryEditor}>
|
|
{props.copy.closeEditorAction ?? ''}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={
|
|
isNew
|
|
? props.newCategoryName.trim().length === 0 ||
|
|
props.savingCategorySlug === '__new__'
|
|
: !category || props.savingCategorySlug === category.slug
|
|
}
|
|
onClick={() =>
|
|
void (isNew ? props.onSaveNewCategory() : props.onSaveExistingCategory())
|
|
}
|
|
>
|
|
{props.savingCategorySlug === (isNew ? '__new__' : (category?.slug ?? null))
|
|
? props.copy.savingCategory
|
|
: isNew
|
|
? props.copy.addCategoryAction
|
|
: props.copy.saveCategoryAction}
|
|
</Button>
|
|
</div>
|
|
)
|
|
})()}
|
|
>
|
|
{props.editingCategorySlug === '__new__' ? (
|
|
<div class="editor-grid">
|
|
<Field label={props.copy.utilityCategoryName ?? ''} wide>
|
|
<input
|
|
value={props.newCategoryName}
|
|
onInput={(event) => props.onNewCategoryNameChange(event.currentTarget.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
) : (
|
|
(() => {
|
|
const category = props.editingCategory
|
|
const draft = props.editingCategoryDraft
|
|
if (!category || !draft) {
|
|
return null
|
|
}
|
|
return (
|
|
<div class="editor-grid">
|
|
<Field label={props.copy.utilityCategoryName ?? ''} wide>
|
|
<input
|
|
value={draft.name}
|
|
onInput={(event) =>
|
|
props.onEditingCategoryNameChange(event.currentTarget.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.utilityCategoryActive ?? ''}>
|
|
<select
|
|
value={draft.isActive ? 'true' : 'false'}
|
|
onChange={(event) =>
|
|
props.onEditingCategoryActiveChange(
|
|
event.currentTarget.value === 'true'
|
|
)
|
|
}
|
|
>
|
|
<option value="true">{enabledLabel()}</option>
|
|
<option value="false">{disabledLabel()}</option>
|
|
</select>
|
|
</Field>
|
|
</div>
|
|
)
|
|
})()
|
|
)}
|
|
</Modal>
|
|
</section>
|
|
</Show>
|
|
|
|
<Show when={props.activeHouseSection === 'members'}>
|
|
<section class="admin-section">
|
|
<div class="admin-grid">
|
|
<article class="balance-item admin-card--wide">
|
|
<header>
|
|
<strong>{props.copy.adminsTitle ?? ''}</strong>
|
|
<span>{String(props.adminSettings?.members.length ?? 0)}</span>
|
|
</header>
|
|
<div class="ledger-list">
|
|
<For each={props.adminSettings?.members ?? []}>
|
|
{(member) => (
|
|
<article class="ledger-compact-card">
|
|
<div class="ledger-compact-card__main">
|
|
<header>
|
|
<strong>{member.displayName}</strong>
|
|
<span>
|
|
{member.isAdmin ? props.copy.adminTag : props.copy.residentTag}
|
|
</span>
|
|
</header>
|
|
<p>{props.memberStatusLabel(member.status)}</p>
|
|
<div class="ledger-compact-card__meta">
|
|
<span class="mini-chip">
|
|
{props.copy.rentWeightLabel}: {member.rentShareWeight}
|
|
</span>
|
|
<span class="mini-chip mini-chip--muted">
|
|
{(() => {
|
|
const policy = props.resolvedMemberAbsencePolicy(
|
|
member.id,
|
|
member.status
|
|
).policy
|
|
return policy === 'away_rent_only'
|
|
? props.copy.absencePolicyAwayRentOnly
|
|
: policy === 'away_rent_and_utilities'
|
|
? props.copy.absencePolicyAwayRentAndUtilities
|
|
: policy === 'inactive'
|
|
? props.copy.absencePolicyInactive
|
|
: props.copy.absencePolicyResident
|
|
})()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="ledger-compact-card__actions">
|
|
<IconButton
|
|
label={props.copy.editMemberAction ?? ''}
|
|
onClick={() => props.onOpenMemberEditor(member.id)}
|
|
>
|
|
<PencilIcon />
|
|
</IconButton>
|
|
</div>
|
|
</article>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</article>
|
|
|
|
<article class="balance-item">
|
|
<header>
|
|
<strong>{props.copy.pendingMembersTitle ?? ''}</strong>
|
|
<span>{String(props.pendingMembers.length)}</span>
|
|
</header>
|
|
<p>{props.copy.pendingMembersBody ?? ''}</p>
|
|
{props.pendingMembers.length === 0 ? (
|
|
<p>{props.copy.pendingMembersEmpty ?? ''}</p>
|
|
) : (
|
|
<div class="admin-sublist admin-sublist--plain">
|
|
<For each={props.pendingMembers}>
|
|
{(member) => (
|
|
<article class="ledger-item">
|
|
<header>
|
|
<strong>{member.displayName}</strong>
|
|
<span>{member.telegramUserId}</span>
|
|
</header>
|
|
<p>
|
|
{member.username
|
|
? (props.copy.pendingMemberHandle ?? '').replace(
|
|
'{username}',
|
|
member.username
|
|
)
|
|
: (member.languageCode ?? 'Telegram')}
|
|
</p>
|
|
<button
|
|
class="ghost-button"
|
|
type="button"
|
|
disabled={props.approvingTelegramUserId === member.telegramUserId}
|
|
onClick={() => void props.onApprovePendingMember(member.telegramUserId)}
|
|
>
|
|
{props.approvingTelegramUserId === member.telegramUserId
|
|
? props.copy.approvingMember
|
|
: props.copy.approveMemberAction}
|
|
</button>
|
|
</article>
|
|
)}
|
|
</For>
|
|
</div>
|
|
)}
|
|
</article>
|
|
</div>
|
|
<Modal
|
|
open={Boolean(props.editingMember)}
|
|
title={props.copy.adminsTitle ?? ''}
|
|
description={props.copy.memberEditorBody ?? ''}
|
|
closeLabel={props.copy.closeEditorAction ?? ''}
|
|
onClose={props.onCloseMemberEditor}
|
|
footer={(() => {
|
|
const member = props.editingMember
|
|
if (!member) {
|
|
return null
|
|
}
|
|
|
|
const nextDisplayName =
|
|
props.memberDisplayNameDrafts[member.id]?.trim() ?? member.displayName
|
|
const nextStatus = props.memberStatusDrafts[member.id] ?? member.status
|
|
const currentPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status)
|
|
const nextPolicy =
|
|
props.memberAbsencePolicyDrafts[member.id] ?? currentPolicy.policy
|
|
const nextWeight = Number(
|
|
props.rentWeightDrafts[member.id] ?? String(member.rentShareWeight)
|
|
)
|
|
const hasNameChange =
|
|
nextDisplayName.length >= 2 && nextDisplayName !== member.displayName
|
|
const hasStatusChange = nextStatus !== member.status
|
|
const hasPolicyChange = nextStatus === 'away' && nextPolicy !== currentPolicy.policy
|
|
const hasWeightChange =
|
|
Number.isInteger(nextWeight) &&
|
|
nextWeight > 0 &&
|
|
nextWeight !== member.rentShareWeight
|
|
const canSave =
|
|
props.savingMemberEditorId !== member.id &&
|
|
(hasNameChange || hasStatusChange || hasPolicyChange || hasWeightChange)
|
|
|
|
return (
|
|
<div class="member-editor-actions">
|
|
<Button
|
|
variant="ghost"
|
|
class="member-editor-actions__close"
|
|
onClick={props.onCloseMemberEditor}
|
|
>
|
|
{props.copy.closeEditorAction ?? ''}
|
|
</Button>
|
|
<div class="member-editor-actions__grid">
|
|
<Button
|
|
variant="primary"
|
|
class="member-editor-actions__button"
|
|
disabled={!canSave}
|
|
onClick={() => void props.onSaveMemberChanges(member.id)}
|
|
>
|
|
{props.savingMemberEditorId === member.id
|
|
? props.copy.savingSettings
|
|
: props.copy.saveMemberChangesAction}
|
|
</Button>
|
|
<Show when={!member.isAdmin}>
|
|
<Button
|
|
variant="secondary"
|
|
class="member-editor-actions__button"
|
|
disabled={props.promotingMemberId === member.id}
|
|
onClick={() => void props.onPromoteMember(member.id)}
|
|
>
|
|
{props.promotingMemberId === member.id
|
|
? props.copy.promotingAdmin
|
|
: props.copy.promoteAdminAction}
|
|
</Button>
|
|
</Show>
|
|
</div>
|
|
</div>
|
|
)
|
|
})()}
|
|
>
|
|
{(() => {
|
|
const member = props.editingMember
|
|
if (!member) {
|
|
return null
|
|
}
|
|
|
|
const resolvedPolicy = props.resolvedMemberAbsencePolicy(member.id, member.status)
|
|
|
|
return (
|
|
<div class="editor-grid">
|
|
<Field
|
|
label={props.copy.displayNameLabel ?? ''}
|
|
hint={props.copy.displayNameHint ?? ''}
|
|
wide
|
|
>
|
|
<input
|
|
value={props.memberDisplayNameDrafts[member.id] ?? member.displayName}
|
|
onInput={(event) =>
|
|
props.onMemberDisplayNameDraftChange(member.id, event.currentTarget.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
<Field label={props.copy.memberStatusLabel ?? ''} wide>
|
|
<select
|
|
value={props.memberStatusDrafts[member.id] ?? member.status}
|
|
onChange={(event) =>
|
|
props.onMemberStatusDraftChange(
|
|
member.id,
|
|
event.currentTarget.value as 'active' | 'away' | 'left'
|
|
)
|
|
}
|
|
>
|
|
<option value="active">{props.copy.memberStatusActive ?? ''}</option>
|
|
<option value="away">{props.copy.memberStatusAway ?? ''}</option>
|
|
<option value="left">{props.copy.memberStatusLeft ?? ''}</option>
|
|
</select>
|
|
</Field>
|
|
<Field
|
|
label={props.copy.absencePolicyLabel ?? ''}
|
|
hint={
|
|
resolvedPolicy.effectiveFromPeriod
|
|
? (props.copy.absencePolicyEffectiveFrom ?? '').replace(
|
|
'{period}',
|
|
resolvedPolicy.effectiveFromPeriod
|
|
)
|
|
: (props.copy.absencePolicyHint ?? '')
|
|
}
|
|
wide
|
|
>
|
|
<select
|
|
value={props.memberAbsencePolicyDrafts[member.id] ?? resolvedPolicy.policy}
|
|
disabled={(props.memberStatusDrafts[member.id] ?? member.status) !== 'away'}
|
|
onChange={(event) =>
|
|
props.onMemberAbsencePolicyDraftChange(
|
|
member.id,
|
|
event.currentTarget.value as MiniAppMemberAbsencePolicy
|
|
)
|
|
}
|
|
>
|
|
<option value="away_rent_and_utilities">
|
|
{props.copy.absencePolicyAwayRentAndUtilities ?? ''}
|
|
</option>
|
|
<option value="away_rent_only">
|
|
{props.copy.absencePolicyAwayRentOnly ?? ''}
|
|
</option>
|
|
<option value="inactive">{props.copy.absencePolicyInactive ?? ''}</option>
|
|
<option value="resident">{props.copy.absencePolicyResident ?? ''}</option>
|
|
</select>
|
|
</Field>
|
|
<Field label={props.copy.rentWeightLabel ?? ''} wide>
|
|
<input
|
|
inputMode="numeric"
|
|
value={props.rentWeightDrafts[member.id] ?? String(member.rentShareWeight)}
|
|
onInput={(event) =>
|
|
props.onRentWeightDraftChange(member.id, event.currentTarget.value)
|
|
}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
)
|
|
})()}
|
|
</Modal>
|
|
</section>
|
|
</Show>
|
|
|
|
<Show when={props.activeHouseSection === 'topics'}>
|
|
<section class="admin-section">
|
|
<div class="admin-grid">
|
|
<article class="balance-item admin-card--wide">
|
|
<header>
|
|
<strong>{props.copy.topicBindingsTitle ?? ''}</strong>
|
|
<span>{String(props.adminSettings?.topics.length ?? 0)}/4</span>
|
|
</header>
|
|
<div class="balance-list admin-sublist">
|
|
<For each={['purchase', 'feedback', 'reminders', 'payments'] as const}>
|
|
{(role) => {
|
|
const binding = props.adminSettings?.topics.find(
|
|
(topic) => topic.role === role
|
|
)
|
|
|
|
return (
|
|
<article class="ledger-item">
|
|
<header>
|
|
<strong>{props.topicRoleLabel(role)}</strong>
|
|
<span>{binding ? props.copy.topicBound : props.copy.topicUnbound}</span>
|
|
</header>
|
|
<p>
|
|
{binding
|
|
? `${binding.topicName ?? `Topic #${binding.telegramThreadId}`} · #${binding.telegramThreadId}`
|
|
: props.copy.topicUnbound}
|
|
</p>
|
|
</article>
|
|
)
|
|
}}
|
|
</For>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
</Show>
|
|
</div>
|
|
</Show>
|
|
)
|
|
}
|