mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:54: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 { dictionary, type Locale } from './i18n'
|
||||||
import {
|
import {
|
||||||
addMiniAppUtilityBill,
|
addMiniAppUtilityBill,
|
||||||
|
addMiniAppPayment,
|
||||||
approveMiniAppPendingMember,
|
approveMiniAppPendingMember,
|
||||||
closeMiniAppBillingCycle,
|
closeMiniAppBillingCycle,
|
||||||
|
deleteMiniAppPayment,
|
||||||
|
deleteMiniAppPurchase,
|
||||||
deleteMiniAppUtilityBill,
|
deleteMiniAppUtilityBill,
|
||||||
fetchMiniAppAdminSettings,
|
fetchMiniAppAdminSettings,
|
||||||
fetchMiniAppBillingCycle,
|
fetchMiniAppBillingCycle,
|
||||||
@@ -20,6 +23,8 @@ import {
|
|||||||
updateMiniAppLocalePreference,
|
updateMiniAppLocalePreference,
|
||||||
updateMiniAppBillingSettings,
|
updateMiniAppBillingSettings,
|
||||||
updateMiniAppCycleRent,
|
updateMiniAppCycleRent,
|
||||||
|
updateMiniAppPayment,
|
||||||
|
updateMiniAppPurchase,
|
||||||
upsertMiniAppUtilityCategory,
|
upsertMiniAppUtilityCategory,
|
||||||
updateMiniAppUtilityBill,
|
updateMiniAppUtilityBill,
|
||||||
type MiniAppDashboard,
|
type MiniAppDashboard,
|
||||||
@@ -63,6 +68,7 @@ type SessionState =
|
|||||||
}
|
}
|
||||||
|
|
||||||
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
|
||||||
|
type HouseSectionKey = 'billing' | 'utilities' | 'members' | 'topics'
|
||||||
|
|
||||||
type UtilityBillDraft = {
|
type UtilityBillDraft = {
|
||||||
billName: string
|
billName: string
|
||||||
@@ -70,6 +76,19 @@ type UtilityBillDraft = {
|
|||||||
currency: 'USD' | 'GEL'
|
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' }> = {
|
const demoSession: Extract<SessionState, { status: 'ready' }> = {
|
||||||
status: 'ready',
|
status: 'ready',
|
||||||
mode: 'demo',
|
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() {
|
function App() {
|
||||||
const [locale, setLocale] = createSignal<Locale>('en')
|
const [locale, setLocale] = createSignal<Locale>('en')
|
||||||
const [session, setSession] = createSignal<SessionState>({
|
const [session, setSession] = createSignal<SessionState>({
|
||||||
status: 'loading'
|
status: 'loading'
|
||||||
})
|
})
|
||||||
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
const [activeNav, setActiveNav] = createSignal<NavigationKey>('home')
|
||||||
|
const [activeHouseSection, setActiveHouseSection] = createSignal<HouseSectionKey>('billing')
|
||||||
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
const [dashboard, setDashboard] = createSignal<MiniAppDashboard | null>(null)
|
||||||
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
|
||||||
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
|
const [adminSettings, setAdminSettings] = createSignal<MiniAppAdminSettingsPayload | null>(null)
|
||||||
@@ -237,6 +292,13 @@ function App() {
|
|||||||
const [utilityBillDrafts, setUtilityBillDrafts] = createSignal<Record<string, UtilityBillDraft>>(
|
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({
|
const [billingForm, setBillingForm] = createSignal({
|
||||||
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
settlementCurrency: 'GEL' as 'USD' | 'GEL',
|
||||||
rentAmountMajor: '',
|
rentAmountMajor: '',
|
||||||
@@ -256,6 +318,12 @@ function App() {
|
|||||||
utilityCategorySlug: '',
|
utilityCategorySlug: '',
|
||||||
utilityAmountMajor: ''
|
utilityAmountMajor: ''
|
||||||
})
|
})
|
||||||
|
const [paymentForm, setPaymentForm] = createSignal<PaymentDraft>({
|
||||||
|
memberId: '',
|
||||||
|
kind: 'rent',
|
||||||
|
amountMajor: '',
|
||||||
|
currency: 'GEL'
|
||||||
|
})
|
||||||
|
|
||||||
const copy = createMemo(() => dictionary[locale()])
|
const copy = createMemo(() => dictionary[locale()])
|
||||||
const onboardingSession = createMemo(() => {
|
const onboardingSession = createMemo(() => {
|
||||||
@@ -316,13 +384,18 @@ function App() {
|
|||||||
|
|
||||||
async function loadDashboard(initData: string) {
|
async function loadDashboard(initData: string) {
|
||||||
try {
|
try {
|
||||||
setDashboard(await fetchMiniAppDashboard(initData))
|
const nextDashboard = await fetchMiniAppDashboard(initData)
|
||||||
|
setDashboard(nextDashboard)
|
||||||
|
setPurchaseDraftMap(purchaseDrafts(nextDashboard.ledger))
|
||||||
|
setPaymentDraftMap(paymentDrafts(nextDashboard.ledger))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.warn('Failed to load mini app dashboard', error)
|
console.warn('Failed to load mini app dashboard', error)
|
||||||
}
|
}
|
||||||
|
|
||||||
setDashboard(null)
|
setDashboard(null)
|
||||||
|
setPurchaseDraftMap({})
|
||||||
|
setPaymentDraftMap({})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,6 +441,11 @@ function App() {
|
|||||||
utilitiesReminderDay: payload.settings.utilitiesReminderDay,
|
utilitiesReminderDay: payload.settings.utilitiesReminderDay,
|
||||||
timezone: payload.settings.timezone
|
timezone: payload.settings.timezone
|
||||||
})
|
})
|
||||||
|
setPaymentForm((current) => ({
|
||||||
|
...current,
|
||||||
|
memberId: current.memberId || payload.members[0]?.id || '',
|
||||||
|
currency: payload.settings.settlementCurrency
|
||||||
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
console.warn('Failed to load mini app admin settings', error)
|
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() {
|
async function bootstrap() {
|
||||||
const fallbackLocale = detectLocale()
|
const fallbackLocale = detectLocale()
|
||||||
setLocale(fallbackLocale)
|
setLocale(fallbackLocale)
|
||||||
@@ -513,6 +613,7 @@ function App() {
|
|||||||
id: 'purchase-1',
|
id: 'purchase-1',
|
||||||
kind: 'purchase',
|
kind: 'purchase',
|
||||||
title: 'Soap',
|
title: 'Soap',
|
||||||
|
memberId: 'member-2',
|
||||||
paymentKind: null,
|
paymentKind: null,
|
||||||
amountMajor: '30.00',
|
amountMajor: '30.00',
|
||||||
currency: 'GEL',
|
currency: 'GEL',
|
||||||
@@ -527,6 +628,7 @@ function App() {
|
|||||||
id: 'utility-1',
|
id: 'utility-1',
|
||||||
kind: 'utility',
|
kind: 'utility',
|
||||||
title: 'Electricity',
|
title: 'Electricity',
|
||||||
|
memberId: null,
|
||||||
paymentKind: null,
|
paymentKind: null,
|
||||||
amountMajor: '120.00',
|
amountMajor: '120.00',
|
||||||
currency: 'GEL',
|
currency: 'GEL',
|
||||||
@@ -541,6 +643,7 @@ function App() {
|
|||||||
id: 'payment-1',
|
id: 'payment-1',
|
||||||
kind: 'payment',
|
kind: 'payment',
|
||||||
title: 'rent',
|
title: 'rent',
|
||||||
|
memberId: 'demo-member',
|
||||||
paymentKind: 'rent',
|
paymentKind: 'rent',
|
||||||
amountMajor: '501.00',
|
amountMajor: '501.00',
|
||||||
currency: 'GEL',
|
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: {
|
async function handleSaveUtilityCategory(input: {
|
||||||
slug?: string
|
slug?: string
|
||||||
name: string
|
name: string
|
||||||
@@ -1106,22 +1333,130 @@ function App() {
|
|||||||
<>
|
<>
|
||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{copy().purchasesTitle}</strong>
|
<strong>
|
||||||
|
{readySession()?.member.isAdmin
|
||||||
|
? copy().purchaseReviewTitle
|
||||||
|
: copy().purchasesTitle}
|
||||||
|
</strong>
|
||||||
</header>
|
</header>
|
||||||
|
<Show when={readySession()?.member.isAdmin}>
|
||||||
|
<p>{copy().purchaseReviewBody}</p>
|
||||||
|
</Show>
|
||||||
{purchaseLedger().length === 0 ? (
|
{purchaseLedger().length === 0 ? (
|
||||||
<p>{copy().purchasesEmpty}</p>
|
<p>{copy().purchasesEmpty}</p>
|
||||||
) : (
|
) : (
|
||||||
<div class="ledger-list">
|
<div class="ledger-list">
|
||||||
{purchaseLedger().map((entry) => (
|
{purchaseLedger().map((entry) => (
|
||||||
<article class="ledger-item">
|
<article class="utility-bill-row">
|
||||||
<header>
|
<header>
|
||||||
<strong>{ledgerTitle(entry)}</strong>
|
<strong>
|
||||||
<span>{ledgerPrimaryAmount(entry)}</span>
|
{entry.actorDisplayName ?? copy().ledgerActorFallback}
|
||||||
|
</strong>
|
||||||
|
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
|
||||||
</header>
|
</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)}>
|
<Show when={ledgerSecondaryAmount(entry)}>
|
||||||
{(secondary) => <p>{secondary()}</p>}
|
{(secondary) => <p>{secondary()}</p>}
|
||||||
</Show>
|
</Show>
|
||||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
<p>{entry.title}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1152,22 +1487,232 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
<article class="balance-item">
|
<article class="balance-item">
|
||||||
<header>
|
<header>
|
||||||
<strong>{copy().paymentsTitle}</strong>
|
<strong>{copy().paymentsAdminTitle}</strong>
|
||||||
</header>
|
</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 ? (
|
{paymentLedger().length === 0 ? (
|
||||||
<p>{copy().paymentsEmpty}</p>
|
<p>{copy().paymentsEmpty}</p>
|
||||||
) : (
|
) : (
|
||||||
<div class="ledger-list">
|
<div class="ledger-list">
|
||||||
{paymentLedger().map((entry) => (
|
{paymentLedger().map((entry) => (
|
||||||
<article class="ledger-item">
|
<article class="utility-bill-row">
|
||||||
<header>
|
<header>
|
||||||
<strong>{ledgerTitle(entry)}</strong>
|
<strong>
|
||||||
<span>{ledgerPrimaryAmount(entry)}</span>
|
{entry.actorDisplayName ?? copy().ledgerActorFallback}
|
||||||
|
</strong>
|
||||||
|
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
|
||||||
</header>
|
</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)}>
|
<Show when={ledgerSecondaryAmount(entry)}>
|
||||||
{(secondary) => <p>{secondary()}</p>}
|
{(secondary) => <p>{secondary()}</p>}
|
||||||
</Show>
|
</Show>
|
||||||
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
|
<p>{ledgerTitle(entry)}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -1207,6 +1752,26 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</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">
|
<section class="admin-section">
|
||||||
<header class="admin-section__header">
|
<header class="admin-section__header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1277,7 +1842,9 @@ function App() {
|
|||||||
}
|
}
|
||||||
onClick={() => void handleSaveCycleRent()}
|
onClick={() => void handleSaveCycleRent()}
|
||||||
>
|
>
|
||||||
{savingCycleRent() ? copy().savingCycleRent : copy().saveCycleRentAction}
|
{savingCycleRent()
|
||||||
|
? copy().savingCycleRent
|
||||||
|
: copy().saveCycleRentAction}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="ghost-button"
|
class="ghost-button"
|
||||||
@@ -1484,36 +2051,11 @@ function App() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeHouseSection() === 'utilities'}>
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<header class="admin-section__header">
|
<header class="admin-section__header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1809,7 +2351,9 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<Show when={activeHouseSection() === 'members'}>
|
||||||
<section class="admin-section">
|
<section class="admin-section">
|
||||||
<header class="admin-section__header">
|
<header class="admin-section__header">
|
||||||
<div>
|
<div>
|
||||||
@@ -1917,6 +2461,45 @@ function App() {
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div class="balance-list">
|
<div class="balance-list">
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ export const dictionary = {
|
|||||||
balances: 'Balances',
|
balances: 'Balances',
|
||||||
ledger: 'Ledger',
|
ledger: 'Ledger',
|
||||||
house: 'House',
|
house: 'House',
|
||||||
|
houseSectionBilling: 'Billing',
|
||||||
|
houseSectionUtilities: 'Utilities',
|
||||||
|
houseSectionMembers: 'Members',
|
||||||
|
houseSectionTopics: 'Topics',
|
||||||
welcome: 'Welcome back',
|
welcome: 'Welcome back',
|
||||||
adminTag: 'Admin',
|
adminTag: 'Admin',
|
||||||
residentTag: 'Resident',
|
residentTag: 'Resident',
|
||||||
@@ -69,6 +73,22 @@ export const dictionary = {
|
|||||||
emptyDashboard: 'No billing cycle is ready yet.',
|
emptyDashboard: 'No billing cycle is ready yet.',
|
||||||
latestActivityTitle: 'Latest activity',
|
latestActivityTitle: 'Latest activity',
|
||||||
latestActivityEmpty: 'Recent utility and purchase entries will appear here.',
|
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',
|
householdSettingsTitle: 'Household settings',
|
||||||
householdSettingsBody: 'Control household defaults and approve roommates who requested access.',
|
householdSettingsBody: 'Control household defaults and approve roommates who requested access.',
|
||||||
topicBindingsTitle: 'Topic bindings',
|
topicBindingsTitle: 'Topic bindings',
|
||||||
@@ -171,6 +191,10 @@ export const dictionary = {
|
|||||||
balances: 'Баланс',
|
balances: 'Баланс',
|
||||||
ledger: 'Леджер',
|
ledger: 'Леджер',
|
||||||
house: 'Дом',
|
house: 'Дом',
|
||||||
|
houseSectionBilling: 'Биллинг',
|
||||||
|
houseSectionUtilities: 'Коммуналка',
|
||||||
|
houseSectionMembers: 'Участники',
|
||||||
|
houseSectionTopics: 'Топики',
|
||||||
welcome: 'С возвращением',
|
welcome: 'С возвращением',
|
||||||
adminTag: 'Админ',
|
adminTag: 'Админ',
|
||||||
residentTag: 'Житель',
|
residentTag: 'Житель',
|
||||||
@@ -204,6 +228,23 @@ export const dictionary = {
|
|||||||
emptyDashboard: 'Пока нет готового billing cycle.',
|
emptyDashboard: 'Пока нет готового billing cycle.',
|
||||||
latestActivityTitle: 'Последняя активность',
|
latestActivityTitle: 'Последняя активность',
|
||||||
latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.',
|
latestActivityEmpty: 'Здесь появятся последние коммунальные платежи и покупки.',
|
||||||
|
purchaseReviewTitle: 'Покупки',
|
||||||
|
purchaseReviewBody:
|
||||||
|
'Здесь можно исправить или удалить покупку, если бот распознал её неправильно.',
|
||||||
|
paymentsAdminTitle: 'Оплаты',
|
||||||
|
paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.',
|
||||||
|
paymentsAddAction: 'Добавить оплату',
|
||||||
|
addingPayment: 'Добавляем оплату…',
|
||||||
|
paymentKind: 'Тип оплаты',
|
||||||
|
paymentAmount: 'Сумма оплаты',
|
||||||
|
paymentMember: 'Участник',
|
||||||
|
paymentSaveAction: 'Сохранить оплату',
|
||||||
|
paymentDeleteAction: 'Удалить оплату',
|
||||||
|
deletingPayment: 'Удаляем оплату…',
|
||||||
|
purchaseSaveAction: 'Сохранить покупку',
|
||||||
|
purchaseDeleteAction: 'Удалить покупку',
|
||||||
|
deletingPurchase: 'Удаляем покупку…',
|
||||||
|
savingPurchase: 'Сохраняем покупку…',
|
||||||
householdSettingsTitle: 'Настройки household',
|
householdSettingsTitle: 'Настройки household',
|
||||||
householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.',
|
householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.',
|
||||||
topicBindingsTitle: 'Привязанные топики',
|
topicBindingsTitle: 'Привязанные топики',
|
||||||
|
|||||||
@@ -327,6 +327,26 @@ button {
|
|||||||
margin-top: 12px;
|
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 {
|
.admin-layout {
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
@@ -396,7 +416,8 @@ button {
|
|||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
background: rgb(255 255 255 / 0.04);
|
background: rgb(255 255 255 / 0.04);
|
||||||
color: inherit;
|
color: inherit;
|
||||||
line-height: 1.35;
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-field__value {
|
.settings-field__value {
|
||||||
@@ -452,6 +473,13 @@ button {
|
|||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.activity-row p,
|
||||||
|
.ledger-item p,
|
||||||
|
.utility-bill-row p,
|
||||||
|
.balance-item p {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 760px) {
|
@media (min-width: 760px) {
|
||||||
.shell {
|
.shell {
|
||||||
max-width: 920px;
|
max-width: 920px;
|
||||||
@@ -498,6 +526,10 @@ button {
|
|||||||
.balance-item--wide {
|
.balance-item--wide {
|
||||||
grid-column: 1 / -1;
|
grid-column: 1 / -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-switch {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 980px) {
|
@media (min-width: 980px) {
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export interface MiniAppDashboard {
|
|||||||
id: string
|
id: string
|
||||||
kind: 'purchase' | 'utility' | 'payment'
|
kind: 'purchase' | 'utility' | 'payment'
|
||||||
title: string
|
title: string
|
||||||
|
memberId: string | null
|
||||||
paymentKind: 'rent' | 'utilities' | null
|
paymentKind: 'rent' | 'utilities' | null
|
||||||
amountMajor: string
|
amountMajor: string
|
||||||
currency: 'USD' | 'GEL'
|
currency: 'USD' | 'GEL'
|
||||||
@@ -731,3 +732,118 @@ export async function deleteMiniAppUtilityBill(
|
|||||||
|
|
||||||
return payload.cycleState
|
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