mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 22:14:02 +00:00
2518 lines
80 KiB
TypeScript
2518 lines
80 KiB
TypeScript
import { Match, Switch, createMemo, createSignal, onMount } from 'solid-js'
|
|
|
|
import { dictionary, type Locale } from './i18n'
|
|
import {
|
|
fetchAdminSettingsQuery,
|
|
fetchBillingCycleQuery,
|
|
fetchDashboardQuery,
|
|
fetchPendingMembersQuery,
|
|
fetchSessionQuery,
|
|
invalidateHouseholdQueries
|
|
} from './app/miniapp-queries'
|
|
import {
|
|
addMiniAppUtilityBill,
|
|
addMiniAppPayment,
|
|
approveMiniAppPendingMember,
|
|
closeMiniAppBillingCycle,
|
|
deleteMiniAppPayment,
|
|
deleteMiniAppPurchase,
|
|
deleteMiniAppUtilityBill,
|
|
joinMiniAppHousehold,
|
|
openMiniAppBillingCycle,
|
|
promoteMiniAppMember,
|
|
updateMiniAppMemberDisplayName,
|
|
updateMiniAppMemberAbsencePolicy,
|
|
updateMiniAppMemberStatus,
|
|
updateMiniAppMemberRentWeight,
|
|
updateMiniAppOwnDisplayName,
|
|
type MiniAppAdminCycleState,
|
|
type MiniAppAdminSettingsPayload,
|
|
type MiniAppMemberAbsencePolicy,
|
|
updateMiniAppLocalePreference,
|
|
updateMiniAppBillingSettings,
|
|
updateMiniAppCycleRent,
|
|
updateMiniAppPayment,
|
|
updateMiniAppPurchase,
|
|
upsertMiniAppUtilityCategory,
|
|
updateMiniAppUtilityBill,
|
|
type MiniAppDashboard,
|
|
type MiniAppPendingMember
|
|
} from './miniapp-api'
|
|
import { Button, Field, Modal } from './components/ui'
|
|
import { HeroBanner } from './components/layout/hero-banner'
|
|
import { NavigationTabs } from './components/layout/navigation-tabs'
|
|
import { ProfileCard } from './components/layout/profile-card'
|
|
import { TopBar } from './components/layout/top-bar'
|
|
import { BlockedState } from './components/session/blocked-state'
|
|
import { LoadingState } from './components/session/loading-state'
|
|
import { OnboardingState } from './components/session/onboarding-state'
|
|
import { BalancesScreen } from './screens/balances-screen'
|
|
import { HomeScreen } from './screens/home-screen'
|
|
import { HouseScreen } from './screens/house-screen'
|
|
import { LedgerScreen } from './screens/ledger-screen'
|
|
import {
|
|
demoAdminSettings,
|
|
demoCycleState,
|
|
demoDashboard,
|
|
demoMember,
|
|
demoPendingMembers,
|
|
demoTelegramUser
|
|
} from './demo/miniapp-demo'
|
|
import { getTelegramWebApp } from './telegram-webapp'
|
|
|
|
type SessionState =
|
|
| {
|
|
status: 'loading'
|
|
}
|
|
| {
|
|
status: 'blocked'
|
|
reason: 'telegram_only' | 'error'
|
|
}
|
|
| {
|
|
status: 'onboarding'
|
|
mode: 'join_required' | 'pending' | 'open_from_group'
|
|
householdName?: string
|
|
telegramUser: {
|
|
firstName: string | null
|
|
username: string | null
|
|
languageCode: string | null
|
|
}
|
|
}
|
|
| {
|
|
status: 'ready'
|
|
mode: 'live' | 'demo'
|
|
member: {
|
|
id: string
|
|
displayName: string
|
|
status: 'active' | 'away' | 'left'
|
|
isAdmin: boolean
|
|
preferredLocale: Locale | null
|
|
householdDefaultLocale: Locale
|
|
}
|
|
telegramUser: {
|
|
firstName: string | null
|
|
username: string | null
|
|
languageCode: string | null
|
|
}
|
|
}
|
|
|
|
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
|
type HouseSectionKey = 'billing' | 'utilities' | 'members' | 'topics'
|
|
|
|
type UtilityBillDraft = {
|
|
billName: string
|
|
amountMajor: string
|
|
currency: 'USD' | 'GEL'
|
|
}
|
|
|
|
type PurchaseDraft = {
|
|
description: string
|
|
amountMajor: string
|
|
currency: 'USD' | 'GEL'
|
|
splitMode: 'equal' | 'custom_amounts'
|
|
participants: {
|
|
memberId: string
|
|
shareAmountMajor: string
|
|
}[]
|
|
}
|
|
|
|
type PaymentDraft = {
|
|
memberId: string
|
|
kind: 'rent' | 'utilities'
|
|
amountMajor: string
|
|
currency: 'USD' | 'GEL'
|
|
}
|
|
|
|
const chartPalette = ['#f7b389', '#6fd3c0', '#f06a8d', '#94a8ff', '#f3d36f', '#7dc96d'] as const
|
|
|
|
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
|
status: 'ready',
|
|
mode: 'demo',
|
|
member: demoMember,
|
|
telegramUser: demoTelegramUser
|
|
}
|
|
|
|
function detectLocale(): Locale {
|
|
const telegramLocale = getTelegramWebApp()?.initDataUnsafe?.user?.language_code
|
|
const browserLocale = navigator.language.toLowerCase()
|
|
|
|
return (telegramLocale ?? browserLocale).startsWith('ru') ? 'ru' : 'en'
|
|
}
|
|
|
|
function joinContext(): {
|
|
joinToken?: string
|
|
botUsername?: string
|
|
} {
|
|
if (typeof window === 'undefined') {
|
|
return {}
|
|
}
|
|
|
|
const params = new URLSearchParams(window.location.search)
|
|
const joinToken = params.get('join')?.trim()
|
|
const botUsername = params.get('bot')?.trim()
|
|
|
|
return {
|
|
...(joinToken
|
|
? {
|
|
joinToken
|
|
}
|
|
: {}),
|
|
...(botUsername
|
|
? {
|
|
botUsername
|
|
}
|
|
: {})
|
|
}
|
|
}
|
|
|
|
function joinDeepLink(): string | null {
|
|
const context = joinContext()
|
|
if (!context.botUsername || !context.joinToken) {
|
|
return null
|
|
}
|
|
|
|
return `https://t.me/${context.botUsername}?start=join_${encodeURIComponent(context.joinToken)}`
|
|
}
|
|
|
|
function defaultCyclePeriod(): string {
|
|
return new Date().toISOString().slice(0, 7)
|
|
}
|
|
|
|
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 absoluteMinor(value: bigint): bigint {
|
|
return value < 0n ? -value : value
|
|
}
|
|
|
|
function memberBaseDueMajor(member: MiniAppDashboard['members'][number]): string {
|
|
return minorToMajorString(
|
|
majorStringToMinor(member.rentShareMajor) + majorStringToMinor(member.utilityShareMajor)
|
|
)
|
|
}
|
|
|
|
function memberRemainingClass(member: MiniAppDashboard['members'][number]): string {
|
|
const remainingMinor = majorStringToMinor(member.remainingMajor)
|
|
|
|
if (remainingMinor < 0n) {
|
|
return 'is-credit'
|
|
}
|
|
|
|
if (remainingMinor === 0n) {
|
|
return 'is-settled'
|
|
}
|
|
|
|
return 'is-due'
|
|
}
|
|
|
|
function ledgerPrimaryAmount(entry: MiniAppDashboard['ledger'][number]): string {
|
|
return `${entry.displayAmountMajor} ${entry.displayCurrency}`
|
|
}
|
|
|
|
function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): string | null {
|
|
if (entry.currency === entry.displayCurrency && entry.amountMajor === entry.displayAmountMajor) {
|
|
return null
|
|
}
|
|
|
|
return `${entry.amountMajor} ${entry.currency}`
|
|
}
|
|
|
|
function cycleUtilityBillDrafts(
|
|
bills: MiniAppAdminCycleState['utilityBills']
|
|
): Record<string, UtilityBillDraft> {
|
|
return Object.fromEntries(
|
|
bills.map((bill) => [
|
|
bill.id,
|
|
{
|
|
billName: bill.billName,
|
|
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
|
|
currency: bill.currency
|
|
}
|
|
])
|
|
)
|
|
}
|
|
|
|
function purchaseDrafts(
|
|
entries: readonly MiniAppDashboard['ledger'][number][]
|
|
): Record<string, PurchaseDraft> {
|
|
return Object.fromEntries(
|
|
entries
|
|
.filter((entry) => entry.kind === 'purchase')
|
|
.map((entry) => [
|
|
entry.id,
|
|
{
|
|
description: entry.title,
|
|
amountMajor: entry.amountMajor,
|
|
currency: entry.currency,
|
|
splitMode: entry.purchaseSplitMode ?? 'equal',
|
|
participants:
|
|
entry.purchaseParticipants
|
|
?.filter((participant) => participant.included)
|
|
.map((participant) => ({
|
|
memberId: participant.memberId,
|
|
shareAmountMajor: participant.shareAmountMajor ?? ''
|
|
})) ?? []
|
|
}
|
|
])
|
|
)
|
|
}
|
|
|
|
function purchaseDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PurchaseDraft {
|
|
return {
|
|
description: entry.title,
|
|
amountMajor: entry.amountMajor,
|
|
currency: entry.currency,
|
|
splitMode: entry.purchaseSplitMode ?? 'equal',
|
|
participants:
|
|
entry.purchaseParticipants
|
|
?.filter((participant) => participant.included)
|
|
.map((participant) => ({
|
|
memberId: participant.memberId,
|
|
shareAmountMajor: participant.shareAmountMajor ?? ''
|
|
})) ?? []
|
|
}
|
|
}
|
|
|
|
function paymentDrafts(
|
|
entries: readonly MiniAppDashboard['ledger'][number][]
|
|
): Record<string, PaymentDraft> {
|
|
return Object.fromEntries(
|
|
entries
|
|
.filter((entry) => entry.kind === 'payment')
|
|
.map((entry) => [
|
|
entry.id,
|
|
{
|
|
memberId: entry.memberId ?? '',
|
|
kind: entry.paymentKind ?? 'rent',
|
|
amountMajor: entry.amountMajor,
|
|
currency: entry.currency
|
|
}
|
|
])
|
|
)
|
|
}
|
|
|
|
function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PaymentDraft {
|
|
return {
|
|
memberId: entry.memberId ?? '',
|
|
kind: entry.paymentKind ?? 'rent',
|
|
amountMajor: entry.amountMajor,
|
|
currency: entry.currency
|
|
}
|
|
}
|
|
|
|
function App() {
|
|
const [locale, setLocale] = createSignal<Locale>('en')
|
|
const [session, setSession] = createSignal<SessionState>({
|
|
status: 'loading'
|
|
})
|
|
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
|
const [activeHouseSection, setActiveHouseSection] = createSignal<HouseSectionKey>('billing')
|
|
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
|
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
|
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
|
|
const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null)
|
|
const [joining, setJoining] = createSignal(false)
|
|
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
|
|
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
|
|
const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false)
|
|
const [, setSavingMemberDisplayNameId] = createSignal<string | null>(null)
|
|
const [, setSavingRentWeightMemberId] = createSignal<string | null>(null)
|
|
const [, setSavingMemberStatusId] = createSignal<string | null>(null)
|
|
const [, setSavingMemberAbsencePolicyId] = createSignal<string | null>(null)
|
|
const [savingMemberEditorId, setSavingMemberEditorId] = createSignal<string | null>(null)
|
|
const [displayNameDraft, setDisplayNameDraft] = createSignal('')
|
|
const [memberDisplayNameDrafts, setMemberDisplayNameDrafts] = createSignal<
|
|
Record<string, string>
|
|
>({})
|
|
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
|
|
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
|
|
Record<string, 'active' | 'away' | 'left'>
|
|
>({})
|
|
const [memberAbsencePolicyDrafts, setMemberAbsencePolicyDrafts] = createSignal<
|
|
Record<string, MiniAppMemberAbsencePolicy>
|
|
>({})
|
|
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
|
|
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
|
|
const [savingBillingSettings, setSavingBillingSettings] = createSignal(false)
|
|
const [savingCategorySlug, setSavingCategorySlug] = createSignal<string | null>(null)
|
|
const [openingCycle, setOpeningCycle] = createSignal(false)
|
|
const [closingCycle, setClosingCycle] = createSignal(false)
|
|
const [savingCycleRent, setSavingCycleRent] = createSignal(false)
|
|
const [savingUtilityBill, setSavingUtilityBill] = createSignal(false)
|
|
const [savingUtilityBillId, setSavingUtilityBillId] = createSignal<string | null>(null)
|
|
const [deletingUtilityBillId, setDeletingUtilityBillId] = createSignal<string | null>(null)
|
|
const [utilityBillDrafts, setUtilityBillDrafts] = createSignal<Record<string, UtilityBillDraft>>(
|
|
{}
|
|
)
|
|
const [purchaseDraftMap, setPurchaseDraftMap] = createSignal<Record<string, PurchaseDraft>>({})
|
|
const [paymentDraftMap, setPaymentDraftMap] = createSignal<Record<string, PaymentDraft>>({})
|
|
const [savingPurchaseId, setSavingPurchaseId] = createSignal<string | null>(null)
|
|
const [deletingPurchaseId, setDeletingPurchaseId] = createSignal<string | null>(null)
|
|
const [savingPaymentId, setSavingPaymentId] = createSignal<string | null>(null)
|
|
const [deletingPaymentId, setDeletingPaymentId] = createSignal<string | null>(null)
|
|
const [editingPurchaseId, setEditingPurchaseId] = createSignal<string | null>(null)
|
|
const [editingPaymentId, setEditingPaymentId] = createSignal<string | null>(null)
|
|
const [editingUtilityBillId, setEditingUtilityBillId] = createSignal<string | null>(null)
|
|
const [editingMemberId, setEditingMemberId] = createSignal<string | null>(null)
|
|
const [editingCategorySlug, setEditingCategorySlug] = createSignal<string | null>(null)
|
|
const [billingSettingsOpen, setBillingSettingsOpen] = createSignal(false)
|
|
const [cycleRentOpen, setCycleRentOpen] = createSignal(false)
|
|
const [addingUtilityBillOpen, setAddingUtilityBillOpen] = createSignal(false)
|
|
const [addingPaymentOpen, setAddingPaymentOpen] = createSignal(false)
|
|
const [profileEditorOpen, setProfileEditorOpen] = createSignal(false)
|
|
const [addingPayment, setAddingPayment] = createSignal(false)
|
|
const [billingForm, setBillingForm] = createSignal({
|
|
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
|
paymentBalanceAdjustmentPolicy: 'utilities' as 'utilities' | 'rent' | 'separate',
|
|
rentAmountMajor: '',
|
|
rentCurrency: 'USD' as 'USD' | 'GEL',
|
|
rentDueDay: 20,
|
|
rentWarningDay: 17,
|
|
utilitiesDueDay: 4,
|
|
utilitiesReminderDay: 3,
|
|
timezone: 'Asia/Tbilisi'
|
|
})
|
|
const [newCategoryName, setNewCategoryName] = createSignal('')
|
|
const [cycleForm, setCycleForm] = createSignal({
|
|
period: defaultCyclePeriod(),
|
|
rentCurrency: 'USD' as 'USD' | 'GEL',
|
|
utilityCurrency: 'GEL' as 'USD' | 'GEL',
|
|
rentAmountMajor: '',
|
|
utilityCategorySlug: '',
|
|
utilityAmountMajor: ''
|
|
})
|
|
const [paymentForm, setPaymentForm] = createSignal<PaymentDraft>({
|
|
memberId: '',
|
|
kind: 'rent',
|
|
amountMajor: '',
|
|
currency: 'GEL'
|
|
})
|
|
|
|
const copy = createMemo(() => dictionary[locale()])
|
|
const onboardingSession = createMemo(() => {
|
|
const current = session()
|
|
return current.status === 'onboarding' ? current : null
|
|
})
|
|
const blockedSession = createMemo(() => {
|
|
const current = session()
|
|
return current.status === 'blocked' ? current : null
|
|
})
|
|
const readySession = createMemo(() => {
|
|
const current = session()
|
|
return current.status === 'ready' ? current : null
|
|
})
|
|
const currentMemberLine = createMemo(() => {
|
|
const current = readySession()
|
|
const data = dashboard()
|
|
|
|
if (!current || !data) {
|
|
return null
|
|
}
|
|
|
|
return data.members.find((member) => member.memberId === current.member.id) ?? null
|
|
})
|
|
const purchaseLedger = createMemo(() =>
|
|
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'purchase')
|
|
)
|
|
const utilityLedger = createMemo(() =>
|
|
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'utility')
|
|
)
|
|
const paymentLedger = createMemo(() =>
|
|
(dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment')
|
|
)
|
|
const editingPurchaseEntry = createMemo(
|
|
() => purchaseLedger().find((entry) => entry.id === editingPurchaseId()) ?? null
|
|
)
|
|
const editingPaymentEntry = createMemo(
|
|
() => paymentLedger().find((entry) => entry.id === editingPaymentId()) ?? null
|
|
)
|
|
const editingUtilityBill = createMemo(
|
|
() => cycleState()?.utilityBills.find((bill) => bill.id === editingUtilityBillId()) ?? null
|
|
)
|
|
const editingMember = createMemo(
|
|
() => adminSettings()?.members.find((member) => member.id === editingMemberId()) ?? null
|
|
)
|
|
const editingCategory = createMemo(
|
|
() =>
|
|
adminSettings()?.categories.find((category) => category.slug === editingCategorySlug()) ??
|
|
null
|
|
)
|
|
const utilityTotalMajor = createMemo(() =>
|
|
minorToMajorString(
|
|
utilityLedger().reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n)
|
|
)
|
|
)
|
|
const purchaseTotalMajor = createMemo(() =>
|
|
minorToMajorString(
|
|
purchaseLedger().reduce(
|
|
(sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor),
|
|
0n
|
|
)
|
|
)
|
|
)
|
|
const memberBalanceVisuals = createMemo(() => {
|
|
const data = dashboard()
|
|
if (!data) {
|
|
return []
|
|
}
|
|
|
|
const totals = data.members.map((member) => {
|
|
const rentMinor = absoluteMinor(majorStringToMinor(member.rentShareMajor))
|
|
const utilityMinor = absoluteMinor(majorStringToMinor(member.utilityShareMajor))
|
|
const purchaseMinor = absoluteMinor(majorStringToMinor(member.purchaseOffsetMajor))
|
|
|
|
return {
|
|
member,
|
|
totalMinor: rentMinor + utilityMinor + purchaseMinor,
|
|
segments: [
|
|
{
|
|
key: 'rent',
|
|
label: copy().shareRent,
|
|
amountMajor: member.rentShareMajor,
|
|
amountMinor: rentMinor
|
|
},
|
|
{
|
|
key: 'utilities',
|
|
label: copy().shareUtilities,
|
|
amountMajor: member.utilityShareMajor,
|
|
amountMinor: utilityMinor
|
|
},
|
|
{
|
|
key:
|
|
majorStringToMinor(member.purchaseOffsetMajor) < 0n
|
|
? 'purchase-credit'
|
|
: 'purchase-debit',
|
|
label: copy().shareOffset,
|
|
amountMajor: member.purchaseOffsetMajor,
|
|
amountMinor: purchaseMinor
|
|
}
|
|
]
|
|
}
|
|
})
|
|
|
|
const maxTotalMinor = totals.reduce(
|
|
(max, item) => (item.totalMinor > max ? item.totalMinor : max),
|
|
0n
|
|
)
|
|
|
|
return totals
|
|
.sort((left, right) => {
|
|
const leftRemaining = majorStringToMinor(left.member.remainingMajor)
|
|
const rightRemaining = majorStringToMinor(right.member.remainingMajor)
|
|
|
|
if (rightRemaining === leftRemaining) {
|
|
return left.member.displayName.localeCompare(right.member.displayName)
|
|
}
|
|
|
|
return rightRemaining > leftRemaining ? 1 : -1
|
|
})
|
|
.map((item) => ({
|
|
...item,
|
|
barWidthPercent:
|
|
maxTotalMinor > 0n ? (Number(item.totalMinor) / Number(maxTotalMinor)) * 100 : 0,
|
|
segments: item.segments.map((segment) => ({
|
|
...segment,
|
|
widthPercent:
|
|
item.totalMinor > 0n ? (Number(segment.amountMinor) / Number(item.totalMinor)) * 100 : 0
|
|
}))
|
|
}))
|
|
})
|
|
const purchaseInvestmentChart = createMemo(() => {
|
|
const data = dashboard()
|
|
if (!data) {
|
|
return {
|
|
totalMajor: '0.00',
|
|
slices: []
|
|
}
|
|
}
|
|
|
|
const membersById = new Map(data.members.map((member) => [member.memberId, member.displayName]))
|
|
const totals = new Map<string, { label: string; amountMinor: bigint }>()
|
|
|
|
for (const entry of purchaseLedger()) {
|
|
const key = entry.memberId ?? entry.actorDisplayName ?? entry.id
|
|
const label =
|
|
(entry.memberId ? membersById.get(entry.memberId) : null) ??
|
|
entry.actorDisplayName ??
|
|
copy().ledgerActorFallback
|
|
const current = totals.get(key) ?? {
|
|
label,
|
|
amountMinor: 0n
|
|
}
|
|
|
|
totals.set(key, {
|
|
label,
|
|
amountMinor:
|
|
current.amountMinor + absoluteMinor(majorStringToMinor(entry.displayAmountMajor))
|
|
})
|
|
}
|
|
|
|
const items = [...totals.entries()]
|
|
.map(([key, value], index) => ({
|
|
key,
|
|
label: value.label,
|
|
amountMinor: value.amountMinor,
|
|
amountMajor: minorToMajorString(value.amountMinor),
|
|
color: chartPalette[index % chartPalette.length]!
|
|
}))
|
|
.filter((item) => item.amountMinor > 0n)
|
|
.sort((left, right) => (right.amountMinor > left.amountMinor ? 1 : -1))
|
|
|
|
const totalMinor = items.reduce((sum, item) => sum + item.amountMinor, 0n)
|
|
const circumference = 2 * Math.PI * 42
|
|
let offset = 0
|
|
|
|
return {
|
|
totalMajor: minorToMajorString(totalMinor),
|
|
slices: items.map((item) => {
|
|
const ratio = totalMinor > 0n ? Number(item.amountMinor) / Number(totalMinor) : 0
|
|
const dash = ratio * circumference
|
|
const slice = {
|
|
...item,
|
|
percentage: Math.round(ratio * 100),
|
|
dasharray: `${dash} ${Math.max(circumference - dash, 0)}`,
|
|
dashoffset: `${-offset}`
|
|
}
|
|
offset += dash
|
|
return slice
|
|
})
|
|
}
|
|
})
|
|
const webApp = getTelegramWebApp()
|
|
|
|
function ledgerTitle(entry: MiniAppDashboard['ledger'][number]): string {
|
|
if (entry.kind !== 'payment') {
|
|
return entry.title
|
|
}
|
|
|
|
return entry.paymentKind === 'utilities'
|
|
? copy().paymentLedgerUtilities
|
|
: copy().paymentLedgerRent
|
|
}
|
|
|
|
function purchaseParticipantSummary(entry: MiniAppDashboard['ledger'][number]): string {
|
|
if (entry.kind !== 'purchase') {
|
|
return ''
|
|
}
|
|
|
|
const includedCount =
|
|
entry.purchaseParticipants?.filter((participant) => participant.included).length ?? 0
|
|
const splitLabel =
|
|
entry.purchaseSplitMode === 'custom_amounts'
|
|
? copy().purchaseSplitCustom
|
|
: copy().purchaseSplitEqual
|
|
|
|
return `${includedCount} ${copy().participantsLabel} · ${splitLabel}`
|
|
}
|
|
|
|
function paymentMemberName(entry: MiniAppDashboard['ledger'][number]): string {
|
|
if (!entry.memberId) {
|
|
return entry.actorDisplayName ?? copy().ledgerActorFallback
|
|
}
|
|
|
|
return (
|
|
adminSettings()?.members.find((member) => member.id === entry.memberId)?.displayName ??
|
|
dashboard()?.members.find((member) => member.memberId === entry.memberId)?.displayName ??
|
|
entry.actorDisplayName ??
|
|
copy().ledgerActorFallback
|
|
)
|
|
}
|
|
|
|
function topicRoleLabel(role: 'purchase' | 'feedback' | 'reminders' | 'payments'): string {
|
|
switch (role) {
|
|
case 'purchase':
|
|
return copy().topicPurchase
|
|
case 'feedback':
|
|
return copy().topicFeedback
|
|
case 'reminders':
|
|
return copy().topicReminders
|
|
case 'payments':
|
|
return copy().topicPayments
|
|
}
|
|
}
|
|
|
|
function memberStatusLabel(status: 'active' | 'away' | 'left'): string {
|
|
switch (status) {
|
|
case 'active':
|
|
return copy().memberStatusActive
|
|
case 'away':
|
|
return copy().memberStatusAway
|
|
case 'left':
|
|
return copy().memberStatusLeft
|
|
}
|
|
}
|
|
|
|
function defaultAbsencePolicyForStatus(
|
|
status: 'active' | 'away' | 'left'
|
|
): MiniAppMemberAbsencePolicy {
|
|
if (status === 'away') {
|
|
return 'away_rent_and_utilities'
|
|
}
|
|
|
|
if (status === 'left') {
|
|
return 'inactive'
|
|
}
|
|
|
|
return 'resident'
|
|
}
|
|
|
|
function resolvedMemberAbsencePolicy(
|
|
memberId: string,
|
|
status: 'active' | 'away' | 'left',
|
|
settings = adminSettings()
|
|
) {
|
|
const current = settings?.memberAbsencePolicies
|
|
.filter((policy) => policy.memberId === memberId)
|
|
.sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod))
|
|
.at(-1)
|
|
|
|
return (
|
|
current ?? {
|
|
memberId,
|
|
effectiveFromPeriod: '',
|
|
policy: defaultAbsencePolicyForStatus(status)
|
|
}
|
|
)
|
|
}
|
|
|
|
function syncDisplayName(memberId: string, displayName: string) {
|
|
setSession((current) =>
|
|
current.status === 'ready' && current.member.id === memberId
|
|
? {
|
|
...current,
|
|
member: {
|
|
...current.member,
|
|
displayName
|
|
}
|
|
}
|
|
: current
|
|
)
|
|
setAdminSettings((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
members: current.members.map((member) =>
|
|
member.id === memberId
|
|
? {
|
|
...member,
|
|
displayName
|
|
}
|
|
: member
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
setDashboard((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
members: current.members.map((member) =>
|
|
member.memberId === memberId
|
|
? {
|
|
...member,
|
|
displayName
|
|
}
|
|
: member
|
|
),
|
|
ledger: current.ledger.map((entry) =>
|
|
entry.memberId === memberId
|
|
? {
|
|
...entry,
|
|
actorDisplayName: displayName
|
|
}
|
|
: entry
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
setDisplayNameDraft((current) =>
|
|
readySession()?.member.id === memberId ? displayName : current
|
|
)
|
|
setMemberDisplayNameDrafts((current) => ({
|
|
...current,
|
|
[memberId]: displayName
|
|
}))
|
|
}
|
|
|
|
function updatePurchaseDraft(
|
|
purchaseId: string,
|
|
entry: MiniAppDashboard['ledger'][number],
|
|
update: (draft: PurchaseDraft) => PurchaseDraft
|
|
) {
|
|
setPurchaseDraftMap((current) => {
|
|
const draft = current[purchaseId] ?? purchaseDraftForEntry(entry)
|
|
return {
|
|
...current,
|
|
[purchaseId]: update(draft)
|
|
}
|
|
})
|
|
}
|
|
|
|
function updatePaymentDraft(
|
|
paymentId: string,
|
|
entry: MiniAppDashboard['ledger'][number],
|
|
update: (draft: PaymentDraft) => PaymentDraft
|
|
) {
|
|
setPaymentDraftMap((current) => {
|
|
const draft = current[paymentId] ?? paymentDraftForEntry(entry)
|
|
return {
|
|
...current,
|
|
[paymentId]: update(draft)
|
|
}
|
|
})
|
|
}
|
|
|
|
function togglePurchaseParticipant(
|
|
purchaseId: string,
|
|
entry: MiniAppDashboard['ledger'][number],
|
|
memberId: string,
|
|
included: boolean
|
|
) {
|
|
updatePurchaseDraft(purchaseId, entry, (draft) => ({
|
|
...draft,
|
|
participants: included
|
|
? [
|
|
...draft.participants.filter((participant) => participant.memberId !== memberId),
|
|
{
|
|
memberId,
|
|
shareAmountMajor: ''
|
|
}
|
|
]
|
|
: draft.participants.filter((participant) => participant.memberId !== memberId)
|
|
}))
|
|
}
|
|
|
|
function updateUtilityBillDraft(
|
|
billId: string,
|
|
bill: MiniAppAdminCycleState['utilityBills'][number],
|
|
update: (draft: UtilityBillDraft) => UtilityBillDraft
|
|
) {
|
|
setUtilityBillDrafts((current) => {
|
|
const draft = current[billId] ?? {
|
|
billName: bill.billName,
|
|
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
|
|
currency: bill.currency
|
|
}
|
|
|
|
return {
|
|
...current,
|
|
[billId]: update(draft)
|
|
}
|
|
})
|
|
}
|
|
|
|
async function loadDashboard(initData: string) {
|
|
try {
|
|
const nextDashboard = await fetchDashboardQuery(initData)
|
|
setDashboard(nextDashboard)
|
|
setPurchaseDraftMap(purchaseDrafts(nextDashboard.ledger))
|
|
setPaymentDraftMap(paymentDrafts(nextDashboard.ledger))
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('Failed to load mini app dashboard', error)
|
|
}
|
|
|
|
setDashboard(null)
|
|
setPurchaseDraftMap({})
|
|
setPaymentDraftMap({})
|
|
}
|
|
}
|
|
|
|
async function loadPendingMembers(initData: string) {
|
|
try {
|
|
setPendingMembers(await fetchPendingMembersQuery(initData))
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('Failed to load pending mini app members', error)
|
|
}
|
|
|
|
setPendingMembers([])
|
|
}
|
|
}
|
|
|
|
async function loadAdminSettings(initData: string) {
|
|
try {
|
|
const payload = await fetchAdminSettingsQuery(initData)
|
|
setAdminSettings(payload)
|
|
setMemberDisplayNameDrafts(
|
|
Object.fromEntries(payload.members.map((member) => [member.id, member.displayName]))
|
|
)
|
|
setRentWeightDrafts(
|
|
Object.fromEntries(
|
|
payload.members.map((member) => [member.id, String(member.rentShareWeight)])
|
|
)
|
|
)
|
|
setMemberStatusDrafts(
|
|
Object.fromEntries(payload.members.map((member) => [member.id, member.status]))
|
|
)
|
|
setMemberAbsencePolicyDrafts(
|
|
Object.fromEntries(
|
|
payload.members.map((member) => [
|
|
member.id,
|
|
resolvedMemberAbsencePolicy(member.id, member.status, payload).policy
|
|
])
|
|
)
|
|
)
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
rentCurrency: payload.settings.rentCurrency,
|
|
utilityCurrency: payload.settings.settlementCurrency,
|
|
utilityCategorySlug:
|
|
current.utilityCategorySlug ||
|
|
payload.categories.find((category) => category.isActive)?.slug ||
|
|
''
|
|
}))
|
|
setBillingForm({
|
|
settlementCurrency: payload.settings.settlementCurrency,
|
|
paymentBalanceAdjustmentPolicy: payload.settings.paymentBalanceAdjustmentPolicy,
|
|
rentAmountMajor: payload.settings.rentAmountMinor
|
|
? (Number(payload.settings.rentAmountMinor) / 100).toFixed(2)
|
|
: '',
|
|
rentCurrency: payload.settings.rentCurrency,
|
|
rentDueDay: payload.settings.rentDueDay,
|
|
rentWarningDay: payload.settings.rentWarningDay,
|
|
utilitiesDueDay: payload.settings.utilitiesDueDay,
|
|
utilitiesReminderDay: payload.settings.utilitiesReminderDay,
|
|
timezone: payload.settings.timezone
|
|
})
|
|
setPaymentForm((current) => ({
|
|
...current,
|
|
memberId: current.memberId || payload.members[0]?.id || '',
|
|
currency: payload.settings.settlementCurrency
|
|
}))
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('Failed to load mini app admin settings', error)
|
|
}
|
|
|
|
setAdminSettings(null)
|
|
}
|
|
}
|
|
|
|
async function loadCycleState(initData: string) {
|
|
try {
|
|
const payload = await fetchBillingCycleQuery(initData)
|
|
setCycleState(payload)
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(payload.utilityBills))
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
period: payload.cycle?.period ?? current.period,
|
|
rentCurrency:
|
|
payload.rentRule?.currency ??
|
|
adminSettings()?.settings.rentCurrency ??
|
|
current.rentCurrency,
|
|
utilityCurrency: adminSettings()?.settings.settlementCurrency ?? current.utilityCurrency,
|
|
rentAmountMajor: payload.rentRule
|
|
? (Number(payload.rentRule.amountMinor) / 100).toFixed(2)
|
|
: '',
|
|
utilityCategorySlug:
|
|
current.utilityCategorySlug ||
|
|
adminSettings()?.categories.find((category) => category.isActive)?.slug ||
|
|
'',
|
|
utilityAmountMajor: current.utilityAmountMajor
|
|
}))
|
|
} catch (error) {
|
|
if (import.meta.env.DEV) {
|
|
console.warn('Failed to load mini app billing cycle', error)
|
|
}
|
|
|
|
setCycleState(null)
|
|
}
|
|
}
|
|
|
|
async function refreshHouseholdData(
|
|
initData: string,
|
|
includeAdmin = false,
|
|
forceRefresh = false
|
|
) {
|
|
if (forceRefresh) {
|
|
await invalidateHouseholdQueries(initData)
|
|
}
|
|
|
|
await loadDashboard(initData)
|
|
|
|
if (includeAdmin) {
|
|
await Promise.all([
|
|
loadAdminSettings(initData),
|
|
loadCycleState(initData),
|
|
loadPendingMembers(initData)
|
|
])
|
|
return
|
|
}
|
|
|
|
const currentReady = readySession()
|
|
if (currentReady?.mode === 'live' && currentReady.member.isAdmin) {
|
|
await Promise.all([
|
|
loadAdminSettings(initData),
|
|
loadCycleState(initData),
|
|
loadPendingMembers(initData)
|
|
])
|
|
}
|
|
}
|
|
|
|
function applyDemoState() {
|
|
setDisplayNameDraft(demoSession.member.displayName)
|
|
setSession(demoSession)
|
|
setDashboard(demoDashboard)
|
|
setPendingMembers([...demoPendingMembers])
|
|
setAdminSettings(demoAdminSettings)
|
|
setCycleState(demoCycleState)
|
|
setPurchaseDraftMap(purchaseDrafts(demoDashboard.ledger))
|
|
setPaymentDraftMap(paymentDrafts(demoDashboard.ledger))
|
|
setMemberDisplayNameDrafts(
|
|
Object.fromEntries(demoAdminSettings.members.map((member) => [member.id, member.displayName]))
|
|
)
|
|
setRentWeightDrafts(
|
|
Object.fromEntries(
|
|
demoAdminSettings.members.map((member) => [member.id, String(member.rentShareWeight)])
|
|
)
|
|
)
|
|
setMemberStatusDrafts(
|
|
Object.fromEntries(demoAdminSettings.members.map((member) => [member.id, member.status]))
|
|
)
|
|
setMemberAbsencePolicyDrafts(
|
|
Object.fromEntries(
|
|
demoAdminSettings.members.map((member) => [
|
|
member.id,
|
|
resolvedMemberAbsencePolicy(member.id, member.status, demoAdminSettings).policy
|
|
])
|
|
)
|
|
)
|
|
setBillingForm({
|
|
settlementCurrency: demoAdminSettings.settings.settlementCurrency,
|
|
paymentBalanceAdjustmentPolicy: demoAdminSettings.settings.paymentBalanceAdjustmentPolicy,
|
|
rentAmountMajor: demoAdminSettings.settings.rentAmountMinor
|
|
? (Number(demoAdminSettings.settings.rentAmountMinor) / 100).toFixed(2)
|
|
: '',
|
|
rentCurrency: demoAdminSettings.settings.rentCurrency,
|
|
rentDueDay: demoAdminSettings.settings.rentDueDay,
|
|
rentWarningDay: demoAdminSettings.settings.rentWarningDay,
|
|
utilitiesDueDay: demoAdminSettings.settings.utilitiesDueDay,
|
|
utilitiesReminderDay: demoAdminSettings.settings.utilitiesReminderDay,
|
|
timezone: demoAdminSettings.settings.timezone
|
|
})
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
period: demoCycleState.cycle?.period ?? current.period,
|
|
rentCurrency: demoAdminSettings.settings.rentCurrency,
|
|
utilityCurrency: demoAdminSettings.settings.settlementCurrency,
|
|
rentAmountMajor: demoAdminSettings.settings.rentAmountMinor
|
|
? (Number(demoAdminSettings.settings.rentAmountMinor) / 100).toFixed(2)
|
|
: '',
|
|
utilityCategorySlug:
|
|
demoAdminSettings.categories.find((category) => category.isActive)?.slug ?? '',
|
|
utilityAmountMajor: ''
|
|
}))
|
|
setPaymentForm({
|
|
memberId: demoAdminSettings.members[0]?.id ?? '',
|
|
kind: 'rent',
|
|
amountMajor: '',
|
|
currency: demoAdminSettings.settings.settlementCurrency
|
|
})
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(demoCycleState.utilityBills))
|
|
}
|
|
|
|
async function bootstrap() {
|
|
const fallbackLocale = detectLocale()
|
|
setLocale(fallbackLocale)
|
|
|
|
webApp?.ready?.()
|
|
webApp?.expand?.()
|
|
|
|
const initData = webApp?.initData?.trim()
|
|
if (!initData) {
|
|
if (import.meta.env.DEV) {
|
|
applyDemoState()
|
|
return
|
|
}
|
|
|
|
setSession({
|
|
status: 'blocked',
|
|
reason: 'telegram_only'
|
|
})
|
|
return
|
|
}
|
|
|
|
try {
|
|
const payload = await fetchSessionQuery(initData, joinContext().joinToken)
|
|
if (!payload.authorized || !payload.member || !payload.telegramUser) {
|
|
setLocale(
|
|
payload.onboarding?.householdDefaultLocale ??
|
|
((payload.telegramUser?.languageCode ?? fallbackLocale).startsWith('ru') ? 'ru' : 'en')
|
|
)
|
|
setSession({
|
|
status: 'onboarding',
|
|
mode: payload.onboarding?.status ?? 'open_from_group',
|
|
...(payload.onboarding?.householdName
|
|
? {
|
|
householdName: payload.onboarding.householdName
|
|
}
|
|
: {}),
|
|
telegramUser: payload.telegramUser ?? {
|
|
firstName: null,
|
|
username: null,
|
|
languageCode: null
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
|
setDisplayNameDraft(payload.member.displayName)
|
|
setSession({
|
|
status: 'ready',
|
|
mode: 'live',
|
|
member: payload.member,
|
|
telegramUser: payload.telegramUser
|
|
})
|
|
|
|
await loadDashboard(initData)
|
|
if (payload.member.isAdmin) {
|
|
await loadPendingMembers(initData)
|
|
await loadAdminSettings(initData)
|
|
await loadCycleState(initData)
|
|
} else {
|
|
setAdminSettings(null)
|
|
setCycleState(null)
|
|
}
|
|
} catch {
|
|
if (import.meta.env.DEV) {
|
|
applyDemoState()
|
|
return
|
|
}
|
|
|
|
setSession({
|
|
status: 'blocked',
|
|
reason: 'error'
|
|
})
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
void bootstrap()
|
|
})
|
|
|
|
async function handleJoinHousehold() {
|
|
const initData = webApp?.initData?.trim()
|
|
const joinToken = joinContext().joinToken
|
|
|
|
if (!initData || !joinToken || joining()) {
|
|
return
|
|
}
|
|
|
|
setJoining(true)
|
|
|
|
try {
|
|
const payload = await joinMiniAppHousehold(initData, joinToken)
|
|
if (payload.authorized && payload.member && payload.telegramUser) {
|
|
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
|
|
setDisplayNameDraft(payload.member.displayName)
|
|
setSession({
|
|
status: 'ready',
|
|
mode: 'live',
|
|
member: payload.member,
|
|
telegramUser: payload.telegramUser
|
|
})
|
|
await loadDashboard(initData)
|
|
if (payload.member.isAdmin) {
|
|
await loadPendingMembers(initData)
|
|
await loadAdminSettings(initData)
|
|
await loadCycleState(initData)
|
|
} else {
|
|
setAdminSettings(null)
|
|
setCycleState(null)
|
|
}
|
|
return
|
|
}
|
|
|
|
setLocale(
|
|
payload.onboarding?.householdDefaultLocale ??
|
|
((payload.telegramUser?.languageCode ?? locale()).startsWith('ru') ? 'ru' : 'en')
|
|
)
|
|
setSession({
|
|
status: 'onboarding',
|
|
mode: payload.onboarding?.status ?? 'pending',
|
|
...(payload.onboarding?.householdName
|
|
? {
|
|
householdName: payload.onboarding.householdName
|
|
}
|
|
: {}),
|
|
telegramUser: payload.telegramUser ?? {
|
|
firstName: null,
|
|
username: null,
|
|
languageCode: null
|
|
}
|
|
})
|
|
} catch {
|
|
setSession({
|
|
status: 'blocked',
|
|
reason: 'error'
|
|
})
|
|
} finally {
|
|
setJoining(false)
|
|
}
|
|
}
|
|
|
|
async function handleApprovePendingMember(pendingTelegramUserId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
if (!initData || approvingTelegramUserId()) {
|
|
return
|
|
}
|
|
|
|
setApprovingTelegramUserId(pendingTelegramUserId)
|
|
|
|
try {
|
|
await approveMiniAppPendingMember(initData, pendingTelegramUserId)
|
|
setPendingMembers((current) =>
|
|
current.filter((member) => member.telegramUserId !== pendingTelegramUserId)
|
|
)
|
|
} finally {
|
|
setApprovingTelegramUserId(null)
|
|
}
|
|
}
|
|
|
|
async function handleMemberLocaleChange(nextLocale: Locale) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
|
|
setLocale(nextLocale)
|
|
|
|
if (!initData || currentReady?.mode !== 'live') {
|
|
return
|
|
}
|
|
|
|
setSavingMemberLocale(true)
|
|
|
|
try {
|
|
const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'member')
|
|
|
|
setSession((current) =>
|
|
current.status === 'ready'
|
|
? {
|
|
...current,
|
|
member: {
|
|
...current.member,
|
|
preferredLocale: updated.memberPreferredLocale,
|
|
householdDefaultLocale: updated.householdDefaultLocale
|
|
}
|
|
}
|
|
: current
|
|
)
|
|
setLocale(updated.effectiveLocale)
|
|
} finally {
|
|
setSavingMemberLocale(false)
|
|
}
|
|
}
|
|
|
|
async function handleHouseholdLocaleChange(nextLocale: Locale) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setSavingHouseholdLocale(true)
|
|
|
|
try {
|
|
const updated = await updateMiniAppLocalePreference(initData, nextLocale, 'household')
|
|
|
|
setSession((current) =>
|
|
current.status === 'ready'
|
|
? {
|
|
...current,
|
|
member: {
|
|
...current.member,
|
|
householdDefaultLocale: updated.householdDefaultLocale
|
|
}
|
|
}
|
|
: current
|
|
)
|
|
|
|
if (!currentReady.member.preferredLocale) {
|
|
setLocale(updated.effectiveLocale)
|
|
}
|
|
} finally {
|
|
setSavingHouseholdLocale(false)
|
|
}
|
|
}
|
|
|
|
async function handleSaveOwnDisplayName() {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const nextDisplayName = displayNameDraft().trim()
|
|
if (!initData || currentReady?.mode !== 'live' || nextDisplayName.length === 0) {
|
|
return
|
|
}
|
|
|
|
setSavingOwnDisplayName(true)
|
|
|
|
try {
|
|
const updatedMember = await updateMiniAppOwnDisplayName(initData, nextDisplayName)
|
|
syncDisplayName(updatedMember.id, updatedMember.displayName)
|
|
} finally {
|
|
setSavingOwnDisplayName(false)
|
|
}
|
|
}
|
|
|
|
async function handleSaveMemberDisplayName(memberId: string, closeEditor = true) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const nextDisplayName = memberDisplayNameDrafts()[memberId]?.trim()
|
|
if (
|
|
!initData ||
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
!nextDisplayName
|
|
) {
|
|
return
|
|
}
|
|
|
|
setSavingMemberDisplayNameId(memberId)
|
|
|
|
try {
|
|
const updatedMember = await updateMiniAppMemberDisplayName(
|
|
initData,
|
|
memberId,
|
|
nextDisplayName
|
|
)
|
|
syncDisplayName(updatedMember.id, updatedMember.displayName)
|
|
if (closeEditor) {
|
|
setEditingMemberId(null)
|
|
}
|
|
} finally {
|
|
setSavingMemberDisplayNameId(null)
|
|
}
|
|
}
|
|
|
|
async function handleSaveBillingSettings() {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setSavingBillingSettings(true)
|
|
|
|
try {
|
|
const settings = await updateMiniAppBillingSettings(initData, billingForm())
|
|
setAdminSettings((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
settings
|
|
}
|
|
: current
|
|
)
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
rentCurrency: settings.rentCurrency,
|
|
utilityCurrency: settings.settlementCurrency
|
|
}))
|
|
setBillingSettingsOpen(false)
|
|
} finally {
|
|
setSavingBillingSettings(false)
|
|
}
|
|
}
|
|
|
|
async function handleOpenCycle() {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setOpeningCycle(true)
|
|
|
|
try {
|
|
const state = await openMiniAppBillingCycle(initData, {
|
|
period: cycleForm().period,
|
|
currency: billingForm().settlementCurrency
|
|
})
|
|
setCycleState(state)
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
period: state.cycle?.period ?? current.period,
|
|
utilityCurrency: billingForm().settlementCurrency
|
|
}))
|
|
setCycleRentOpen(false)
|
|
} finally {
|
|
setOpeningCycle(false)
|
|
}
|
|
}
|
|
|
|
async function handleCloseCycle() {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setClosingCycle(true)
|
|
|
|
try {
|
|
const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period)
|
|
setCycleState(state)
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
|
setCycleRentOpen(false)
|
|
} finally {
|
|
setClosingCycle(false)
|
|
}
|
|
}
|
|
|
|
async function handleSaveCycleRent() {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setSavingCycleRent(true)
|
|
|
|
try {
|
|
const state = await updateMiniAppCycleRent(initData, {
|
|
amountMajor: cycleForm().rentAmountMajor,
|
|
currency: cycleForm().rentCurrency,
|
|
...(cycleState()?.cycle?.period
|
|
? {
|
|
period: cycleState()!.cycle!.period
|
|
}
|
|
: {})
|
|
})
|
|
setCycleState(state)
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
|
setCycleRentOpen(false)
|
|
} finally {
|
|
setSavingCycleRent(false)
|
|
}
|
|
}
|
|
|
|
async function handleAddUtilityBill() {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
const selectedCategory =
|
|
adminSettings()?.categories.find(
|
|
(category) => category.slug === cycleForm().utilityCategorySlug
|
|
) ?? adminSettings()?.categories.find((category) => category.isActive)
|
|
|
|
if (!selectedCategory || cycleForm().utilityAmountMajor.trim().length === 0) {
|
|
return
|
|
}
|
|
|
|
setSavingUtilityBill(true)
|
|
|
|
try {
|
|
const state = await addMiniAppUtilityBill(initData, {
|
|
billName: selectedCategory.name,
|
|
amountMajor: cycleForm().utilityAmountMajor,
|
|
currency: cycleForm().utilityCurrency
|
|
})
|
|
setCycleState(state)
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
utilityAmountMajor: ''
|
|
}))
|
|
setAddingUtilityBillOpen(false)
|
|
} finally {
|
|
setSavingUtilityBill(false)
|
|
}
|
|
}
|
|
|
|
async function handleUpdateUtilityBill(billId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const draft = utilityBillDrafts()[billId]
|
|
|
|
if (
|
|
!initData ||
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
!draft ||
|
|
draft.billName.trim().length === 0 ||
|
|
draft.amountMajor.trim().length === 0
|
|
) {
|
|
return
|
|
}
|
|
|
|
setSavingUtilityBillId(billId)
|
|
|
|
try {
|
|
const state = await updateMiniAppUtilityBill(initData, {
|
|
billId,
|
|
billName: draft.billName,
|
|
amountMajor: draft.amountMajor,
|
|
currency: draft.currency
|
|
})
|
|
setCycleState(state)
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
|
setEditingUtilityBillId(null)
|
|
} finally {
|
|
setSavingUtilityBillId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDeleteUtilityBill(billId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setDeletingUtilityBillId(billId)
|
|
|
|
try {
|
|
const state = await deleteMiniAppUtilityBill(initData, billId)
|
|
setCycleState(state)
|
|
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
|
|
setEditingUtilityBillId((current) => (current === billId ? null : current))
|
|
} finally {
|
|
setDeletingUtilityBillId(null)
|
|
}
|
|
}
|
|
|
|
async function handleUpdatePurchase(purchaseId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const draft = purchaseDraftMap()[purchaseId]
|
|
|
|
if (
|
|
!initData ||
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
!draft ||
|
|
draft.description.trim().length === 0 ||
|
|
draft.amountMajor.trim().length === 0 ||
|
|
draft.participants.length === 0 ||
|
|
(draft.splitMode === 'custom_amounts' &&
|
|
draft.participants.some((participant) => participant.shareAmountMajor.trim().length === 0))
|
|
) {
|
|
return
|
|
}
|
|
|
|
setSavingPurchaseId(purchaseId)
|
|
|
|
try {
|
|
await updateMiniAppPurchase(initData, {
|
|
purchaseId,
|
|
description: draft.description,
|
|
amountMajor: draft.amountMajor,
|
|
currency: draft.currency,
|
|
split: {
|
|
mode: draft.splitMode,
|
|
participants: (adminSettings()?.members ?? []).map((member) => {
|
|
const participant = draft.participants.find(
|
|
(currentParticipant) => currentParticipant.memberId === member.id
|
|
)
|
|
|
|
return {
|
|
memberId: member.id,
|
|
included: Boolean(participant),
|
|
...(draft.splitMode === 'custom_amounts' && participant
|
|
? {
|
|
shareAmountMajor: participant.shareAmountMajor
|
|
}
|
|
: {})
|
|
}
|
|
})
|
|
}
|
|
})
|
|
await refreshHouseholdData(initData, true, true)
|
|
setEditingPurchaseId(null)
|
|
} finally {
|
|
setSavingPurchaseId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDeletePurchase(purchaseId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setDeletingPurchaseId(purchaseId)
|
|
|
|
try {
|
|
await deleteMiniAppPurchase(initData, purchaseId)
|
|
await refreshHouseholdData(initData, true, true)
|
|
setEditingPurchaseId((current) => (current === purchaseId ? null : current))
|
|
} finally {
|
|
setDeletingPurchaseId(null)
|
|
}
|
|
}
|
|
|
|
async function handleAddPayment() {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const draft = paymentForm()
|
|
if (
|
|
!initData ||
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
draft.memberId.trim().length === 0 ||
|
|
draft.amountMajor.trim().length === 0
|
|
) {
|
|
return
|
|
}
|
|
|
|
setAddingPayment(true)
|
|
|
|
try {
|
|
await addMiniAppPayment(initData, draft)
|
|
setPaymentForm((current) => ({
|
|
...current,
|
|
amountMajor: ''
|
|
}))
|
|
await refreshHouseholdData(initData, true, true)
|
|
setAddingPaymentOpen(false)
|
|
} finally {
|
|
setAddingPayment(false)
|
|
}
|
|
}
|
|
|
|
async function handleUpdatePayment(paymentId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const draft = paymentDraftMap()[paymentId]
|
|
if (
|
|
!initData ||
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
!draft ||
|
|
draft.memberId.trim().length === 0 ||
|
|
draft.amountMajor.trim().length === 0
|
|
) {
|
|
return
|
|
}
|
|
|
|
setSavingPaymentId(paymentId)
|
|
|
|
try {
|
|
await updateMiniAppPayment(initData, {
|
|
paymentId,
|
|
memberId: draft.memberId,
|
|
kind: draft.kind,
|
|
amountMajor: draft.amountMajor,
|
|
currency: draft.currency
|
|
})
|
|
await refreshHouseholdData(initData, true, true)
|
|
setEditingPaymentId(null)
|
|
} finally {
|
|
setSavingPaymentId(null)
|
|
}
|
|
}
|
|
|
|
async function handleDeletePayment(paymentId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setDeletingPaymentId(paymentId)
|
|
|
|
try {
|
|
await deleteMiniAppPayment(initData, paymentId)
|
|
await refreshHouseholdData(initData, true, true)
|
|
setEditingPaymentId((current) => (current === paymentId ? null : current))
|
|
} finally {
|
|
setDeletingPaymentId(null)
|
|
}
|
|
}
|
|
|
|
async function handleSaveUtilityCategory(input: {
|
|
slug?: string
|
|
name: string
|
|
sortOrder: number
|
|
isActive: boolean
|
|
}) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setSavingCategorySlug(input.slug ?? '__new__')
|
|
|
|
try {
|
|
const category = await upsertMiniAppUtilityCategory(initData, input)
|
|
setAdminSettings((current) => {
|
|
if (!current) {
|
|
return current
|
|
}
|
|
|
|
const categories = current.categories.some((item) => item.slug === category.slug)
|
|
? current.categories.map((item) => (item.slug === category.slug ? category : item))
|
|
: [...current.categories, category]
|
|
|
|
return {
|
|
...current,
|
|
categories: [...categories].sort((left, right) => left.sortOrder - right.sortOrder)
|
|
}
|
|
})
|
|
|
|
if (!input.slug) {
|
|
setNewCategoryName('')
|
|
}
|
|
|
|
setEditingCategorySlug(null)
|
|
} finally {
|
|
setSavingCategorySlug(null)
|
|
}
|
|
}
|
|
|
|
async function handlePromoteMember(memberId: string) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
|
|
return
|
|
}
|
|
|
|
setPromotingMemberId(memberId)
|
|
|
|
try {
|
|
const member = await promoteMiniAppMember(initData, memberId)
|
|
setAdminSettings((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
members: current.members.map((item) => (item.id === member.id ? member : item))
|
|
}
|
|
: current
|
|
)
|
|
setRentWeightDrafts((current) => ({
|
|
...current,
|
|
[member.id]: String(member.rentShareWeight)
|
|
}))
|
|
setEditingMemberId(null)
|
|
} finally {
|
|
setPromotingMemberId(null)
|
|
}
|
|
}
|
|
|
|
async function handleSaveRentWeight(memberId: string, closeEditor = true) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const nextWeight = Number(rentWeightDrafts()[memberId] ?? '')
|
|
if (
|
|
!initData ||
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
!Number.isInteger(nextWeight) ||
|
|
nextWeight <= 0
|
|
) {
|
|
return
|
|
}
|
|
|
|
setSavingRentWeightMemberId(memberId)
|
|
|
|
try {
|
|
const member = await updateMiniAppMemberRentWeight(initData, memberId, nextWeight)
|
|
setAdminSettings((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
members: current.members.map((item) => (item.id === member.id ? member : item))
|
|
}
|
|
: current
|
|
)
|
|
setRentWeightDrafts((current) => ({
|
|
...current,
|
|
[member.id]: String(member.rentShareWeight)
|
|
}))
|
|
if (closeEditor) {
|
|
setEditingMemberId(null)
|
|
}
|
|
} finally {
|
|
setSavingRentWeightMemberId(null)
|
|
}
|
|
}
|
|
|
|
async function handleSaveMemberStatus(memberId: string, closeEditor = true) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const nextStatus = memberStatusDrafts()[memberId]
|
|
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin || !nextStatus) {
|
|
return
|
|
}
|
|
|
|
setSavingMemberStatusId(memberId)
|
|
|
|
try {
|
|
const member = await updateMiniAppMemberStatus(initData, memberId, nextStatus)
|
|
setAdminSettings((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
members: current.members.map((item) => (item.id === member.id ? member : item))
|
|
}
|
|
: current
|
|
)
|
|
setMemberStatusDrafts((current) => ({
|
|
...current,
|
|
[member.id]: member.status
|
|
}))
|
|
setMemberAbsencePolicyDrafts((current) => ({
|
|
...current,
|
|
[member.id]:
|
|
current[member.id] ??
|
|
resolvedMemberAbsencePolicy(member.id, member.status).policy ??
|
|
defaultAbsencePolicyForStatus(member.status)
|
|
}))
|
|
if (closeEditor) {
|
|
setEditingMemberId(null)
|
|
}
|
|
} finally {
|
|
setSavingMemberStatusId(null)
|
|
}
|
|
}
|
|
|
|
async function handleSaveMemberAbsencePolicy(memberId: string, closeEditor = true) {
|
|
const initData = webApp?.initData?.trim()
|
|
const currentReady = readySession()
|
|
const member = adminSettings()?.members.find((entry) => entry.id === memberId)
|
|
const nextPolicy = memberAbsencePolicyDrafts()[memberId]
|
|
const effectiveStatus = memberStatusDrafts()[memberId] ?? member?.status
|
|
|
|
if (
|
|
!initData ||
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
!member ||
|
|
!nextPolicy ||
|
|
effectiveStatus !== 'away'
|
|
) {
|
|
return
|
|
}
|
|
|
|
setSavingMemberAbsencePolicyId(memberId)
|
|
|
|
try {
|
|
const savedPolicy = await updateMiniAppMemberAbsencePolicy(initData, memberId, nextPolicy)
|
|
setAdminSettings((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
memberAbsencePolicies: [
|
|
...current.memberAbsencePolicies.filter(
|
|
(policy) =>
|
|
!(
|
|
policy.memberId === savedPolicy.memberId &&
|
|
policy.effectiveFromPeriod === savedPolicy.effectiveFromPeriod
|
|
)
|
|
),
|
|
savedPolicy
|
|
]
|
|
}
|
|
: current
|
|
)
|
|
setMemberAbsencePolicyDrafts((current) => ({
|
|
...current,
|
|
[memberId]: savedPolicy.policy
|
|
}))
|
|
if (closeEditor) {
|
|
setEditingMemberId(null)
|
|
}
|
|
} finally {
|
|
setSavingMemberAbsencePolicyId(null)
|
|
}
|
|
}
|
|
|
|
async function handleSaveMemberChanges(memberId: string) {
|
|
const currentReady = readySession()
|
|
const member = adminSettings()?.members.find((entry) => entry.id === memberId)
|
|
const nextDisplayName = memberDisplayNameDrafts()[memberId]?.trim() ?? member?.displayName ?? ''
|
|
const nextStatus = memberStatusDrafts()[memberId] ?? member?.status
|
|
const nextPolicy = memberAbsencePolicyDrafts()[memberId]
|
|
const nextWeight = Number(rentWeightDrafts()[memberId] ?? member?.rentShareWeight ?? 0)
|
|
|
|
if (
|
|
currentReady?.mode !== 'live' ||
|
|
!currentReady.member.isAdmin ||
|
|
!member ||
|
|
nextDisplayName.length < 2 ||
|
|
!nextStatus ||
|
|
!Number.isInteger(nextWeight) ||
|
|
nextWeight <= 0 ||
|
|
savingMemberEditorId() === memberId
|
|
) {
|
|
return
|
|
}
|
|
|
|
const currentPolicy = resolvedMemberAbsencePolicy(member.id, member.status).policy
|
|
const wantsAwayPolicySave = nextStatus === 'away' && nextPolicy && nextPolicy !== currentPolicy
|
|
const hasNameChange = nextDisplayName !== member.displayName
|
|
const hasStatusChange = nextStatus !== member.status
|
|
const hasWeightChange = nextWeight !== member.rentShareWeight
|
|
|
|
if (!hasNameChange && !hasStatusChange && !wantsAwayPolicySave && !hasWeightChange) {
|
|
return
|
|
}
|
|
|
|
setSavingMemberEditorId(memberId)
|
|
|
|
try {
|
|
if (hasNameChange) {
|
|
await handleSaveMemberDisplayName(memberId, false)
|
|
}
|
|
|
|
if (hasStatusChange) {
|
|
await handleSaveMemberStatus(memberId, false)
|
|
}
|
|
|
|
if (wantsAwayPolicySave) {
|
|
await handleSaveMemberAbsencePolicy(memberId, false)
|
|
}
|
|
|
|
if (hasWeightChange) {
|
|
await handleSaveRentWeight(memberId, false)
|
|
}
|
|
|
|
setEditingMemberId(null)
|
|
} finally {
|
|
setSavingMemberEditorId(null)
|
|
}
|
|
}
|
|
|
|
function purchaseSplitPreview(purchaseId: string): { memberId: string; amountMajor: string }[] {
|
|
const draft = purchaseDraftMap()[purchaseId]
|
|
if (!draft || draft.participants.length === 0) {
|
|
return []
|
|
}
|
|
|
|
if (draft.splitMode === 'custom_amounts') {
|
|
return draft.participants.map((participant) => ({
|
|
memberId: participant.memberId,
|
|
amountMajor: participant.shareAmountMajor
|
|
}))
|
|
}
|
|
|
|
const totalMinor = majorStringToMinor(draft.amountMajor)
|
|
const count = BigInt(draft.participants.length)
|
|
if (count <= 0n) {
|
|
return []
|
|
}
|
|
|
|
const base = totalMinor / count
|
|
const remainder = totalMinor % count
|
|
|
|
return draft.participants.map((participant, index) => ({
|
|
memberId: participant.memberId,
|
|
amountMajor: minorToMajorString(base + (BigInt(index) < remainder ? 1n : 0n))
|
|
}))
|
|
}
|
|
|
|
const renderPanel = () => {
|
|
switch (activeNav()) {
|
|
case 'balances':
|
|
return (
|
|
<BalancesScreen
|
|
copy={copy()}
|
|
dashboard={dashboard()}
|
|
currentMemberLine={currentMemberLine()}
|
|
utilityTotalMajor={utilityTotalMajor()}
|
|
purchaseTotalMajor={purchaseTotalMajor()}
|
|
memberBalanceVisuals={memberBalanceVisuals()}
|
|
purchaseChart={purchaseInvestmentChart()}
|
|
memberBaseDueMajor={memberBaseDueMajor}
|
|
memberRemainingClass={memberRemainingClass}
|
|
/>
|
|
)
|
|
case 'ledger':
|
|
return (
|
|
<LedgerScreen
|
|
copy={copy()}
|
|
dashboard={dashboard()}
|
|
readyIsAdmin={readySession()?.member.isAdmin === true}
|
|
adminMembers={adminSettings()?.members ?? []}
|
|
purchaseEntries={purchaseLedger()}
|
|
utilityEntries={utilityLedger()}
|
|
paymentEntries={paymentLedger()}
|
|
editingPurchaseEntry={editingPurchaseEntry()}
|
|
editingPaymentEntry={editingPaymentEntry()}
|
|
purchaseDraftMap={purchaseDraftMap()}
|
|
paymentDraftMap={paymentDraftMap()}
|
|
paymentForm={paymentForm()}
|
|
addingPaymentOpen={addingPaymentOpen()}
|
|
savingPurchaseId={savingPurchaseId()}
|
|
deletingPurchaseId={deletingPurchaseId()}
|
|
savingPaymentId={savingPaymentId()}
|
|
deletingPaymentId={deletingPaymentId()}
|
|
addingPayment={addingPayment()}
|
|
ledgerTitle={ledgerTitle}
|
|
ledgerPrimaryAmount={ledgerPrimaryAmount}
|
|
ledgerSecondaryAmount={ledgerSecondaryAmount}
|
|
purchaseParticipantSummary={purchaseParticipantSummary}
|
|
purchaseDraftForEntry={purchaseDraftForEntry}
|
|
paymentDraftForEntry={paymentDraftForEntry}
|
|
purchaseSplitPreview={purchaseSplitPreview}
|
|
paymentMemberName={paymentMemberName}
|
|
onOpenPurchaseEditor={setEditingPurchaseId}
|
|
onClosePurchaseEditor={() => setEditingPurchaseId(null)}
|
|
onDeletePurchase={handleDeletePurchase}
|
|
onSavePurchase={handleUpdatePurchase}
|
|
onPurchaseDescriptionChange={(purchaseId, entry, value) =>
|
|
updatePurchaseDraft(purchaseId, entry, (current) => ({
|
|
...current,
|
|
description: value
|
|
}))
|
|
}
|
|
onPurchaseAmountChange={(purchaseId, entry, value) =>
|
|
updatePurchaseDraft(purchaseId, entry, (current) => ({
|
|
...current,
|
|
amountMajor: value
|
|
}))
|
|
}
|
|
onPurchaseCurrencyChange={(purchaseId, entry, value) =>
|
|
updatePurchaseDraft(purchaseId, entry, (current) => ({
|
|
...current,
|
|
currency: value
|
|
}))
|
|
}
|
|
onPurchaseSplitModeChange={(purchaseId, entry, value) =>
|
|
updatePurchaseDraft(purchaseId, entry, (current) => ({
|
|
...current,
|
|
splitMode: value
|
|
}))
|
|
}
|
|
onTogglePurchaseParticipant={togglePurchaseParticipant}
|
|
onPurchaseParticipantShareChange={(purchaseId, entry, memberId, value) =>
|
|
updatePurchaseDraft(purchaseId, entry, (current) => ({
|
|
...current,
|
|
participants: current.participants.map((participant) =>
|
|
participant.memberId === memberId
|
|
? {
|
|
...participant,
|
|
shareAmountMajor: value
|
|
}
|
|
: participant
|
|
)
|
|
}))
|
|
}
|
|
onOpenAddPayment={() => setAddingPaymentOpen(true)}
|
|
onCloseAddPayment={() => setAddingPaymentOpen(false)}
|
|
onAddPayment={handleAddPayment}
|
|
onPaymentFormMemberChange={(value) =>
|
|
setPaymentForm((current) => ({
|
|
...current,
|
|
memberId: value
|
|
}))
|
|
}
|
|
onPaymentFormKindChange={(value) =>
|
|
setPaymentForm((current) => ({
|
|
...current,
|
|
kind: value
|
|
}))
|
|
}
|
|
onPaymentFormAmountChange={(value) =>
|
|
setPaymentForm((current) => ({
|
|
...current,
|
|
amountMajor: value
|
|
}))
|
|
}
|
|
onPaymentFormCurrencyChange={(value) =>
|
|
setPaymentForm((current) => ({
|
|
...current,
|
|
currency: value
|
|
}))
|
|
}
|
|
onOpenPaymentEditor={setEditingPaymentId}
|
|
onClosePaymentEditor={() => setEditingPaymentId(null)}
|
|
onDeletePayment={handleDeletePayment}
|
|
onSavePayment={handleUpdatePayment}
|
|
onPaymentDraftMemberChange={(paymentId, entry, value) =>
|
|
updatePaymentDraft(paymentId, entry, (current) => ({
|
|
...current,
|
|
memberId: value
|
|
}))
|
|
}
|
|
onPaymentDraftKindChange={(paymentId, entry, value) =>
|
|
updatePaymentDraft(paymentId, entry, (current) => ({
|
|
...current,
|
|
kind: value
|
|
}))
|
|
}
|
|
onPaymentDraftAmountChange={(paymentId, entry, value) =>
|
|
updatePaymentDraft(paymentId, entry, (current) => ({
|
|
...current,
|
|
amountMajor: value
|
|
}))
|
|
}
|
|
onPaymentDraftCurrencyChange={(paymentId, entry, value) =>
|
|
updatePaymentDraft(paymentId, entry, (current) => ({
|
|
...current,
|
|
currency: value
|
|
}))
|
|
}
|
|
/>
|
|
)
|
|
case 'house':
|
|
return (
|
|
<HouseScreen
|
|
copy={copy()}
|
|
readyIsAdmin={readySession()?.member.isAdmin === true}
|
|
householdDefaultLocale={readySession()?.member.householdDefaultLocale ?? 'en'}
|
|
dashboard={dashboard()}
|
|
adminSettings={adminSettings()}
|
|
cycleState={cycleState()}
|
|
pendingMembers={pendingMembers()}
|
|
activeHouseSection={activeHouseSection()}
|
|
onChangeHouseSection={setActiveHouseSection}
|
|
billingForm={billingForm()}
|
|
cycleForm={cycleForm()}
|
|
newCategoryName={newCategoryName()}
|
|
cycleRentOpen={cycleRentOpen()}
|
|
billingSettingsOpen={billingSettingsOpen()}
|
|
addingUtilityBillOpen={addingUtilityBillOpen()}
|
|
editingUtilityBill={editingUtilityBill()}
|
|
editingUtilityBillId={editingUtilityBillId()}
|
|
utilityBillDrafts={utilityBillDrafts()}
|
|
editingCategorySlug={editingCategorySlug()}
|
|
editingCategory={editingCategory()}
|
|
editingMember={editingMember()}
|
|
memberDisplayNameDrafts={memberDisplayNameDrafts()}
|
|
memberStatusDrafts={memberStatusDrafts()}
|
|
memberAbsencePolicyDrafts={memberAbsencePolicyDrafts()}
|
|
rentWeightDrafts={rentWeightDrafts()}
|
|
openingCycle={openingCycle()}
|
|
closingCycle={closingCycle()}
|
|
savingCycleRent={savingCycleRent()}
|
|
savingBillingSettings={savingBillingSettings()}
|
|
savingUtilityBill={savingUtilityBill()}
|
|
savingUtilityBillId={savingUtilityBillId()}
|
|
deletingUtilityBillId={deletingUtilityBillId()}
|
|
savingCategorySlug={savingCategorySlug()}
|
|
approvingTelegramUserId={approvingTelegramUserId()}
|
|
savingMemberEditorId={savingMemberEditorId()}
|
|
promotingMemberId={promotingMemberId()}
|
|
savingHouseholdLocale={savingHouseholdLocale()}
|
|
minorToMajorString={minorToMajorString}
|
|
memberStatusLabel={memberStatusLabel}
|
|
topicRoleLabel={topicRoleLabel}
|
|
resolvedMemberAbsencePolicy={(memberId, status) =>
|
|
resolvedMemberAbsencePolicy(memberId, status)
|
|
}
|
|
onChangeHouseholdLocale={handleHouseholdLocaleChange}
|
|
onOpenCycleModal={() => setCycleRentOpen(true)}
|
|
onCloseCycleModal={() => setCycleRentOpen(false)}
|
|
onSaveCycleRent={handleSaveCycleRent}
|
|
onOpenCycle={handleOpenCycle}
|
|
onCloseCycle={handleCloseCycle}
|
|
onCycleRentAmountChange={(value) =>
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
rentAmountMajor: value
|
|
}))
|
|
}
|
|
onCycleRentCurrencyChange={(value) =>
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
rentCurrency: value
|
|
}))
|
|
}
|
|
onCyclePeriodChange={(value) =>
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
period: value
|
|
}))
|
|
}
|
|
onOpenBillingSettingsModal={() => setBillingSettingsOpen(true)}
|
|
onCloseBillingSettingsModal={() => setBillingSettingsOpen(false)}
|
|
onSaveBillingSettings={handleSaveBillingSettings}
|
|
onBillingSettlementCurrencyChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
settlementCurrency: value
|
|
}))
|
|
}
|
|
onBillingAdjustmentPolicyChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
paymentBalanceAdjustmentPolicy: value
|
|
}))
|
|
}
|
|
onBillingRentAmountChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
rentAmountMajor: value
|
|
}))
|
|
}
|
|
onBillingRentCurrencyChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
rentCurrency: value
|
|
}))
|
|
}
|
|
onBillingRentDueDayChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
rentDueDay: value
|
|
}))
|
|
}
|
|
onBillingRentWarningDayChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
rentWarningDay: value
|
|
}))
|
|
}
|
|
onBillingUtilitiesDueDayChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
utilitiesDueDay: value
|
|
}))
|
|
}
|
|
onBillingUtilitiesReminderDayChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
utilitiesReminderDay: value
|
|
}))
|
|
}
|
|
onBillingTimezoneChange={(value) =>
|
|
setBillingForm((current) => ({
|
|
...current,
|
|
timezone: value
|
|
}))
|
|
}
|
|
onOpenAddUtilityBill={() => setAddingUtilityBillOpen(true)}
|
|
onCloseAddUtilityBill={() => setAddingUtilityBillOpen(false)}
|
|
onAddUtilityBill={handleAddUtilityBill}
|
|
onCycleUtilityCategoryChange={(value) =>
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
utilityCategorySlug: value
|
|
}))
|
|
}
|
|
onCycleUtilityAmountChange={(value) =>
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
utilityAmountMajor: value
|
|
}))
|
|
}
|
|
onCycleUtilityCurrencyChange={(value) =>
|
|
setCycleForm((current) => ({
|
|
...current,
|
|
utilityCurrency: value
|
|
}))
|
|
}
|
|
onOpenUtilityBillEditor={setEditingUtilityBillId}
|
|
onCloseUtilityBillEditor={() => setEditingUtilityBillId(null)}
|
|
onDeleteUtilityBill={handleDeleteUtilityBill}
|
|
onSaveUtilityBill={handleUpdateUtilityBill}
|
|
onUtilityBillNameChange={(billId, bill, value) =>
|
|
updateUtilityBillDraft(billId, bill, (current) => ({
|
|
...current,
|
|
billName: value
|
|
}))
|
|
}
|
|
onUtilityBillAmountChange={(billId, bill, value) =>
|
|
updateUtilityBillDraft(billId, bill, (current) => ({
|
|
...current,
|
|
amountMajor: value
|
|
}))
|
|
}
|
|
onUtilityBillCurrencyChange={(billId, bill, value) =>
|
|
updateUtilityBillDraft(billId, bill, (current) => ({
|
|
...current,
|
|
currency: value
|
|
}))
|
|
}
|
|
onOpenCategoryEditor={setEditingCategorySlug}
|
|
onCloseCategoryEditor={() => setEditingCategorySlug(null)}
|
|
onNewCategoryNameChange={setNewCategoryName}
|
|
onSaveNewCategory={() =>
|
|
handleSaveUtilityCategory({
|
|
name: newCategoryName(),
|
|
sortOrder: adminSettings()?.categories.length ?? 0,
|
|
isActive: true
|
|
})
|
|
}
|
|
onSaveExistingCategory={() => {
|
|
const category = editingCategory()
|
|
if (!category) {
|
|
return Promise.resolve()
|
|
}
|
|
|
|
return handleSaveUtilityCategory({
|
|
slug: category.slug,
|
|
name: category.name,
|
|
sortOrder: category.sortOrder,
|
|
isActive: category.isActive
|
|
})
|
|
}}
|
|
onEditingCategoryNameChange={(value) =>
|
|
setAdminSettings((current) =>
|
|
current && editingCategory()
|
|
? {
|
|
...current,
|
|
categories: current.categories.map((item) =>
|
|
item.slug === editingCategory()!.slug
|
|
? {
|
|
...item,
|
|
name: value
|
|
}
|
|
: item
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
}
|
|
onEditingCategoryActiveChange={(value) =>
|
|
setAdminSettings((current) =>
|
|
current && editingCategory()
|
|
? {
|
|
...current,
|
|
categories: current.categories.map((item) =>
|
|
item.slug === editingCategory()!.slug
|
|
? {
|
|
...item,
|
|
isActive: value
|
|
}
|
|
: item
|
|
)
|
|
}
|
|
: current
|
|
)
|
|
}
|
|
onOpenMemberEditor={setEditingMemberId}
|
|
onCloseMemberEditor={() => setEditingMemberId(null)}
|
|
onApprovePendingMember={handleApprovePendingMember}
|
|
onMemberDisplayNameDraftChange={(memberId, value) =>
|
|
setMemberDisplayNameDrafts((current) => ({
|
|
...current,
|
|
[memberId]: value
|
|
}))
|
|
}
|
|
onMemberStatusDraftChange={(memberId, value) =>
|
|
setMemberStatusDrafts((current) => ({
|
|
...current,
|
|
[memberId]: value
|
|
}))
|
|
}
|
|
onMemberAbsencePolicyDraftChange={(memberId, value) =>
|
|
setMemberAbsencePolicyDrafts((current) => ({
|
|
...current,
|
|
[memberId]: value
|
|
}))
|
|
}
|
|
onRentWeightDraftChange={(memberId, value) =>
|
|
setRentWeightDrafts((current) => ({
|
|
...current,
|
|
[memberId]: value
|
|
}))
|
|
}
|
|
onSaveMemberChanges={handleSaveMemberChanges}
|
|
onPromoteMember={handlePromoteMember}
|
|
/>
|
|
)
|
|
default:
|
|
return (
|
|
<HomeScreen
|
|
copy={copy()}
|
|
dashboard={dashboard()}
|
|
readyIsAdmin={Boolean(readySession()?.member.isAdmin)}
|
|
pendingMembersCount={pendingMembers().length}
|
|
currentMemberLine={currentMemberLine()}
|
|
utilityTotalMajor={utilityTotalMajor()}
|
|
purchaseTotalMajor={purchaseTotalMajor()}
|
|
memberBalanceVisuals={memberBalanceVisuals()}
|
|
purchaseChart={purchaseInvestmentChart()}
|
|
memberBaseDueMajor={memberBaseDueMajor}
|
|
memberRemainingClass={memberRemainingClass}
|
|
ledgerTitle={ledgerTitle}
|
|
ledgerPrimaryAmount={ledgerPrimaryAmount}
|
|
ledgerSecondaryAmount={ledgerSecondaryAmount}
|
|
/>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main class="shell">
|
|
<div class="shell__backdrop shell__backdrop--top" />
|
|
<div class="shell__backdrop shell__backdrop--bottom" />
|
|
|
|
<TopBar
|
|
subtitle={copy().appSubtitle}
|
|
title={copy().appTitle}
|
|
languageLabel={copy().language}
|
|
locale={locale()}
|
|
saving={savingMemberLocale()}
|
|
onChange={(nextLocale) => void handleMemberLocaleChange(nextLocale)}
|
|
/>
|
|
|
|
<Switch>
|
|
<Match when={session().status === 'loading'}>
|
|
<LoadingState
|
|
badge={copy().loadingBadge}
|
|
title={copy().loadingTitle}
|
|
body={copy().loadingBody}
|
|
/>
|
|
</Match>
|
|
|
|
<Match when={session().status === 'blocked'}>
|
|
<BlockedState
|
|
badge={copy().loadingBadge}
|
|
title={
|
|
blockedSession()?.reason === 'telegram_only'
|
|
? copy().telegramOnlyTitle
|
|
: copy().unexpectedErrorTitle
|
|
}
|
|
body={
|
|
blockedSession()?.reason === 'telegram_only'
|
|
? copy().telegramOnlyBody
|
|
: copy().unexpectedErrorBody
|
|
}
|
|
reloadLabel={copy().reload}
|
|
onReload={() => window.location.reload()}
|
|
/>
|
|
</Match>
|
|
|
|
<Match when={session().status === 'onboarding'}>
|
|
<OnboardingState
|
|
badge={copy().loadingBadge}
|
|
title={
|
|
onboardingSession()?.mode === 'pending'
|
|
? copy().pendingTitle
|
|
: onboardingSession()?.mode === 'open_from_group'
|
|
? copy().openFromGroupTitle
|
|
: copy().joinTitle
|
|
}
|
|
body={
|
|
onboardingSession()?.mode === 'pending'
|
|
? copy().pendingBody.replace(
|
|
'{household}',
|
|
onboardingSession()?.householdName ?? copy().householdFallback
|
|
)
|
|
: onboardingSession()?.mode === 'open_from_group'
|
|
? copy().openFromGroupBody
|
|
: copy().joinBody.replace(
|
|
'{household}',
|
|
onboardingSession()?.householdName ?? copy().householdFallback
|
|
)
|
|
}
|
|
canJoin={onboardingSession()?.mode === 'join_required'}
|
|
joining={joining()}
|
|
joinActionLabel={copy().joinAction}
|
|
joiningLabel={copy().joining}
|
|
botLinkLabel={copy().botLinkAction}
|
|
botLink={joinDeepLink()}
|
|
reloadLabel={copy().reload}
|
|
onJoin={handleJoinHousehold}
|
|
onReload={() => window.location.reload()}
|
|
/>
|
|
</Match>
|
|
|
|
<Match when={session().status === 'ready'}>
|
|
<HeroBanner
|
|
badges={[
|
|
readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge,
|
|
readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag,
|
|
readySession()?.member.status
|
|
? memberStatusLabel(readySession()!.member.status)
|
|
: copy().memberStatusActive
|
|
]}
|
|
title={`${copy().welcome}, ${readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}`}
|
|
body={copy().overviewBody}
|
|
action={
|
|
readySession()?.mode === 'live'
|
|
? {
|
|
label: copy().manageProfileAction,
|
|
onClick: () => setProfileEditorOpen(true)
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
|
|
<NavigationTabs
|
|
items={
|
|
[
|
|
{ key: 'home', label: copy().home },
|
|
{ key: 'balances', label: copy().balances },
|
|
{ key: 'ledger', label: copy().ledger },
|
|
{ key: 'house', label: copy().house }
|
|
] as const
|
|
}
|
|
active={activeNav()}
|
|
onChange={setActiveNav}
|
|
/>
|
|
|
|
<section class="content-grid">
|
|
<ProfileCard
|
|
displayName={readySession()?.member.displayName ?? ''}
|
|
roleLabel={readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag}
|
|
statusSummary={copy().memberStatusSummary.replace(
|
|
'{status}',
|
|
readySession()?.member.status
|
|
? memberStatusLabel(readySession()!.member.status)
|
|
: copy().memberStatusActive
|
|
)}
|
|
modeBadge={readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
|
|
localeBadge={locale().toUpperCase()}
|
|
/>
|
|
<div class="content-stack">{renderPanel()}</div>
|
|
</section>
|
|
<Modal
|
|
open={profileEditorOpen()}
|
|
title={copy().displayNameLabel}
|
|
description={copy().profileEditorBody}
|
|
closeLabel={copy().closeEditorAction}
|
|
onClose={() => setProfileEditorOpen(false)}
|
|
footer={
|
|
<div class="modal-action-row modal-action-row--single">
|
|
<Button variant="ghost" onClick={() => setProfileEditorOpen(false)}>
|
|
{copy().closeEditorAction}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={
|
|
savingOwnDisplayName() ||
|
|
displayNameDraft().trim().length < 2 ||
|
|
displayNameDraft().trim() === readySession()?.member.displayName
|
|
}
|
|
onClick={() => void handleSaveOwnDisplayName()}
|
|
>
|
|
{savingOwnDisplayName() ? copy().savingDisplayName : copy().saveDisplayName}
|
|
</Button>
|
|
</div>
|
|
}
|
|
>
|
|
<div class="editor-grid">
|
|
<Field label={copy().displayNameLabel} hint={copy().displayNameHint} wide>
|
|
<input
|
|
value={displayNameDraft()}
|
|
onInput={(event) => setDisplayNameDraft(event.currentTarget.value)}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</Modal>
|
|
</Match>
|
|
</Switch>
|
|
</main>
|
|
)
|
|
}
|
|
|
|
export default App
|