feat(miniapp): add billing review tools and house sections

This commit is contained in:
2026-03-10 22:04:43 +04:00
parent 7f8c238a23
commit 8f9abf998f
4 changed files with 1468 additions and 696 deletions

View File

@@ -3,8 +3,11 @@ import { Match, Show, Switch, createMemo, createSignal, onMount, type JSX } from
import { dictionary, type Locale } from './i18n'
import {
addMiniAppUtilityBill,
addMiniAppPayment,
approveMiniAppPendingMember,
closeMiniAppBillingCycle,
deleteMiniAppPayment,
deleteMiniAppPurchase,
deleteMiniAppUtilityBill,
fetchMiniAppAdminSettings,
fetchMiniAppBillingCycle,
@@ -20,6 +23,8 @@ import {
updateMiniAppLocalePreference,
updateMiniAppBillingSettings,
updateMiniAppCycleRent,
updateMiniAppPayment,
updateMiniAppPurchase,
upsertMiniAppUtilityCategory,
updateMiniAppUtilityBill,
type MiniAppDashboard,
@@ -63,6 +68,7 @@ type SessionState =
}
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
type HouseSectionKey = 'billing' | 'utilities' | 'members' | 'topics'
type UtilityBillDraft = {
billName: string
@@ -70,6 +76,19 @@ type UtilityBillDraft = {
currency: 'USD' | 'GEL'
}
type PurchaseDraft = {
description: string
amountMajor: string
currency: 'USD' | 'GEL'
}
type PaymentDraft = {
memberId: string
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
}
const demoSession: Extract<SessionState, { status: 'ready' }> = {
status: 'ready',
mode: 'demo',
@@ -209,12 +228,48 @@ function cycleUtilityBillDrafts(
)
}
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
}
])
)
}
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 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)
@@ -237,6 +292,13 @@ function App() {
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 [addingPayment, setAddingPayment] = createSignal(false)
const [billingForm, setBillingForm] = createSignal({
settlementCurrency: 'GEL' as 'USD' | 'GEL',
rentAmountMajor: '',
@@ -256,6 +318,12 @@ function App() {
utilityCategorySlug: '',
utilityAmountMajor: ''
})
const [paymentForm, setPaymentForm] = createSignal<PaymentDraft>({
memberId: '',
kind: 'rent',
amountMajor: '',
currency: 'GEL'
})
const copy = createMemo(() => dictionary[locale()])
const onboardingSession = createMemo(() => {
@@ -316,13 +384,18 @@ function App() {
async function loadDashboard(initData: string) {
try {
setDashboard(await fetchMiniAppDashboard(initData))
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({})
}
}
@@ -368,6 +441,11 @@ function App() {
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)
@@ -408,6 +486,28 @@ function App() {
}
}
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)
@@ -513,6 +613,7 @@ function App() {
id: 'purchase-1',
kind: 'purchase',
title: 'Soap',
memberId: 'member-2',
paymentKind: null,
amountMajor: '30.00',
currency: 'GEL',
@@ -527,6 +628,7 @@ function App() {
id: 'utility-1',
kind: 'utility',
title: 'Electricity',
memberId: null,
paymentKind: null,
amountMajor: '120.00',
currency: 'GEL',
@@ -541,6 +643,7 @@ function App() {
id: 'payment-1',
kind: 'payment',
title: 'rent',
memberId: 'demo-member',
paymentKind: 'rent',
amountMajor: '501.00',
currency: 'GEL',
@@ -902,6 +1005,130 @@ function App() {
}
}
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
) {
return
}
setSavingPurchaseId(purchaseId)
try {
await updateMiniAppPurchase(initData, {
purchaseId,
description: draft.description,
amountMajor: draft.amountMajor,
currency: draft.currency
})
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
@@ -1106,22 +1333,130 @@ function App() {
<>
<article class="balance-item">
<header>
<strong>{copy().purchasesTitle}</strong>
<strong>
{readySession()?.member.isAdmin
? copy().purchaseReviewTitle
: copy().purchasesTitle}
</strong>
</header>
<Show when={readySession()?.member.isAdmin}>
<p>{copy().purchaseReviewBody}</p>
</Show>
{purchaseLedger().length === 0 ? (
<p>{copy().purchasesEmpty}</p>
) : (
<div class="ledger-list">
{purchaseLedger().map((entry) => (
<article class="ledger-item">
<article class="utility-bill-row">
<header>
<strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span>
<strong>
{entry.actorDisplayName ?? copy().ledgerActorFallback}
</strong>
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
</header>
{readySession()?.member.isAdmin ? (
<>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().purchaseReviewTitle}</span>
<input
value={
purchaseDraftMap()[entry.id]?.description ?? entry.title
}
onInput={(event) =>
setPurchaseDraftMap((current) => ({
...current,
[entry.id]: {
...(current[entry.id] ?? {
description: entry.title,
amountMajor: entry.amountMajor,
currency: entry.currency
}),
description: event.currentTarget.value
}
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().paymentAmount}</span>
<input
value={
purchaseDraftMap()[entry.id]?.amountMajor ??
entry.amountMajor
}
onInput={(event) =>
setPurchaseDraftMap((current) => ({
...current,
[entry.id]: {
...(current[entry.id] ?? {
description: entry.title,
amountMajor: entry.amountMajor,
currency: entry.currency
}),
amountMajor: event.currentTarget.value
}
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().settlementCurrency}</span>
<select
value={
purchaseDraftMap()[entry.id]?.currency ?? entry.currency
}
onChange={(event) =>
setPurchaseDraftMap((current) => ({
...current,
[entry.id]: {
...(current[entry.id] ?? {
description: entry.title,
amountMajor: entry.amountMajor,
currency: entry.currency
}),
currency: event.currentTarget.value as 'USD' | 'GEL'
}
}))
}
>
<option value="GEL">GEL</option>
<option value="USD">USD</option>
</select>
</label>
</div>
<div class="inline-actions">
<button
class="ghost-button"
type="button"
disabled={savingPurchaseId() === entry.id}
onClick={() => void handleUpdatePurchase(entry.id)}
>
{savingPurchaseId() === entry.id
? copy().savingPurchase
: copy().purchaseSaveAction}
</button>
<button
class="ghost-button ghost-button--danger"
type="button"
disabled={deletingPurchaseId() === entry.id}
onClick={() => void handleDeletePurchase(entry.id)}
>
{deletingPurchaseId() === entry.id
? copy().deletingPurchase
: copy().purchaseDeleteAction}
</button>
</div>
</>
) : (
<>
<p>{ledgerPrimaryAmount(entry)}</p>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => <p>{secondary()}</p>}
</Show>
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
<p>{entry.title}</p>
</>
)}
</article>
))}
</div>
@@ -1152,22 +1487,232 @@ function App() {
</article>
<article class="balance-item">
<header>
<strong>{copy().paymentsTitle}</strong>
<strong>{copy().paymentsAdminTitle}</strong>
</header>
<Show when={readySession()?.member.isAdmin}>
<p>{copy().paymentsAdminBody}</p>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().paymentMember}</span>
<select
value={paymentForm().memberId}
onChange={(event) =>
setPaymentForm((current) => ({
...current,
memberId: event.currentTarget.value
}))
}
>
{adminSettings()?.members.map((member) => (
<option value={member.id}>{member.displayName}</option>
))}
</select>
</label>
<label class="settings-field">
<span>{copy().paymentKind}</span>
<select
value={paymentForm().kind}
onChange={(event) =>
setPaymentForm((current) => ({
...current,
kind: event.currentTarget.value as 'rent' | 'utilities'
}))
}
>
<option value="rent">{copy().paymentLedgerRent}</option>
<option value="utilities">{copy().paymentLedgerUtilities}</option>
</select>
</label>
<label class="settings-field">
<span>{copy().paymentAmount}</span>
<input
value={paymentForm().amountMajor}
onInput={(event) =>
setPaymentForm((current) => ({
...current,
amountMajor: event.currentTarget.value
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().settlementCurrency}</span>
<select
value={paymentForm().currency}
onChange={(event) =>
setPaymentForm((current) => ({
...current,
currency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
<option value="GEL">GEL</option>
<option value="USD">USD</option>
</select>
</label>
</div>
<button
class="ghost-button"
type="button"
disabled={addingPayment() || paymentForm().amountMajor.trim().length === 0}
onClick={() => void handleAddPayment()}
>
{addingPayment() ? copy().addingPayment : copy().paymentsAddAction}
</button>
</Show>
{paymentLedger().length === 0 ? (
<p>{copy().paymentsEmpty}</p>
) : (
<div class="ledger-list">
{paymentLedger().map((entry) => (
<article class="ledger-item">
<article class="utility-bill-row">
<header>
<strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span>
<strong>
{entry.actorDisplayName ?? copy().ledgerActorFallback}
</strong>
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
</header>
{readySession()?.member.isAdmin ? (
<>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().paymentMember}</span>
<select
value={
paymentDraftMap()[entry.id]?.memberId ??
entry.memberId ??
''
}
onChange={(event) =>
setPaymentDraftMap((current) => ({
...current,
[entry.id]: {
...(current[entry.id] ?? {
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
}),
memberId: event.currentTarget.value
}
}))
}
>
{adminSettings()?.members.map((member) => (
<option value={member.id}>{member.displayName}</option>
))}
</select>
</label>
<label class="settings-field">
<span>{copy().paymentKind}</span>
<select
value={
paymentDraftMap()[entry.id]?.kind ??
entry.paymentKind ??
'rent'
}
onChange={(event) =>
setPaymentDraftMap((current) => ({
...current,
[entry.id]: {
...(current[entry.id] ?? {
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
}),
kind: event.currentTarget.value as 'rent' | 'utilities'
}
}))
}
>
<option value="rent">{copy().paymentLedgerRent}</option>
<option value="utilities">
{copy().paymentLedgerUtilities}
</option>
</select>
</label>
<label class="settings-field">
<span>{copy().paymentAmount}</span>
<input
value={
paymentDraftMap()[entry.id]?.amountMajor ??
entry.amountMajor
}
onInput={(event) =>
setPaymentDraftMap((current) => ({
...current,
[entry.id]: {
...(current[entry.id] ?? {
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
}),
amountMajor: event.currentTarget.value
}
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().settlementCurrency}</span>
<select
value={
paymentDraftMap()[entry.id]?.currency ?? entry.currency
}
onChange={(event) =>
setPaymentDraftMap((current) => ({
...current,
[entry.id]: {
...(current[entry.id] ?? {
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
}),
currency: event.currentTarget.value as 'USD' | 'GEL'
}
}))
}
>
<option value="GEL">GEL</option>
<option value="USD">USD</option>
</select>
</label>
</div>
<div class="inline-actions">
<button
class="ghost-button"
type="button"
disabled={savingPaymentId() === entry.id}
onClick={() => void handleUpdatePayment(entry.id)}
>
{savingPaymentId() === entry.id
? copy().addingPayment
: copy().paymentSaveAction}
</button>
<button
class="ghost-button ghost-button--danger"
type="button"
disabled={deletingPaymentId() === entry.id}
onClick={() => void handleDeletePayment(entry.id)}
>
{deletingPaymentId() === entry.id
? copy().deletingPayment
: copy().paymentDeleteAction}
</button>
</div>
</>
) : (
<>
<p>{ledgerPrimaryAmount(entry)}</p>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => <p>{secondary()}</p>}
</Show>
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
<p>{ledgerTitle(entry)}</p>
</>
)}
</article>
))}
</div>
@@ -1207,6 +1752,26 @@ function App() {
</div>
</article>
<div class="section-switch">
{(
[
['billing', copy().houseSectionBilling],
['utilities', copy().houseSectionUtilities],
['members', copy().houseSectionMembers],
['topics', copy().houseSectionTopics]
] as const
).map(([key, label]) => (
<button
classList={{ 'is-active': activeHouseSection() === key }}
type="button"
onClick={() => setActiveHouseSection(key)}
>
{label}
</button>
))}
</div>
<Show when={activeHouseSection() === 'billing'}>
<section class="admin-section">
<header class="admin-section__header">
<div>
@@ -1277,7 +1842,9 @@ function App() {
}
onClick={() => void handleSaveCycleRent()}
>
{savingCycleRent() ? copy().savingCycleRent : copy().saveCycleRentAction}
{savingCycleRent()
? copy().savingCycleRent
: copy().saveCycleRentAction}
</button>
<button
class="ghost-button"
@@ -1484,36 +2051,11 @@ function App() {
</button>
</div>
</article>
<article class="balance-item">
<header>
<strong>{copy().topicBindingsTitle}</strong>
<span>{String(adminSettings()?.topics.length ?? 0)}/4</span>
</header>
<p>{copy().topicBindingsBody}</p>
<div class="balance-list admin-sublist">
{(['purchase', 'feedback', 'reminders', 'payments'] as const).map((role) => {
const binding = adminSettings()?.topics.find((topic) => topic.role === role)
return (
<article class="ledger-item">
<header>
<strong>{topicRoleLabel(role)}</strong>
<span>{binding ? copy().topicBound : copy().topicUnbound}</span>
</header>
<p>
{binding
? `${binding.topicName ?? `Topic #${binding.telegramThreadId}`} · #${binding.telegramThreadId}`
: copy().topicUnbound}
</p>
</article>
)
})}
</div>
</article>
</div>
</section>
</Show>
<Show when={activeHouseSection() === 'utilities'}>
<section class="admin-section">
<header class="admin-section__header">
<div>
@@ -1809,7 +2351,9 @@ function App() {
</article>
</div>
</section>
</Show>
<Show when={activeHouseSection() === 'members'}>
<section class="admin-section">
<header class="admin-section__header">
<div>
@@ -1917,6 +2461,45 @@ function App() {
</article>
</div>
</section>
</Show>
<Show when={activeHouseSection() === 'topics'}>
<section class="admin-section">
<header class="admin-section__header">
<div>
<h3>{copy().topicBindingsTitle}</h3>
<p>{copy().topicBindingsBody}</p>
</div>
</header>
<div class="admin-grid">
<article class="balance-item admin-card--wide">
<header>
<strong>{copy().topicBindingsTitle}</strong>
<span>{String(adminSettings()?.topics.length ?? 0)}/4</span>
</header>
<div class="balance-list admin-sublist">
{(['purchase', 'feedback', 'reminders', 'payments'] as const).map((role) => {
const binding = adminSettings()?.topics.find((topic) => topic.role === role)
return (
<article class="ledger-item">
<header>
<strong>{topicRoleLabel(role)}</strong>
<span>{binding ? copy().topicBound : copy().topicUnbound}</span>
</header>
<p>
{binding
? `${binding.topicName ?? `Topic #${binding.telegramThreadId}`} · #${binding.telegramThreadId}`
: copy().topicUnbound}
</p>
</article>
)
})}
</div>
</article>
</div>
</section>
</Show>
</div>
) : (
<div class="balance-list">

View File

@@ -35,6 +35,10 @@ export const dictionary = {
balances: 'Balances',
ledger: 'Ledger',
house: 'House',
houseSectionBilling: 'Billing',
houseSectionUtilities: 'Utilities',
houseSectionMembers: 'Members',
houseSectionTopics: 'Topics',
welcome: 'Welcome back',
adminTag: 'Admin',
residentTag: 'Resident',
@@ -69,6 +73,22 @@ export const dictionary = {
emptyDashboard: 'No billing cycle is ready yet.',
latestActivityTitle: 'Latest activity',
latestActivityEmpty: 'Recent utility and purchase entries will appear here.',
purchaseReviewTitle: 'Purchases',
purchaseReviewBody: 'Edit or remove purchases if the bot recorded the wrong item.',
paymentsAdminTitle: 'Payments',
paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.',
paymentsAddAction: 'Add payment',
addingPayment: 'Adding payment…',
paymentKind: 'Payment kind',
paymentAmount: 'Payment amount',
paymentMember: 'Member',
paymentSaveAction: 'Save payment',
paymentDeleteAction: 'Delete payment',
deletingPayment: 'Deleting payment…',
purchaseSaveAction: 'Save purchase',
purchaseDeleteAction: 'Delete purchase',
deletingPurchase: 'Deleting purchase…',
savingPurchase: 'Saving purchase…',
householdSettingsTitle: 'Household settings',
householdSettingsBody: 'Control household defaults and approve roommates who requested access.',
topicBindingsTitle: 'Topic bindings',
@@ -171,6 +191,10 @@ export const dictionary = {
balances: 'Баланс',
ledger: 'Леджер',
house: 'Дом',
houseSectionBilling: 'Биллинг',
houseSectionUtilities: 'Коммуналка',
houseSectionMembers: 'Участники',
houseSectionTopics: 'Топики',
welcome: 'С возвращением',
adminTag: 'Админ',
residentTag: 'Житель',
@@ -204,6 +228,23 @@ export const dictionary = {
emptyDashboard: 'Пока нет готового billing cycle.',
latestActivityTitle: 'Последняя активность',
latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.',
purchaseReviewTitle: 'Покупки',
purchaseReviewBody:
'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.',
paymentsAdminTitle: 'Оплаты',
paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.',
paymentsAddAction: 'Добавить оплату',
addingPayment: 'Добавляем оплату…',
paymentKind: 'Тип оплаты',
paymentAmount: 'Сумма оплаты',
paymentMember: 'Участник',
paymentSaveAction: 'Сохранить оплату',
paymentDeleteAction: 'Удалить оплату',
deletingPayment: 'Удаляем оплату…',
purchaseSaveAction: 'Сохранить покупку',
purchaseDeleteAction: 'Удалить покупку',
deletingPurchase: 'Удаляем покупку…',
savingPurchase: 'Сохраняем покупку…',
householdSettingsTitle: 'Настройки household',
householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.',
topicBindingsTitle: 'Привязанные топики',

View File

@@ -327,6 +327,26 @@ button {
margin-top: 12px;
}
.section-switch {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.section-switch button {
border: 1px solid rgb(255 255 255 / 0.12);
border-radius: 16px;
min-height: 44px;
padding: 10px 12px;
background: rgb(255 255 255 / 0.04);
color: inherit;
}
.section-switch button.is-active {
border-color: rgb(247 179 137 / 0.7);
background: rgb(247 179 137 / 0.14);
}
.admin-layout {
gap: 18px;
}
@@ -396,7 +416,8 @@ button {
padding: 12px 14px;
background: rgb(255 255 255 / 0.04);
color: inherit;
line-height: 1.35;
font-size: 1rem;
line-height: 1.2;
}
.settings-field__value {
@@ -452,6 +473,13 @@ button {
grid-column: 1 / -1;
}
.activity-row p,
.ledger-item p,
.utility-bill-row p,
.balance-item p {
overflow-wrap: anywhere;
}
@media (min-width: 760px) {
.shell {
max-width: 920px;
@@ -498,6 +526,10 @@ button {
.balance-item--wide {
grid-column: 1 / -1;
}
.section-switch {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media (min-width: 980px) {

View File

@@ -96,6 +96,7 @@ export interface MiniAppDashboard {
id: string
kind: 'purchase' | 'utility' | 'payment'
title: string
memberId: string | null
paymentKind: 'rent' | 'utilities' | null
amountMajor: string
currency: 'USD' | 'GEL'
@@ -731,3 +732,118 @@ export async function deleteMiniAppUtilityBill(
return payload.cycleState
}
export async function updateMiniAppPurchase(
initData: string,
input: {
purchaseId: string
description: string
amountMajor: string
currency: 'USD' | 'GEL'
}
): Promise<void> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/purchases/update`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
...input
})
})
const payload = (await response.json()) as { ok: boolean; authorized?: boolean; error?: string }
if (!response.ok || !payload.authorized) {
throw new Error(payload.error ?? 'Failed to update purchase')
}
}
export async function deleteMiniAppPurchase(initData: string, purchaseId: string): Promise<void> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/purchases/delete`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
purchaseId
})
})
const payload = (await response.json()) as { ok: boolean; authorized?: boolean; error?: string }
if (!response.ok || !payload.authorized) {
throw new Error(payload.error ?? 'Failed to delete purchase')
}
}
export async function addMiniAppPayment(
initData: string,
input: {
memberId: string
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
}
): Promise<void> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/payments/add`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
...input
})
})
const payload = (await response.json()) as { ok: boolean; authorized?: boolean; error?: string }
if (!response.ok || !payload.authorized) {
throw new Error(payload.error ?? 'Failed to add payment')
}
}
export async function updateMiniAppPayment(
initData: string,
input: {
paymentId: string
memberId: string
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
}
): Promise<void> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/payments/update`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
...input
})
})
const payload = (await response.json()) as { ok: boolean; authorized?: boolean; error?: string }
if (!response.ok || !payload.authorized) {
throw new Error(payload.error ?? 'Failed to update payment')
}
}
export async function deleteMiniAppPayment(initData: string, paymentId: string): Promise<void> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/payments/delete`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
paymentId
})
})
const payload = (await response.json()) as { ok: boolean; authorized?: boolean; error?: string }
if (!response.ok || !payload.authorized) {
throw new Error(payload.error ?? 'Failed to delete payment')
}
}