feat(miniapp): improve mobile billing and utility controls

This commit is contained in:
2026-03-10 18:50:19 +04:00
parent 3168356431
commit b7658164a8
15 changed files with 878 additions and 52 deletions

View File

@@ -5,6 +5,7 @@ import {
addMiniAppUtilityBill,
approveMiniAppPendingMember,
closeMiniAppBillingCycle,
deleteMiniAppUtilityBill,
fetchMiniAppAdminSettings,
fetchMiniAppBillingCycle,
fetchMiniAppDashboard,
@@ -20,6 +21,7 @@ import {
updateMiniAppBillingSettings,
updateMiniAppCycleRent,
upsertMiniAppUtilityCategory,
updateMiniAppUtilityBill,
type MiniAppDashboard,
type MiniAppPendingMember
} from './miniapp-api'
@@ -62,6 +64,12 @@ type SessionState =
type NavigationKey = 'home' | 'balances' | 'ledger' | 'house'
type UtilityBillDraft = {
billName: string
amountMajor: string
currency: 'USD' | 'GEL'
}
const demoSession: Extract<SessionState, { status: 'ready' }> = {
status: 'ready',
mode: 'demo',
@@ -186,6 +194,21 @@ function ledgerSecondaryAmount(entry: MiniAppDashboard['ledger'][number]): strin
return `${entry.amountMajor} ${entry.currency}`
}
function cycleUtilityBillDrafts(
bills: MiniAppAdminCycleState['utilityBills']
): Record<string, UtilityBillDraft> {
return Object.fromEntries(
bills.map((bill) => [
bill.id,
{
billName: bill.billName,
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
currency: bill.currency
}
])
)
}
function App() {
const [locale, setLocale] = createSignal<Locale>('en')
const [session, setSession] = createSignal<SessionState>({
@@ -209,6 +232,11 @@ function App() {
const [closingCycle, setClosingCycle] = createSignal(false)
const [savingCycleRent, setSavingCycleRent] = createSignal(false)
const [savingUtilityBill, setSavingUtilityBill] = createSignal(false)
const [savingUtilityBillId, setSavingUtilityBillId] = createSignal<string | null>(null)
const [deletingUtilityBillId, setDeletingUtilityBillId] = createSignal<string | null>(null)
const [utilityBillDrafts, setUtilityBillDrafts] = createSignal<Record<string, UtilityBillDraft>>(
{}
)
const [billingForm, setBillingForm] = createSignal({
settlementCurrency: 'GEL' as 'USD' | 'GEL',
rentAmountMajor: '',
@@ -222,7 +250,8 @@ function App() {
const [newCategoryName, setNewCategoryName] = createSignal('')
const [cycleForm, setCycleForm] = createSignal({
period: defaultCyclePeriod(),
currency: 'GEL' as 'USD' | 'GEL',
rentCurrency: 'USD' as 'USD' | 'GEL',
utilityCurrency: 'GEL' as 'USD' | 'GEL',
rentAmountMajor: '',
utilityCategorySlug: '',
utilityAmountMajor: ''
@@ -320,7 +349,8 @@ function App() {
)
setCycleForm((current) => ({
...current,
currency: current.currency || payload.settings.settlementCurrency,
rentCurrency: payload.settings.rentCurrency,
utilityCurrency: payload.settings.settlementCurrency,
utilityCategorySlug:
current.utilityCategorySlug ||
payload.categories.find((category) => category.isActive)?.slug ||
@@ -351,13 +381,15 @@ function App() {
try {
const payload = await fetchMiniAppBillingCycle(initData)
setCycleState(payload)
setUtilityBillDrafts(cycleUtilityBillDrafts(payload.utilityBills))
setCycleForm((current) => ({
...current,
period: payload.cycle?.period ?? current.period,
currency:
payload.cycle?.currency ??
adminSettings()?.settings.settlementCurrency ??
current.currency,
rentCurrency:
payload.rentRule?.currency ??
adminSettings()?.settings.rentCurrency ??
current.rentCurrency,
utilityCurrency: adminSettings()?.settings.settlementCurrency ?? current.utilityCurrency,
rentAmountMajor: payload.rentRule
? (Number(payload.rentRule.amountMinor) / 100).toFixed(2)
: '',
@@ -707,7 +739,8 @@ function App() {
)
setCycleForm((current) => ({
...current,
currency: cycleState()?.cycle?.currency ?? settings.settlementCurrency
rentCurrency: settings.rentCurrency,
utilityCurrency: settings.settlementCurrency
}))
} finally {
setSavingBillingSettings(false)
@@ -726,13 +759,14 @@ function App() {
try {
const state = await openMiniAppBillingCycle(initData, {
period: cycleForm().period,
currency: cycleForm().currency
currency: billingForm().settlementCurrency
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setCycleForm((current) => ({
...current,
period: state.cycle?.period ?? current.period,
currency: state.cycle?.currency ?? current.currency
utilityCurrency: billingForm().settlementCurrency
}))
} finally {
setOpeningCycle(false)
@@ -751,6 +785,7 @@ function App() {
try {
const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period)
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
} finally {
setClosingCycle(false)
}
@@ -768,7 +803,7 @@ function App() {
try {
const state = await updateMiniAppCycleRent(initData, {
amountMajor: cycleForm().rentAmountMajor,
currency: cycleForm().currency,
currency: cycleForm().rentCurrency,
...(cycleState()?.cycle?.period
? {
period: cycleState()!.cycle!.period
@@ -776,6 +811,7 @@ function App() {
: {})
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
} finally {
setSavingCycleRent(false)
}
@@ -803,9 +839,10 @@ function App() {
const state = await addMiniAppUtilityBill(initData, {
billName: selectedCategory.name,
amountMajor: cycleForm().utilityAmountMajor,
currency: cycleForm().currency
currency: cycleForm().utilityCurrency
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
setCycleForm((current) => ({
...current,
utilityAmountMajor: ''
@@ -815,6 +852,56 @@ function App() {
}
}
async function handleUpdateUtilityBill(billId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const draft = utilityBillDrafts()[billId]
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!draft ||
draft.billName.trim().length === 0 ||
draft.amountMajor.trim().length === 0
) {
return
}
setSavingUtilityBillId(billId)
try {
const state = await updateMiniAppUtilityBill(initData, {
billId,
billName: draft.billName,
amountMajor: draft.amountMajor,
currency: draft.currency
})
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
} finally {
setSavingUtilityBillId(null)
}
}
async function handleDeleteUtilityBill(billId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
if (!initData || currentReady?.mode !== 'live' || !currentReady.member.isAdmin) {
return
}
setDeletingUtilityBillId(billId)
try {
const state = await deleteMiniAppUtilityBill(initData, billId)
setCycleState(state)
setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills))
} finally {
setDeletingUtilityBillId(null)
}
}
async function handleSaveUtilityCategory(input: {
slug?: string
name: string
@@ -1138,7 +1225,7 @@ function App() {
<p>
{copy().billingCycleStatus.replace(
'{currency}',
cycleState()?.cycle?.currency ?? cycleForm().currency
cycleState()?.cycle?.currency ?? billingForm().settlementCurrency
)}
</p>
<Show when={dashboard()}>
@@ -1168,11 +1255,11 @@ function App() {
<label class="settings-field">
<span>{copy().shareRent}</span>
<select
value={cycleForm().currency}
value={cycleForm().rentCurrency}
onChange={(event) =>
setCycleForm((current) => ({
...current,
currency: event.currentTarget.value as 'USD' | 'GEL'
rentCurrency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
@@ -1218,21 +1305,12 @@ function App() {
}
/>
</label>
<label class="settings-field">
<div class="settings-field">
<span>{copy().settlementCurrency}</span>
<select
value={cycleForm().currency}
onChange={(event) =>
setCycleForm((current) => ({
...current,
currency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
<option value="USD">USD</option>
<option value="GEL">GEL</option>
</select>
</label>
<div class="settings-field__value">
{billingForm().settlementCurrency}
</div>
</div>
</div>
<button
class="ghost-button"
@@ -1447,7 +1525,7 @@ function App() {
<article class="balance-item">
<header>
<strong>{copy().utilityLedgerTitle}</strong>
<span>{cycleState()?.cycle?.currency ?? billingForm().settlementCurrency}</span>
<span>{cycleForm().utilityCurrency}</span>
</header>
<div class="settings-grid">
<label class="settings-field">
@@ -1480,6 +1558,21 @@ function App() {
}
/>
</label>
<label class="settings-field">
<span>{copy().settlementCurrency}</span>
<select
value={cycleForm().utilityCurrency}
onChange={(event) =>
setCycleForm((current) => ({
...current,
utilityCurrency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
<option value="GEL">GEL</option>
<option value="USD">USD</option>
</select>
</label>
</div>
<button
class="ghost-button"
@@ -1491,17 +1584,103 @@ function App() {
>
{savingUtilityBill() ? copy().savingUtilityBill : copy().addUtilityBillAction}
</button>
<div class="balance-list admin-sublist">
<div class="admin-sublist admin-sublist--plain">
{cycleState()?.utilityBills.length ? (
cycleState()?.utilityBills.map((bill) => (
<article class="ledger-item">
<article class="utility-bill-row">
<header>
<strong>{bill.billName}</strong>
<span>
{(Number(bill.amountMinor) / 100).toFixed(2)} {bill.currency}
</span>
<strong>
{utilityBillDrafts()[bill.id]?.billName ?? bill.billName}
</strong>
<span>{bill.createdAt.slice(0, 10)}</span>
</header>
<p>{bill.createdAt.slice(0, 10)}</p>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().utilityCategoryName}</span>
<input
value={utilityBillDrafts()[bill.id]?.billName ?? bill.billName}
onInput={(event) =>
setUtilityBillDrafts((current) => ({
...current,
[bill.id]: {
...(current[bill.id] ?? {
billName: bill.billName,
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
currency: bill.currency
}),
billName: event.currentTarget.value
}
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().utilityAmount}</span>
<input
value={
utilityBillDrafts()[bill.id]?.amountMajor ??
minorToMajorString(BigInt(bill.amountMinor))
}
onInput={(event) =>
setUtilityBillDrafts((current) => ({
...current,
[bill.id]: {
...(current[bill.id] ?? {
billName: bill.billName,
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
currency: bill.currency
}),
amountMajor: event.currentTarget.value
}
}))
}
/>
</label>
<label class="settings-field">
<span>{copy().settlementCurrency}</span>
<select
value={utilityBillDrafts()[bill.id]?.currency ?? bill.currency}
onChange={(event) =>
setUtilityBillDrafts((current) => ({
...current,
[bill.id]: {
...(current[bill.id] ?? {
billName: bill.billName,
amountMajor: minorToMajorString(BigInt(bill.amountMinor)),
currency: bill.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={savingUtilityBillId() === bill.id}
onClick={() => void handleUpdateUtilityBill(bill.id)}
>
{savingUtilityBillId() === bill.id
? copy().savingUtilityBill
: copy().saveUtilityBillAction}
</button>
<button
class="ghost-button ghost-button--danger"
type="button"
disabled={deletingUtilityBillId() === bill.id}
onClick={() => void handleDeleteUtilityBill(bill.id)}
>
{deletingUtilityBillId() === bill.id
? copy().deletingUtilityBill
: copy().deleteUtilityBillAction}
</button>
</div>
</article>
))
) : (
@@ -1515,9 +1694,9 @@ function App() {
<strong>{copy().utilityCategoriesTitle}</strong>
<span>{String(adminSettings()?.categories.length ?? 0)}</span>
</header>
<div class="balance-list admin-sublist">
<div class="admin-sublist admin-sublist--plain">
{adminSettings()?.categories.map((category) => (
<article class="ledger-item">
<article class="utility-bill-row">
<header>
<strong>{category.name}</strong>
<span>{category.isActive ? 'ON' : 'OFF'}</span>
@@ -1646,7 +1825,7 @@ function App() {
</header>
<div class="balance-list admin-sublist">
{adminSettings()?.members.map((member) => (
<article class="ledger-item">
<article class="utility-bill-row">
<header>
<strong>{member.displayName}</strong>
<span>{member.isAdmin ? copy().adminTag : copy().residentTag}</span>
@@ -1709,7 +1888,7 @@ function App() {
{pendingMembers().length === 0 ? (
<p>{copy().pendingMembersEmpty}</p>
) : (
<div class="balance-list admin-sublist">
<div class="admin-sublist admin-sublist--plain">
{pendingMembers().map((member) => (
<article class="ledger-item">
<header>
@@ -1751,7 +1930,7 @@ function App() {
)
default:
return (
<div class="home-grid">
<div class="home-grid home-grid--summary">
<article class="stat-card">
<span>{copy().totalDue}</span>
<strong>
@@ -1852,7 +2031,7 @@ function App() {
</article>
)}
<article class="balance-item">
<article class="balance-item balance-item--wide">
<header>
<strong>{copy().latestActivityTitle}</strong>
</header>
@@ -1863,9 +2042,9 @@ function App() {
data.ledger.length === 0 ? (
<p>{copy().latestActivityEmpty}</p>
) : (
<div class="ledger-list">
<div class="activity-list">
{data.ledger.slice(0, 3).map((entry) => (
<article class="ledger-item">
<article class="activity-row">
<header>
<strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span>

View File

@@ -97,6 +97,9 @@ export const dictionary = {
utilityAmount: 'Utility amount',
addUtilityBillAction: 'Add utility bill',
savingUtilityBill: 'Saving utility bill…',
saveUtilityBillAction: 'Save utility bill',
deleteUtilityBillAction: 'Delete utility bill',
deletingUtilityBill: 'Deleting utility bill…',
utilityBillsEmpty: 'No utility bills recorded for this cycle yet.',
rentAmount: 'Rent amount',
rentDueDay: 'Rent due day',
@@ -229,6 +232,9 @@ export const dictionary = {
utilityAmount: 'Сумма коммуналки',
addUtilityBillAction: 'Добавить коммунальный счёт',
savingUtilityBill: 'Сохраняем счёт…',
saveUtilityBillAction: 'Сохранить счёт',
deleteUtilityBillAction: 'Удалить счёт',
deletingUtilityBill: 'Удаляем счёт…',
utilityBillsEmpty: 'Для этого цикла пока нет коммунальных счетов.',
rentAmount: 'Сумма аренды',
rentDueDay: 'День оплаты аренды',

View File

@@ -231,14 +231,17 @@ button {
.ledger-list,
.home-grid,
.admin-layout,
.admin-sublist {
.admin-sublist,
.activity-list {
display: grid;
gap: 12px;
}
.balance-item,
.ledger-item,
.stat-card {
.stat-card,
.activity-row,
.utility-bill-row {
border: 1px solid rgb(255 255 255 / 0.08);
border-radius: 18px;
padding: 14px;
@@ -253,7 +256,9 @@ button {
}
.balance-item header,
.ledger-item header {
.ledger-item header,
.activity-row header,
.utility-bill-row header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
@@ -262,13 +267,17 @@ button {
}
.balance-item strong,
.ledger-item strong {
.ledger-item strong,
.activity-row strong,
.utility-bill-row strong {
font-size: 1rem;
overflow-wrap: anywhere;
}
.balance-item p,
.ledger-item p {
.ledger-item p,
.activity-row p,
.utility-bill-row p {
margin-top: 6px;
}
@@ -285,7 +294,7 @@ button {
}
.home-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(0, 1fr);
}
.stat-card {
@@ -360,6 +369,10 @@ button {
margin-top: 12px;
}
.admin-sublist--plain {
gap: 10px;
}
.admin-card--wide {
min-width: 0;
}
@@ -379,6 +392,19 @@ button {
width: 100%;
border: 1px solid rgb(255 255 255 / 0.12);
border-radius: 14px;
min-height: 48px;
padding: 12px 14px;
background: rgb(255 255 255 / 0.04);
color: inherit;
line-height: 1.35;
}
.settings-field__value {
display: flex;
align-items: center;
min-height: 48px;
border: 1px solid rgb(255 255 255 / 0.12);
border-radius: 14px;
padding: 12px 14px;
background: rgb(255 255 255 / 0.04);
color: inherit;
@@ -399,10 +425,33 @@ button {
margin-top: 0;
}
.ghost-button--danger {
border-color: rgb(247 115 115 / 0.28);
color: #ffc5c5;
}
.panel--wide {
min-height: 170px;
}
.activity-row,
.utility-bill-row {
background: rgb(255 255 255 / 0.02);
}
.activity-row header,
.utility-bill-row header {
margin-bottom: 4px;
}
.activity-row strong {
word-break: break-word;
}
.balance-item--wide {
grid-column: 1 / -1;
}
@media (min-width: 760px) {
.shell {
max-width: 920px;
@@ -418,6 +467,10 @@ button {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.home-grid--summary {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.balance-breakdown {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
@@ -441,6 +494,10 @@ button {
.admin-card--wide {
grid-column: 1 / -1;
}
.balance-item--wide {
grid-column: 1 / -1;
}
}
@media (min-width: 980px) {
@@ -448,3 +505,37 @@ button {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 759px) {
.shell {
padding: 18px 14px 28px;
}
.topbar {
flex-direction: column;
}
.locale-switch {
width: 100%;
min-width: 0;
}
.nav-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.balance-breakdown {
grid-template-columns: minmax(0, 1fr);
}
.admin-section__header {
align-items: start;
}
.activity-row header,
.ledger-item header,
.utility-bill-row header,
.balance-item header {
grid-template-columns: minmax(0, 1fr);
}
}

View File

@@ -668,3 +668,66 @@ export async function addMiniAppUtilityBill(
return payload.cycleState
}
export async function updateMiniAppUtilityBill(
initData: string,
input: {
billId: string
billName: string
amountMajor: string
currency: 'USD' | 'GEL'
}
): Promise<MiniAppAdminCycleState> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-bills/update`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
...input
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
cycleState?: MiniAppAdminCycleState
error?: string
}
if (!response.ok || !payload.authorized || !payload.cycleState) {
throw new Error(payload.error ?? 'Failed to update utility bill')
}
return payload.cycleState
}
export async function deleteMiniAppUtilityBill(
initData: string,
billId: string
): Promise<MiniAppAdminCycleState> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/utility-bills/delete`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
billId
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
cycleState?: MiniAppAdminCycleState
error?: string
}
if (!response.ok || !payload.authorized || !payload.cycleState) {
throw new Error(payload.error ?? 'Failed to delete utility bill')
}
return payload.cycleState
}