mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 12:04:02 +00:00
feat(miniapp): add billing review tools and house sections
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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: 'Привязанные топики',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user