import { Match, Show, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js' import { dictionary, type Locale } from './i18n' import { addMiniAppUtilityBill, addMiniAppPayment, approveMiniAppPendingMember, closeMiniAppBillingCycle, deleteMiniAppPayment, deleteMiniAppPurchase, deleteMiniAppUtilityBill, fetchMiniAppAdminSettings, fetchMiniAppBillingCycle, fetchMiniAppDashboard, fetchMiniAppPendingMembers, fetchMiniAppSession, 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 { 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 demoSession: Extract = { status: 'ready', mode: 'demo', member: { id: 'demo-member', displayName: 'Demo Resident', status: 'active', isAdmin: false, preferredLocale: 'en', householdDefaultLocale: 'en' }, telegramUser: { firstName: 'Demo', username: 'demo_user', languageCode: 'en' } } 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 dashboardMemberCount(dashboard: MiniAppDashboard | null): string { return dashboard ? String(dashboard.members.length) : '—' } function dashboardLedgerCount(dashboard: MiniAppDashboard | null): string { return dashboard ? String(dashboard.ledger.length) : '—' } 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 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 { 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 { 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 { 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 App() { const [locale, setLocale] = createSignal('en') const [session, setSession] = createSignal({ status: 'loading' }) const [activeNav, setActiveNav] = createSignal('home') const [activeHouseSection, setActiveHouseSection] = createSignal('billing') const [dashboard, setDashboard] = createSignal(null) const [pendingMembers, setPendingMembers] = createSignal([]) const [adminSettings, setAdminSettings] = createSignal(null) const [cycleState, setCycleState] = createSignal(null) const [joining, setJoining] = createSignal(false) const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal(null) const [promotingMemberId, setPromotingMemberId] = createSignal(null) const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false) const [savingMemberDisplayNameId, setSavingMemberDisplayNameId] = createSignal( null ) const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal(null) const [savingMemberStatusId, setSavingMemberStatusId] = createSignal(null) const [savingMemberAbsencePolicyId, setSavingMemberAbsencePolicyId] = createSignal( null ) const [displayNameDraft, setDisplayNameDraft] = createSignal('') const [memberDisplayNameDrafts, setMemberDisplayNameDrafts] = createSignal< Record >({}) const [rentWeightDrafts, setRentWeightDrafts] = createSignal>({}) const [memberStatusDrafts, setMemberStatusDrafts] = createSignal< Record >({}) const [memberAbsencePolicyDrafts, setMemberAbsencePolicyDrafts] = createSignal< Record >({}) const [savingMemberLocale, setSavingMemberLocale] = createSignal(false) const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false) const [savingBillingSettings, setSavingBillingSettings] = createSignal(false) const [savingCategorySlug, setSavingCategorySlug] = createSignal(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(null) const [deletingUtilityBillId, setDeletingUtilityBillId] = createSignal(null) const [utilityBillDrafts, setUtilityBillDrafts] = createSignal>( {} ) const [purchaseDraftMap, setPurchaseDraftMap] = createSignal>({}) const [paymentDraftMap, setPaymentDraftMap] = createSignal>({}) const [savingPurchaseId, setSavingPurchaseId] = createSignal(null) const [deletingPurchaseId, setDeletingPurchaseId] = createSignal(null) const [savingPaymentId, setSavingPaymentId] = createSignal(null) const [deletingPaymentId, setDeletingPaymentId] = createSignal(null) 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({ 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 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 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 })) } async function loadDashboard(initData: string) { try { const nextDashboard = await fetchMiniAppDashboard(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 fetchMiniAppPendingMembers(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 fetchMiniAppAdminSettings(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 fetchMiniAppBillingCycle(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) { 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) ]) } } async function bootstrap() { const fallbackLocale = detectLocale() setLocale(fallbackLocale) webApp?.ready?.() webApp?.expand?.() const initData = webApp?.initData?.trim() if (!initData) { if (import.meta.env.DEV) { setSession(demoSession) return } setSession({ status: 'blocked', reason: 'telegram_only' }) return } try { const payload = await fetchMiniAppSession(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) { setDisplayNameDraft(demoSession.member.displayName) setSession(demoSession) setDashboard({ period: '2026-03', currency: 'GEL', totalDueMajor: '1030.00', totalPaidMajor: '501.00', totalRemainingMajor: '529.00', rentSourceAmountMajor: '700.00', rentSourceCurrency: 'USD', rentDisplayAmountMajor: '1932.00', rentFxRateMicros: '2760000', rentFxEffectiveDate: '2026-03-17', members: [ { memberId: 'demo-member', displayName: 'Demo Resident', rentShareMajor: '483.00', utilityShareMajor: '32.00', purchaseOffsetMajor: '-14.00', netDueMajor: '501.00', paidMajor: '501.00', remainingMajor: '0.00', explanations: ['Equal utility split', 'Shared purchase offset'] }, { memberId: 'member-2', displayName: 'Alice', rentShareMajor: '483.00', utilityShareMajor: '32.00', purchaseOffsetMajor: '14.00', netDueMajor: '529.00', paidMajor: '0.00', remainingMajor: '529.00', explanations: ['Equal utility split'] } ], ledger: [ { id: 'purchase-1', kind: 'purchase', title: 'Soap', memberId: 'member-2', paymentKind: null, amountMajor: '30.00', currency: 'GEL', displayAmountMajor: '30.00', displayCurrency: 'GEL', fxRateMicros: null, fxEffectiveDate: null, actorDisplayName: 'Alice', occurredAt: '2026-03-12T11:00:00.000Z' }, { id: 'utility-1', kind: 'utility', title: 'Electricity', memberId: null, paymentKind: null, amountMajor: '120.00', currency: 'GEL', displayAmountMajor: '120.00', displayCurrency: 'GEL', fxRateMicros: null, fxEffectiveDate: null, actorDisplayName: 'Alice', occurredAt: '2026-03-12T12:00:00.000Z' }, { id: 'payment-1', kind: 'payment', title: 'rent', memberId: 'demo-member', paymentKind: 'rent', amountMajor: '501.00', currency: 'GEL', displayAmountMajor: '501.00', displayCurrency: 'GEL', fxRateMicros: null, fxEffectiveDate: null, actorDisplayName: 'Demo Resident', occurredAt: '2026-03-18T15:10:00.000Z' } ] }) setPendingMembers([ { telegramUserId: '555777', displayName: 'Mia', username: 'mia', languageCode: 'ru' } ]) 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) { 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) } 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 })) } 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 })) } 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)) } 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)) } 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: '' })) } 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)) } 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)) } 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) } 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) } 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) } 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) } 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) } 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('') } } 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) })) } finally { setPromotingMemberId(null) } } async function handleSaveRentWeight(memberId: string) { 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) })) } finally { setSavingRentWeightMemberId(null) } } async function handleSaveMemberStatus(memberId: string) { 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) })) } finally { setSavingMemberStatusId(null) } } async function handleSaveMemberAbsencePolicy(memberId: string) { 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 })) } finally { setSavingMemberAbsencePolicyId(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 (
{copy().emptyDashboard}

} render={(data) => ( <> {currentMemberLine() ? (
{copy().yourBalanceTitle} {currentMemberLine()!.netDueMajor} {data.currency}

{copy().yourBalanceBody}

{copy().baseDue} {memberBaseDueMajor(currentMemberLine()!)} {data.currency}
{copy().shareOffset} {currentMemberLine()!.purchaseOffsetMajor} {data.currency}
{copy().finalDue} {currentMemberLine()!.netDueMajor} {data.currency}
{copy().paidLabel} {currentMemberLine()!.paidMajor} {data.currency}
{copy().remainingLabel} {currentMemberLine()!.remainingMajor} {data.currency}
) : null}
{copy().householdBalancesTitle}

{copy().householdBalancesBody}

{data.members.map((member) => (
{member.displayName} {member.remainingMajor} {data.currency}

{copy().baseDue}: {memberBaseDueMajor(member)} {data.currency}

{copy().shareRent}: {member.rentShareMajor} {data.currency}

{copy().shareUtilities}: {member.utilityShareMajor} {data.currency}

{copy().shareOffset}: {member.purchaseOffsetMajor} {data.currency}

{copy().paidLabel}: {member.paidMajor} {data.currency}

{copy().remainingLabel}: {member.remainingMajor} {data.currency}

))} )} />
) case 'ledger': return (
{copy().emptyDashboard}

} render={() => ( <>
{readySession()?.member.isAdmin ? copy().purchaseReviewTitle : copy().purchasesTitle}

{copy().purchaseReviewBody}

{purchaseLedger().length === 0 ? (

{copy().purchasesEmpty}

) : (
{purchaseLedger().map((entry) => (
{entry.actorDisplayName ?? copy().ledgerActorFallback} {entry.occurredAt?.slice(0, 10) ?? '—'}
{readySession()?.member.isAdmin ? ( <>
{copy().purchaseSplitTitle} {purchaseDraftMap()[entry.id]?.splitMode === 'custom_amounts' ? copy().purchaseSplitCustom : copy().purchaseSplitEqual}
{(adminSettings()?.members ?? []).map((member) => { const draft = purchaseDraftMap()[entry.id] ?? purchaseDraftForEntry(entry) const included = draft.participants.some( (participant) => participant.memberId === member.id ) return (
{member.displayName} {purchaseSplitPreview(entry.id).find( (participant) => participant.memberId === member.id )?.amountMajor ?? '0.00'}{' '} {draft.currency}
) })}
) : ( <>

{ledgerPrimaryAmount(entry)}

{(secondary) =>

{secondary()}

}

{entry.title}

)}
))}
)}
{copy().utilityLedgerTitle}
{utilityLedger().length === 0 ? (

{copy().utilityLedgerEmpty}

) : (
{utilityLedger().map((entry) => (
{ledgerTitle(entry)} {ledgerPrimaryAmount(entry)}
{(secondary) =>

{secondary()}

}

{entry.actorDisplayName ?? copy().ledgerActorFallback}

))}
)}
{copy().paymentsAdminTitle}

{copy().paymentsAdminBody}

{paymentLedger().length === 0 ? (

{copy().paymentsEmpty}

) : (
{paymentLedger().map((entry) => (
{entry.actorDisplayName ?? copy().ledgerActorFallback} {entry.occurredAt?.slice(0, 10) ?? '—'}
{readySession()?.member.isAdmin ? ( <>
) : ( <>

{ledgerPrimaryAmount(entry)}

{(secondary) =>

{secondary()}

}

{ledgerTitle(entry)}

)}
))}
)}
)} />
) case 'house': return readySession()?.member.isAdmin ? (
{copy().householdSettingsTitle} {adminSettings()?.settings.settlementCurrency ?? '—'}

{copy().householdSettingsBody}

{copy().billingCycleTitle} {cycleState()?.cycle?.period ?? copy().billingCycleEmpty}
{copy().settlementCurrency} {adminSettings()?.settings.settlementCurrency ?? '—'}
{copy().membersCount} {String(adminSettings()?.members.length ?? 0)}
{copy().pendingRequests} {String(pendingMembers().length)}
{( [ ['billing', copy().houseSectionBilling], ['utilities', copy().houseSectionUtilities], ['members', copy().houseSectionMembers], ['topics', copy().houseSectionTopics] ] as const ).map(([key, label]) => ( ))}

{copy().billingCycleTitle}

{copy().billingSettingsTitle}

{copy().billingCycleTitle} {cycleState()?.cycle?.period ?? copy().billingCycleEmpty}
{cycleState()?.cycle ? ( <>

{copy().billingCycleStatus.replace( '{currency}', cycleState()?.cycle?.currency ?? billingForm().settlementCurrency )}

{(data) => (

{copy().shareRent}: {data().rentSourceAmountMajor}{' '} {data().rentSourceCurrency} {data().rentSourceCurrency !== data().currency ? ` -> ${data().rentDisplayAmountMajor} ${data().currency}` : ''}

)}
) : ( <>

{copy().billingCycleOpenHint}

{copy().settlementCurrency}
{billingForm().settlementCurrency}
)}
{copy().billingSettingsTitle} {billingForm().settlementCurrency}
{copy().householdLanguage} {readySession()?.member.householdDefaultLocale.toUpperCase()}

{copy().householdSettingsBody}

{copy().utilityCategoriesTitle}

{copy().utilityCategoriesBody}

{copy().utilityLedgerTitle} {cycleForm().utilityCurrency}
{cycleState()?.utilityBills.length ? ( cycleState()?.utilityBills.map((bill) => (
{utilityBillDrafts()[bill.id]?.billName ?? bill.billName} {bill.createdAt.slice(0, 10)}
)) ) : (

{copy().utilityBillsEmpty}

)}
{copy().utilityCategoriesTitle} {String(adminSettings()?.categories.length ?? 0)}
{adminSettings()?.categories.map((category) => (
{category.name} {category.isActive ? 'ON' : 'OFF'}
))}

{copy().adminsTitle}

{copy().adminsBody}

{copy().adminsTitle} {String(adminSettings()?.members.length ?? 0)}
{adminSettings()?.members.map((member) => (
{member.displayName} {member.isAdmin ? copy().adminTag : copy().residentTag} {` · ${memberStatusLabel(member.status)}`}
{!member.isAdmin ? ( ) : null}
))}
{copy().pendingMembersTitle} {String(pendingMembers().length)}

{copy().pendingMembersBody}

{pendingMembers().length === 0 ? (

{copy().pendingMembersEmpty}

) : (
{pendingMembers().map((member) => (
{member.displayName} {member.telegramUserId}

{member.username ? copy().pendingMemberHandle.replace('{username}', member.username) : (member.languageCode ?? 'Telegram')}

))}
)}

{copy().topicBindingsTitle}

{copy().topicBindingsBody}

{copy().topicBindingsTitle} {String(adminSettings()?.topics.length ?? 0)}/4
{(['purchase', 'feedback', 'reminders', 'payments'] as const).map((role) => { const binding = adminSettings()?.topics.find((topic) => topic.role === role) return (
{topicRoleLabel(role)} {binding ? copy().topicBound : copy().topicUnbound}

{binding ? `${binding.topicName ?? `Topic #${binding.telegramThreadId}`} · #${binding.telegramThreadId}` : copy().topicUnbound}

) })}
) : (
{copy().residentHouseTitle}

{copy().residentHouseBody}

) default: return (
{copy().totalDue} {dashboard() ? `${dashboard()!.totalDueMajor} ${dashboard()!.currency}` : '—'}
{copy().paidLabel} {dashboard() ? `${dashboard()!.totalPaidMajor} ${dashboard()!.currency}` : '—'}
{copy().remainingLabel} {dashboard() ? `${dashboard()!.totalRemainingMajor} ${dashboard()!.currency}` : '—'}
{copy().membersCount} {dashboardMemberCount(dashboard())}
{copy().ledgerEntries} {dashboardLedgerCount(dashboard())}
{copy().purchasesTitle} {String(purchaseLedger().length)}
{readySession()?.member.isAdmin ? (
{copy().pendingRequests} {String(pendingMembers().length)}
) : null} {currentMemberLine() ? (
{copy().yourBalanceTitle} {currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''}

{copy().yourBalanceBody}

(

{copy().shareRent}: {data.rentSourceAmountMajor} {data.rentSourceCurrency} {data.rentSourceCurrency !== data.currency ? ` -> ${data.rentDisplayAmountMajor} ${data.currency}` : ''}

)} />
{copy().baseDue} {memberBaseDueMajor(currentMemberLine()!)} {dashboard()?.currency ?? ''}
{copy().shareOffset} {currentMemberLine()!.purchaseOffsetMajor} {dashboard()?.currency ?? ''}
{copy().finalDue} {currentMemberLine()!.netDueMajor} {dashboard()?.currency ?? ''}
{copy().paidLabel} {currentMemberLine()!.paidMajor} {dashboard()?.currency ?? ''}
{copy().remainingLabel} {currentMemberLine()!.remainingMajor} {dashboard()?.currency ?? ''}
) : (
{copy().overviewTitle}

{copy().overviewBody}

)}
{copy().latestActivityTitle}
{copy().latestActivityEmpty}

} render={(data) => data.ledger.length === 0 ? (

{copy().latestActivityEmpty}

) : (
{data.ledger.slice(0, 3).map((entry) => (
{ledgerTitle(entry)} {ledgerPrimaryAmount(entry)}
{(secondary) =>

{secondary()}

}

{entry.actorDisplayName ?? copy().ledgerActorFallback}

))}
) } />
) } } return (

{copy().appSubtitle}

{copy().appTitle}

{copy().loadingBadge}

{copy().loadingTitle}

{copy().loadingBody}

{copy().loadingBadge}

{blockedSession()?.reason === 'telegram_only' ? copy().telegramOnlyTitle : copy().unexpectedErrorTitle}

{blockedSession()?.reason === 'telegram_only' ? copy().telegramOnlyBody : copy().unexpectedErrorBody}

{copy().loadingBadge}

{onboardingSession()?.mode === 'pending' ? copy().pendingTitle : onboardingSession()?.mode === 'open_from_group' ? copy().openFromGroupTitle : copy().joinTitle}

{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 )}

{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge} {readySession()?.member.isAdmin ? copy().adminTag : copy().residentTag} {readySession()?.member.status ? memberStatusLabel(readySession()!.member.status) : copy().memberStatusActive}

{copy().welcome},{' '} {readySession()?.telegramUser.firstName ?? readySession()?.member.displayName}

{copy().overviewBody}

{copy().overviewTitle}

{readySession()?.member.displayName}

{copy().memberStatusSummary.replace( '{status}', readySession()?.member.status ? memberStatusLabel(readySession()!.member.status) : copy().memberStatusActive )}

{renderPanel()}
) } function ShowDashboard(props: { dashboard: MiniAppDashboard | null fallback: JSX.Element render: (dashboard: MiniAppDashboard) => JSX.Element }) { return <>{props.dashboard ? props.render(props.dashboard) : props.fallback} } export default App