feat(miniapp): carry overdue billing and admin role flows

This commit is contained in:
2026-03-23 15:44:55 +04:00
parent ee8c53d89b
commit 5af14e101e
44 changed files with 2965 additions and 329 deletions

View File

@@ -256,6 +256,7 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -40,6 +40,7 @@ import {
createMiniAppApproveMemberHandler,
createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
createMiniAppDemoteMemberHandler,
createMiniAppRejectMemberHandler,
createMiniAppSettingsHandler,
createMiniAppUpdateMemberAbsencePolicyHandler,
@@ -634,6 +635,15 @@ export async function createBotRuntimeApp(): Promise<BotRuntimeApp> {
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppDemoteMember: householdOnboardingService
? createMiniAppDemoteMemberHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,
botToken: runtime.telegramBotToken,
onboardingService: householdOnboardingService,
miniAppAdminService: miniAppAdminService!,
logger: getLogger('miniapp-admin')
})
: undefined,
miniAppUpdateOwnDisplayName: householdOnboardingService
? createMiniAppUpdateOwnDisplayNameHandler({
allowedOrigins: runtime.miniAppAllowedOrigins,

View File

@@ -135,6 +135,7 @@ function createRepository(isAdmin = false): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -267,6 +267,7 @@ function createHouseholdRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
@@ -366,6 +367,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('850.00', 'GEL'),
paid: Money.fromMajor('500.00', 'GEL'),
remaining: Money.fromMajor('350.00', 'GEL'),
overduePayments: [],
explanations: []
},
{
@@ -377,6 +379,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('815.00', 'GEL'),
paid: Money.fromMajor('200.00', 'GEL'),
remaining: Money.fromMajor('615.00', 'GEL'),
overduePayments: [],
explanations: []
},
{
@@ -388,6 +391,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('680.00', 'GEL'),
paid: Money.fromMajor('100.00', 'GEL'),
remaining: Money.fromMajor('580.00', 'GEL'),
overduePayments: [],
explanations: []
}
],

View File

@@ -113,6 +113,7 @@ function createRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
@@ -150,6 +151,7 @@ function createDashboard(): NonNullable<
netDue: Money.fromMajor('210', 'GEL'),
paid: Money.fromMajor('100', 'GEL'),
remaining: Money.fromMajor('110', 'GEL'),
overduePayments: [],
explanations: []
},
{
@@ -161,6 +163,7 @@ function createDashboard(): NonNullable<
netDue: Money.fromMajor('190', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('190', 'GEL'),
overduePayments: [],
explanations: []
}
],

View File

@@ -484,6 +484,9 @@ function createHouseholdConfigurationRepository(): HouseholdConfigurationReposit
async promoteHouseholdAdmin() {
return null
},
async demoteHouseholdAdmin() {
return null
},
async updateHouseholdMemberRentShareWeight() {
return null
},

View File

@@ -8,6 +8,7 @@ import type {
import {
createMiniAppApproveMemberHandler,
createMiniAppDemoteMemberHandler,
createMiniAppRejectMemberHandler,
createMiniAppPendingMembersHandler,
createMiniAppPromoteMemberHandler,
@@ -230,6 +231,28 @@ function onboardingRepository(): HouseholdConfigurationRepository {
}
: null
},
demoteHouseholdAdmin: async (householdId, memberId) => {
const member = [
{
id: 'member-123456',
householdId,
telegramUserId: '123456',
displayName: 'Stan',
status: 'active' as const,
preferredLocale: null,
householdDefaultLocale: household.defaultLocale,
rentShareWeight: 1,
isAdmin: true
}
].find((entry) => entry.id === memberId)
return member
? {
...member,
isAdmin: false
}
: null
},
updateHouseholdMemberRentShareWeight: async (_householdId, memberId, rentShareWeight) =>
memberId === 'member-123456'
? {
@@ -776,6 +799,95 @@ describe('createMiniAppPromoteMemberHandler', () => {
})
})
describe('createMiniAppDemoteMemberHandler', () => {
test('removes admin access from a household member for an authenticated admin', async () => {
const authDate = Math.floor(Date.now() / 1000)
const repository = onboardingRepository()
repository.listHouseholdMembersByTelegramUserId = async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
repository.listHouseholdMembers = async () => [
{
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
},
{
id: 'member-555777',
householdId: 'household-1',
telegramUserId: '555777',
displayName: 'Mia',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: true
}
]
const handler = createMiniAppDemoteMemberHandler({
allowedOrigins: ['http://localhost:5173'],
botToken: 'test-bot-token',
onboardingService: createHouseholdOnboardingService({
repository
}),
miniAppAdminService: createMiniAppAdminService(repository)
})
const response = await handler.handler(
new Request('http://localhost/api/miniapp/admin/members/demote', {
method: 'POST',
headers: {
origin: 'http://localhost:5173',
'content-type': 'application/json'
},
body: JSON.stringify({
initData: buildMiniAppInitData('test-bot-token', authDate, {
id: 123456,
first_name: 'Stan',
username: 'stanislav',
language_code: 'ru'
}),
memberId: 'member-123456'
})
})
)
expect(response.status).toBe(200)
expect(await response.json()).toEqual({
ok: true,
authorized: true,
member: {
id: 'member-123456',
householdId: 'household-1',
telegramUserId: '123456',
displayName: 'Stan',
status: 'active',
preferredLocale: null,
householdDefaultLocale: 'ru',
rentShareWeight: 1,
isAdmin: false
}
})
})
})
describe('createMiniAppUpdateOwnDisplayNameHandler', () => {
test('updates the acting member display name for an authenticated member', async () => {
const authDate = Math.floor(Date.now() / 1000)

View File

@@ -898,6 +898,94 @@ export function createMiniAppPromoteMemberHandler(options: {
}
}
export function createMiniAppDemoteMemberHandler(options: {
allowedOrigins: readonly string[]
botToken: string
onboardingService: HouseholdOnboardingService
miniAppAdminService: MiniAppAdminService
logger?: Logger
}): {
handler: (request: Request) => Promise<Response>
} {
const sessionService = createMiniAppSessionService({
botToken: options.botToken,
onboardingService: options.onboardingService
})
return {
handler: async (request) => {
const origin = allowedMiniAppOrigin(request, options.allowedOrigins)
if (request.method === 'OPTIONS') {
return miniAppJsonResponse({ ok: true }, 204, origin)
}
if (request.method !== 'POST') {
return miniAppJsonResponse({ ok: false, error: 'Method Not Allowed' }, 405, origin)
}
try {
const payload = await readPromoteMemberPayload(request)
const session = await sessionService.authenticate({
initData: payload.initData
})
if (!session) {
return miniAppJsonResponse(
{ ok: false, error: 'Invalid Telegram init data' },
401,
origin
)
}
if (
!session.authorized ||
!session.member ||
session.member.status !== 'active' ||
!session.member.isAdmin
) {
return miniAppJsonResponse(
{ ok: false, error: 'Admin access required for active household members' },
403,
origin
)
}
const result = await options.miniAppAdminService.demoteMemberFromAdmin({
householdId: session.member.householdId,
actorIsAdmin: session.member.isAdmin,
memberId: payload.memberId
})
if (result.status === 'rejected') {
const status =
result.reason === 'member_not_found' ? 404 : result.reason === 'last_admin' ? 409 : 403
const error =
result.reason === 'member_not_found'
? 'Member not found'
: result.reason === 'last_admin'
? 'Cannot remove the last household admin'
: 'Admin access required'
return miniAppJsonResponse({ ok: false, error }, status, origin)
}
return miniAppJsonResponse(
{
ok: true,
authorized: true,
member: result.member
},
200,
origin
)
} catch (error) {
return miniAppErrorResponse(error, origin, options.logger)
}
}
}
}
export function createMiniAppUpdateOwnDisplayNameHandler(options: {
allowedOrigins: readonly string[]
botToken: string

View File

@@ -191,7 +191,8 @@ function onboardingRepository(): HouseholdConfigurationRepository {
sortOrder: input.sortOrder,
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null
}
}

View File

@@ -137,6 +137,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
updateMemberPreferredLocale: async () => null,
updateHouseholdMemberDisplayName: async () => null,
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -486,6 +486,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{
kind?: 'rent' | 'utilities'
amountMajor?: string
currency?: string
period?: string
}> {
const parsed = await parseJsonBody<{
initData?: string
@@ -494,6 +495,7 @@ async function readPaymentMutationPayload(request: Request): Promise<{
kind?: 'rent' | 'utilities'
amountMajor?: string
currency?: string
period?: string
}>(request)
const initData = parsed.initData?.trim()
if (!initData) {
@@ -526,6 +528,11 @@ async function readPaymentMutationPayload(request: Request): Promise<{
? {
currency: parsed.currency.trim()
}
: {}),
...(parsed.period?.trim()
? {
period: BillingPeriod.fromString(parsed.period.trim()).toString()
}
: {})
}
}
@@ -1001,7 +1008,8 @@ export function createMiniAppSubmitPaymentHandler(options: {
auth.member.id,
payload.kind,
payload.amountMajor,
payload.currency
payload.currency,
payload.period
)
if (!payment) {
@@ -1374,7 +1382,8 @@ export function createMiniAppAddPaymentHandler(options: {
payload.memberId,
payload.kind,
payload.amountMajor,
payload.currency
payload.currency,
payload.period
)
if (!payment) {

View File

@@ -54,6 +54,7 @@ function repository(
isAdmin: true
}
],
listCycles: async () => [cycle],
getOpenCycle: async () => cycle,
getCycleByPeriod: async (period) => (period === cycle.period ? cycle : null),
getLatestCycle: async () => cycle,
@@ -72,6 +73,8 @@ function repository(
updateParsedPurchase: async () => null,
addParsedPurchase: async (input) => ({
id: 'purchase-new',
cycleId: input.cycleId,
cyclePeriod: null,
payerMemberId: input.payerMemberId,
amountMinor: input.amountMinor,
currency: input.currency,
@@ -90,6 +93,8 @@ function repository(
deleteUtilityBill: async () => false,
addPaymentRecord: async (input) => ({
id: 'payment-new',
cycleId: input.cycleId,
cyclePeriod: null,
memberId: input.memberId,
kind: input.kind,
amountMinor: input.amountMinor,
@@ -97,6 +102,8 @@ function repository(
recordedAt: input.recordedAt
}),
updatePaymentRecord: async () => null,
getPaymentRecord: async () => null,
replacePaymentPurchaseAllocations: async () => {},
deletePaymentRecord: async () => false,
getRentRuleForPeriod: async () => ({
amountMinor: 70000n,
@@ -116,6 +123,8 @@ function repository(
listPaymentRecordsForCycle: async () => [
{
id: 'payment-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
memberId: member?.id ?? 'member-1',
kind: 'rent',
amountMinor: 50000n,
@@ -126,6 +135,8 @@ function repository(
listParsedPurchasesForRange: async () => [
{
id: 'purchase-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
payerMemberId: member?.id ?? 'member-1',
amountMinor: 3000n,
currency: 'GEL',
@@ -133,6 +144,19 @@ function repository(
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
}
],
listParsedPurchases: async () => [
{
id: 'purchase-1',
cycleId: cycle.id,
cyclePeriod: cycle.period,
payerMemberId: member?.id ?? 'member-1',
amountMinor: 3000n,
currency: 'GEL',
description: 'Soap',
occurredAt: instantFromIso('2026-03-12T11:00:00.000Z')
}
],
listPaymentPurchaseAllocations: async () => [],
getSettlementSnapshotLines: async () => [],
savePaymentConfirmation: async () =>
({
@@ -282,6 +306,7 @@ function onboardingRepository(): HouseholdConfigurationRepository {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],
@@ -364,6 +389,7 @@ describe('createMiniAppDashboardHandler', () => {
{
displayName: 'Stan',
netDueMajor: '2010.00',
overduePayments: [],
paidMajor: '500.00',
remainingMajor: '1510.00',
rentShareMajor: '1890.00',
@@ -408,6 +434,8 @@ describe('createMiniAppDashboardHandler', () => {
financeRepository.listParsedPurchasesForRange = async () => [
{
id: 'purchase-1',
cycleId: 'cycle-1',
cyclePeriod: '2026-03',
payerMemberId: 'member-1',
amountMinor: 3000n,
currency: 'GEL',
@@ -433,6 +461,11 @@ describe('createMiniAppDashboardHandler', () => {
]
}
]
financeRepository.listParsedPurchases = async () =>
financeRepository.listParsedPurchasesForRange(
instantFromIso('2026-03-01T00:00:00.000Z'),
instantFromIso('2026-04-01T00:00:00.000Z')
)
financeRepository.listMembers = async () => [
{
id: 'member-1',

View File

@@ -1,4 +1,5 @@
import type { FinanceCommandService, HouseholdOnboardingService } from '@household/application'
import { Money } from '@household/domain'
import type { Logger } from '@household/observability'
import {
@@ -113,6 +114,14 @@ export function createMiniAppDashboardHandler(options: {
netDueMajor: line.netDue.toMajorString(),
paidMajor: line.paid.toMajorString(),
remainingMajor: line.remaining.toMajorString(),
overduePayments: line.overduePayments.map((overdue) => ({
kind: overdue.kind,
amountMajor: Money.fromMinor(
overdue.amountMinor,
dashboard.currency
).toMajorString(),
periods: overdue.periods
})),
explanations: line.explanations
})),
ledger: dashboard.ledger.map((entry) => ({
@@ -132,6 +141,14 @@ export function createMiniAppDashboardHandler(options: {
...(entry.kind === 'purchase'
? {
purchaseSplitMode: entry.purchaseSplitMode ?? 'equal',
originPeriod: entry.originPeriod ?? null,
resolutionStatus: entry.resolutionStatus ?? 'unresolved',
resolvedAt: entry.resolvedAt ?? null,
outstandingByMember:
entry.outstandingByMember?.map((outstanding) => ({
memberId: outstanding.memberId,
amountMajor: outstanding.amount.toMajorString()
})) ?? [],
purchaseParticipants:
entry.purchaseParticipants?.map((participant) => ({
memberId: participant.memberId,

View File

@@ -168,6 +168,7 @@ function repository(): HouseholdConfigurationRepository {
isActive: input.isActive
}),
promoteHouseholdAdmin: async () => null,
demoteHouseholdAdmin: async () => null,
updateHouseholdMemberRentShareWeight: async () => null,
updateHouseholdMemberStatus: async () => null,
listHouseholdMemberAbsencePolicies: async () => [],

View File

@@ -198,6 +198,7 @@ function createFinanceService(): FinanceCommandService {
netDue: Money.fromMajor('500.50', 'GEL'),
paid: Money.zero('GEL'),
remaining: Money.fromMajor('500.50', 'GEL'),
overduePayments: [],
explanations: []
}
],

View File

@@ -62,6 +62,12 @@ export interface BotWebhookServerOptions {
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppDemoteMember?:
| {
path?: string
handler: (request: Request) => Promise<Response>
}
| undefined
miniAppUpdateOwnDisplayName?:
| {
path?: string
@@ -234,6 +240,8 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
options.miniAppUpsertUtilityCategory?.path ?? '/api/miniapp/admin/utility-categories/upsert'
const miniAppPromoteMemberPath =
options.miniAppPromoteMember?.path ?? '/api/miniapp/admin/members/promote'
const miniAppDemoteMemberPath =
options.miniAppDemoteMember?.path ?? '/api/miniapp/admin/members/demote'
const miniAppUpdateOwnDisplayNamePath =
options.miniAppUpdateOwnDisplayName?.path ?? '/api/miniapp/member/display-name'
const miniAppUpdateMemberDisplayNamePath =
@@ -328,6 +336,10 @@ export function createBotWebhookServer(options: BotWebhookServerOptions): {
return await options.miniAppPromoteMember.handler(request)
}
if (options.miniAppDemoteMember && url.pathname === miniAppDemoteMemberPath) {
return await options.miniAppDemoteMember.handler(request)
}
if (options.miniAppUpdateOwnDisplayName && url.pathname === miniAppUpdateOwnDisplayNamePath) {
return await options.miniAppUpdateOwnDisplayName.handler(request)
}

View File

@@ -20,10 +20,13 @@ export function AppShell(props: ParentProps) {
effectiveIsAdmin,
testingRolePreview,
setTestingRolePreview,
demoScenario,
setDemoScenario,
testingPeriodOverride,
setTestingPeriodOverride,
testingTodayOverride,
setTestingTodayOverride
setTestingTodayOverride,
applyDemoState
} = useDashboard()
const navigate = useNavigate()
@@ -38,6 +41,28 @@ export function AppShell(props: ParentProps) {
return labels[status]
}
function demoScenarioLabel(
id: 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities'
) {
const labels = {
'current-cycle': copy().testingScenarioCurrentCycle ?? '',
'overdue-utilities': copy().testingScenarioOverdueUtilities ?? '',
'overdue-rent-and-utilities': copy().testingScenarioOverdueBoth ?? ''
}
return labels[id]
}
function demoScenarioDescription(
id: 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities'
) {
const descriptions = {
'current-cycle': copy().testingScenarioCurrentCycleBody ?? '',
'overdue-utilities': copy().testingScenarioOverdueUtilitiesBody ?? '',
'overdue-rent-and-utilities': copy().testingScenarioOverdueBothBody ?? ''
}
return descriptions[id]
}
let tapCount = 0
let tapTimer: ReturnType<typeof setTimeout> | undefined
function handleRoleChipTap() {
@@ -92,6 +117,9 @@ export function AppShell(props: ParentProps) {
<Badge variant={readySession()?.mode === 'demo' ? 'accent' : 'default'}>
{readySession()?.mode === 'demo' ? copy().demoBadge : copy().liveBadge}
</Badge>
<Show when={readySession()?.mode === 'demo'}>
<Badge variant="muted">{demoScenarioLabel(demoScenario())}</Badge>
</Show>
<Show
when={readySession()?.member.isAdmin}
fallback={
@@ -168,11 +196,47 @@ export function AppShell(props: ParentProps) {
{copy().testingPreviewResidentAction ?? ''}
</Button>
</div>
<Show when={readySession()?.mode === 'demo'}>
<article class="testing-card__section testing-card__section--stack">
<span>{copy().testingScenarioLabel ?? ''}</span>
<div class="testing-card__section-content">
<strong>{demoScenarioLabel(demoScenario())}</strong>
<p class="testing-card__section-description">
{demoScenarioDescription(demoScenario())}
</p>
</div>
</article>
<div class="testing-card__actions testing-card__actions--wrap">
<Button
variant={demoScenario() === 'current-cycle' ? 'primary' : 'secondary'}
onClick={() => setDemoScenario('current-cycle')}
>
{copy().testingScenarioCurrentCycle ?? ''}
</Button>
<Button
variant={demoScenario() === 'overdue-utilities' ? 'primary' : 'secondary'}
onClick={() => setDemoScenario('overdue-utilities')}
>
{copy().testingScenarioOverdueUtilities ?? ''}
</Button>
<Button
variant={demoScenario() === 'overdue-rent-and-utilities' ? 'primary' : 'secondary'}
onClick={() => setDemoScenario('overdue-rent-and-utilities')}
>
{copy().testingScenarioOverdueBoth ?? ''}
</Button>
</div>
<div class="modal-action-row">
<Button variant="ghost" onClick={() => applyDemoState()}>
{copy().testingResetDemoStateAction ?? ''}
</Button>
</div>
</Show>
<article class="testing-card__section">
<span>{copy().testingPeriodCurrentLabel ?? ''}</span>
<strong>{dashboard()?.period ?? '—'}</strong>
</article>
<div class="testing-card__actions" style={{ 'flex-direction': 'column', gap: '12px' }}>
<div class="testing-card__actions testing-card__actions--stack">
<Field label={copy().testingPeriodOverrideLabel ?? ''} wide>
<Input
placeholder={copy().testingPeriodOverridePlaceholder ?? ''}

View File

@@ -21,12 +21,7 @@ import type {
MiniAppDashboard,
MiniAppPendingMember
} from '../miniapp-api'
import {
demoAdminSettings,
demoCycleState,
demoDashboard,
demoPendingMembers
} from '../demo/miniapp-demo'
import { getDemoScenarioState, type DemoScenarioId } from '../demo/miniapp-demo'
import { useSession } from './session-context'
import { useI18n } from './i18n-context'
@@ -106,6 +101,8 @@ type DashboardContextValue = {
memberUtilityBalanceVisuals: () => MemberBalanceItem[]
testingRolePreview: () => TestingRolePreview | null
setTestingRolePreview: (value: TestingRolePreview | null) => void
demoScenario: () => DemoScenarioId
setDemoScenario: (value: DemoScenarioId) => void
testingPeriodOverride: () => string | null
setTestingPeriodOverride: (value: string | null) => void
testingTodayOverride: () => string | null
@@ -297,6 +294,7 @@ export function DashboardProvider(props: ParentProps) {
const [cycleState, setCycleState] = createSignal<MiniAppAdminCycleState | null>(null)
const [pendingMembers, setPendingMembers] = createSignal<readonly MiniAppPendingMember[]>([])
const [testingRolePreview, setTestingRolePreview] = createSignal<TestingRolePreview | null>(null)
const [demoScenario, setDemoScenarioSignal] = createSignal<DemoScenarioId>('current-cycle')
const [testingPeriodOverride, setTestingPeriodOverride] = createSignal<string | null>(null)
const [testingTodayOverride, setTestingTodayOverride] = createSignal<string | null>(null)
@@ -393,10 +391,22 @@ export function DashboardProvider(props: ParentProps) {
}
function applyDemoState() {
setDashboard(demoDashboard)
setPendingMembers([...demoPendingMembers])
setAdminSettings(demoAdminSettings)
setCycleState(demoCycleState)
const state = getDemoScenarioState(demoScenario())
setDashboard(state.dashboard)
setPendingMembers(state.pendingMembers)
setAdminSettings(state.adminSettings)
setCycleState(state.cycleState)
}
function setDemoScenario(value: DemoScenarioId) {
setDemoScenarioSignal(value)
if (readySession()?.mode === 'demo') {
const state = getDemoScenarioState(value)
setDashboard(state.dashboard)
setPendingMembers(state.pendingMembers)
setAdminSettings(state.adminSettings)
setCycleState(state.cycleState)
}
}
return (
@@ -424,6 +434,8 @@ export function DashboardProvider(props: ParentProps) {
memberUtilityBalanceVisuals,
testingRolePreview,
setTestingRolePreview,
demoScenario,
setDemoScenario,
testingPeriodOverride,
setTestingPeriodOverride,
testingTodayOverride,

View File

@@ -6,6 +6,15 @@ import type {
MiniAppSession
} from '../miniapp-api'
export type DemoScenarioId = 'current-cycle' | 'overdue-utilities' | 'overdue-rent-and-utilities'
type DemoScenarioState = {
dashboard: MiniAppDashboard
pendingMembers: readonly MiniAppPendingMember[]
adminSettings: MiniAppAdminSettingsPayload
cycleState: MiniAppAdminCycleState
}
export const demoMember: NonNullable<MiniAppSession['member']> = {
id: 'demo-member',
householdId: 'demo-household',
@@ -23,178 +32,26 @@ export const demoTelegramUser: NonNullable<MiniAppSession['telegramUser']> = {
languageCode: 'en'
}
export const demoDashboard: MiniAppDashboard = {
period: '2026-03',
currency: 'GEL',
timezone: 'Asia/Tbilisi',
rentWarningDay: 17,
rentDueDay: 20,
utilitiesReminderDay: 3,
utilitiesDueDay: 4,
paymentBalanceAdjustmentPolicy: 'utilities',
rentPaymentDestinations: [
{
label: 'TBC card',
recipientName: 'Landlord',
bankName: 'TBC Bank',
account: '1234 5678 9012 3456',
note: null,
link: null
}
],
totalDueMajor: '2410.00',
totalPaidMajor: '650.00',
totalRemainingMajor: '1760.00',
rentSourceAmountMajor: '875.00',
rentSourceCurrency: 'USD',
rentDisplayAmountMajor: '2415.00',
rentFxRateMicros: '2760000',
rentFxEffectiveDate: '2026-03-17',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '78.00',
rentShareMajor: '603.75',
utilityShareMajor: '78.00',
purchaseOffsetMajor: '-66.00',
netDueMajor: '615.75',
paidMajor: '615.75',
remainingMajor: '0.00',
explanations: ['Weighted rent share', 'Custom purchase split credit']
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '78.00',
rentShareMajor: '603.75',
utilityShareMajor: '78.00',
purchaseOffsetMajor: '12.00',
netDueMajor: '693.75',
paidMajor: '0.00',
remainingMajor: '693.75',
explanations: ['Standard resident share']
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '54.00',
netDueMajor: '1261.50',
paidMajor: '34.25',
remainingMajor: '1227.25',
explanations: ['Away policy applied to utilities']
}
],
ledger: [
{
id: 'purchase-1',
kind: 'purchase',
title: 'Bought kitchen towels',
memberId: 'demo-member',
paymentKind: null,
amountMajor: '24.00',
currency: 'GEL',
displayAmountMajor: '24.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-04T11:00:00.000Z',
purchaseSplitMode: 'equal',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: null },
{ memberId: 'member-chorb', included: true, shareAmountMajor: null },
{ memberId: 'member-el', included: false, shareAmountMajor: null }
]
},
{
id: 'purchase-2',
kind: 'purchase',
title: 'Electric kettle',
memberId: 'member-chorb',
paymentKind: null,
amountMajor: '96.00',
currency: 'GEL',
displayAmountMajor: '96.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-03-08T16:20:00.000Z',
purchaseSplitMode: 'custom_amounts',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: '42.00' },
{ memberId: 'member-chorb', included: true, shareAmountMajor: '24.00' },
{ memberId: 'member-el', included: true, shareAmountMajor: '30.00' }
]
},
{
id: 'utility-1',
kind: 'utility',
title: 'Electricity',
memberId: null,
paymentKind: null,
amountMajor: '154.00',
currency: 'GEL',
displayAmountMajor: '154.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-09T12:00:00.000Z'
},
{
id: 'utility-2',
kind: 'utility',
title: 'Internet',
memberId: null,
paymentKind: null,
amountMajor: '80.00',
currency: 'GEL',
displayAmountMajor: '80.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-10T10:30:00.000Z'
},
{
id: 'payment-1',
kind: 'payment',
title: 'rent',
memberId: 'demo-member',
paymentKind: 'rent',
amountMajor: '615.75',
currency: 'GEL',
displayAmountMajor: '615.75',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-11T18:10:00.000Z'
},
{
id: 'payment-2',
kind: 'payment',
title: 'utilities',
memberId: 'member-el',
paymentKind: 'utilities',
amountMajor: '34.25',
currency: 'GEL',
displayAmountMajor: '34.25',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'El',
occurredAt: '2026-03-13T09:00:00.000Z'
}
]
}
const rentPaymentDestinations = [
{
label: 'Landlord TBC card',
recipientName: 'Nana Beridze',
bankName: 'TBC Bank',
account: '1234 5678 9012 3456',
note: 'Message: Kojori House rent',
link: null
},
{
label: 'USD fallback transfer',
recipientName: 'Nana Beridze',
bankName: 'Bank of Georgia',
account: 'GE29BG0000000123456789',
note: 'Use only if GEL transfer is unavailable',
link: 'https://bank.example/rent'
}
] as const
export const demoPendingMembers: readonly MiniAppPendingMember[] = [
const pendingMembers: readonly MiniAppPendingMember[] = [
{
telegramUserId: '555777',
displayName: 'Mia',
@@ -206,10 +63,16 @@ export const demoPendingMembers: readonly MiniAppPendingMember[] = [
displayName: 'Dima',
username: 'dima',
languageCode: 'en'
},
{
telegramUserId: '888111',
displayName: 'Nika',
username: 'nika_forest',
languageCode: 'en'
}
]
export const demoAdminSettings: MiniAppAdminSettingsPayload = {
const adminSettings: MiniAppAdminSettingsPayload = {
householdName: 'Kojori House',
settings: {
householdId: 'demo-household',
@@ -222,11 +85,12 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
utilitiesDueDay: 4,
utilitiesReminderDay: 3,
timezone: 'Asia/Tbilisi',
rentPaymentDestinations: demoDashboard.rentPaymentDestinations
rentPaymentDestinations
},
assistantConfig: {
householdId: 'demo-household',
assistantContext: 'The household is a house in Kojori with a backyard and pine forest nearby.',
assistantContext:
'The household is a large shared house in Kojori with a backyard, a guest room, and a long-running purchase ledger.',
assistantTone: 'Playful but concise'
},
topics: [
@@ -252,12 +116,20 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
sortOrder: 1,
isActive: true
},
{
id: 'cat-water',
householdId: 'demo-household',
slug: 'water',
name: 'Water',
sortOrder: 2,
isActive: true
},
{
id: 'cat-gas',
householdId: 'demo-household',
slug: 'gas',
name: 'Gas',
sortOrder: 2,
sortOrder: 3,
isActive: false
}
],
@@ -281,7 +153,7 @@ export const demoAdminSettings: MiniAppAdminSettingsPayload = {
]
}
export const demoCycleState: MiniAppAdminCycleState = {
const cycleState: MiniAppAdminCycleState = {
cycle: {
id: 'cycle-demo-2026-03',
period: '2026-03',
@@ -295,10 +167,10 @@ export const demoCycleState: MiniAppAdminCycleState = {
{
id: 'utility-bill-1',
billName: 'Electricity',
amountMinor: '15400',
amountMinor: '16400',
currency: 'GEL',
createdByMemberId: 'demo-member',
createdAt: '2026-03-09T12:00:00.000Z'
createdAt: '2026-03-02T09:15:00.000Z'
},
{
id: 'utility-bill-2',
@@ -306,7 +178,487 @@ export const demoCycleState: MiniAppAdminCycleState = {
amountMinor: '8000',
currency: 'GEL',
createdByMemberId: 'demo-member',
createdAt: '2026-03-10T10:30:00.000Z'
createdAt: '2026-03-03T10:30:00.000Z'
},
{
id: 'utility-bill-3',
billName: 'Water',
amountMinor: '4200',
currency: 'GEL',
createdByMemberId: 'member-chorb',
createdAt: '2026-03-03T12:45:00.000Z'
}
]
}
function baseLedger(): MiniAppDashboard['ledger'] {
return [
{
id: 'purchase-resolved-1',
kind: 'purchase',
title: 'Bulk cleaning supplies',
memberId: 'demo-member',
paymentKind: null,
amountMajor: '72.00',
currency: 'GEL',
displayAmountMajor: '72.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-01-28T18:30:00.000Z',
originPeriod: '2026-01',
resolutionStatus: 'resolved',
resolvedAt: '2026-02-04T09:10:00.000Z',
outstandingByMember: [],
payerMemberId: 'demo-member',
purchaseSplitMode: 'equal',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: null },
{ memberId: 'member-chorb', included: true, shareAmountMajor: null },
{ memberId: 'member-el', included: true, shareAmountMajor: null }
]
},
{
id: 'purchase-unresolved-1',
kind: 'purchase',
title: 'Gas heater refill',
memberId: 'member-chorb',
paymentKind: null,
amountMajor: '54.00',
currency: 'GEL',
displayAmountMajor: '54.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-02-17T20:15:00.000Z',
originPeriod: '2026-02',
resolutionStatus: 'unresolved',
resolvedAt: null,
outstandingByMember: [
{ memberId: 'demo-member', amountMajor: '18.00' },
{ memberId: 'member-el', amountMajor: '18.00' }
],
payerMemberId: 'member-chorb',
purchaseSplitMode: 'equal',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: null },
{ memberId: 'member-chorb', included: true, shareAmountMajor: null },
{ memberId: 'member-el', included: true, shareAmountMajor: null }
]
},
{
id: 'purchase-unresolved-2',
kind: 'purchase',
title: 'Water filter cartridges',
memberId: 'demo-member',
paymentKind: null,
amountMajor: '96.00',
currency: 'GEL',
displayAmountMajor: '96.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-03T19:00:00.000Z',
originPeriod: '2026-03',
resolutionStatus: 'unresolved',
resolvedAt: null,
outstandingByMember: [
{ memberId: 'member-chorb', amountMajor: '24.00' },
{ memberId: 'member-el', amountMajor: '34.00' }
],
payerMemberId: 'demo-member',
purchaseSplitMode: 'custom_amounts',
purchaseParticipants: [
{ memberId: 'demo-member', included: true, shareAmountMajor: '38.00' },
{ memberId: 'member-chorb', included: true, shareAmountMajor: '24.00' },
{ memberId: 'member-el', included: true, shareAmountMajor: '34.00' }
]
},
{
id: 'utility-1',
kind: 'utility',
title: 'Electricity',
memberId: null,
paymentKind: null,
amountMajor: '164.00',
currency: 'GEL',
displayAmountMajor: '164.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-02T09:15:00.000Z'
},
{
id: 'utility-2',
kind: 'utility',
title: 'Internet',
memberId: null,
paymentKind: null,
amountMajor: '80.00',
currency: 'GEL',
displayAmountMajor: '80.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-03T10:30:00.000Z'
},
{
id: 'utility-3',
kind: 'utility',
title: 'Water',
memberId: null,
paymentKind: null,
amountMajor: '42.00',
currency: 'GEL',
displayAmountMajor: '42.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-03-03T12:45:00.000Z'
},
{
id: 'payment-rent-demo',
kind: 'payment',
title: 'rent',
memberId: 'demo-member',
paymentKind: 'rent',
amountMajor: '603.75',
currency: 'GEL',
displayAmountMajor: '603.75',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-18T18:10:00.000Z'
},
{
id: 'payment-utilities-demo',
kind: 'payment',
title: 'utilities',
memberId: 'demo-member',
paymentKind: 'utilities',
amountMajor: '58.00',
currency: 'GEL',
displayAmountMajor: '58.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Stas',
occurredAt: '2026-03-04T20:00:00.000Z'
},
{
id: 'payment-rent-el',
kind: 'payment',
title: 'rent',
memberId: 'member-el',
paymentKind: 'rent',
amountMajor: '377.00',
currency: 'GEL',
displayAmountMajor: '377.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'El',
occurredAt: '2026-03-21T08:20:00.000Z'
}
]
}
function createDashboard(state: {
totalDueMajor: string
totalPaidMajor: string
totalRemainingMajor: string
members: MiniAppDashboard['members']
ledger?: MiniAppDashboard['ledger']
}): MiniAppDashboard {
return {
period: '2026-03',
currency: 'GEL',
timezone: 'Asia/Tbilisi',
rentWarningDay: 17,
rentDueDay: 20,
utilitiesReminderDay: 3,
utilitiesDueDay: 4,
paymentBalanceAdjustmentPolicy: 'utilities',
rentPaymentDestinations,
totalDueMajor: state.totalDueMajor,
totalPaidMajor: state.totalPaidMajor,
totalRemainingMajor: state.totalRemainingMajor,
rentSourceAmountMajor: '875.00',
rentSourceCurrency: 'USD',
rentDisplayAmountMajor: '2415.00',
rentFxRateMicros: '2760000',
rentFxEffectiveDate: '2026-03-17',
members: state.members,
ledger: state.ledger ?? baseLedger()
}
}
const demoScenarioCatalog: Record<DemoScenarioId, DemoScenarioState> = {
'current-cycle': {
dashboard: createDashboard({
totalDueMajor: '2571.00',
totalPaidMajor: '1288.75',
totalRemainingMajor: '1282.25',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '95.33',
rentShareMajor: '603.75',
utilityShareMajor: '95.33',
purchaseOffsetMajor: '-37.33',
netDueMajor: '661.75',
paidMajor: '661.75',
remainingMajor: '0.00',
overduePayments: [],
explanations: [
'Weighted rent share',
'Utilities reflect three posted bills',
'Purchase credit from January supplies and March water filters'
]
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '95.33',
rentShareMajor: '603.75',
utilityShareMajor: '95.33',
purchaseOffsetMajor: '44.67',
netDueMajor: '743.75',
paidMajor: '250.00',
remainingMajor: '493.75',
overduePayments: [],
explanations: [
'Standard resident share',
'Still owes current-cycle utilities and purchases'
]
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '-42.00',
netDueMajor: '1165.50',
paidMajor: '377.00',
remainingMajor: '788.50',
overduePayments: [],
explanations: ['Away policy applied to utilities', 'Purchase credit offsets part of rent']
}
]
}),
pendingMembers,
adminSettings,
cycleState
},
'overdue-utilities': {
dashboard: createDashboard({
totalDueMajor: '2623.00',
totalPaidMajor: '783.75',
totalRemainingMajor: '1839.25',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '104.00',
rentShareMajor: '603.75',
utilityShareMajor: '104.00',
purchaseOffsetMajor: '18.00',
netDueMajor: '725.75',
paidMajor: '603.75',
remainingMajor: '122.00',
overduePayments: [
{
kind: 'utilities',
amountMajor: '182.00',
periods: ['2026-01', '2026-02']
}
],
explanations: [
'Current rent is paid',
'Utilities remain overdue from two prior periods',
'Purchase carry-over stays separate from overdue closure'
]
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '104.00',
rentShareMajor: '603.75',
utilityShareMajor: '104.00',
purchaseOffsetMajor: '12.00',
netDueMajor: '719.75',
paidMajor: '180.00',
remainingMajor: '539.75',
overduePayments: [
{
kind: 'utilities',
amountMajor: '91.00',
periods: ['2026-02']
}
],
explanations: ['Partial utilities payment recorded this month']
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '-30.00',
netDueMajor: '1177.50',
paidMajor: '0.00',
remainingMajor: '1177.50',
overduePayments: [],
explanations: [
'Away policy applied to utilities',
'No overdue utility base because away policy removed the share'
]
}
],
ledger: [
...baseLedger(),
{
id: 'payment-overdue-utilities-jan',
kind: 'payment',
title: 'utilities',
memberId: 'member-chorb',
paymentKind: 'utilities',
amountMajor: '52.00',
currency: 'GEL',
displayAmountMajor: '52.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'Chorbanaut',
occurredAt: '2026-02-07T21:10:00.000Z'
}
]
}),
pendingMembers,
adminSettings,
cycleState
},
'overdue-rent-and-utilities': {
dashboard: createDashboard({
totalDueMajor: '2629.00',
totalPaidMajor: '200.00',
totalRemainingMajor: '2429.00',
members: [
{
memberId: 'demo-member',
displayName: 'Stas',
predictedUtilityShareMajor: '88.00',
rentShareMajor: '603.75',
utilityShareMajor: '88.00',
purchaseOffsetMajor: '14.00',
netDueMajor: '705.75',
paidMajor: '0.00',
remainingMajor: '705.75',
overduePayments: [
{
kind: 'rent',
amountMajor: '603.75',
periods: ['2026-02']
},
{
kind: 'utilities',
amountMajor: '166.00',
periods: ['2026-01', '2026-02']
}
],
explanations: [
'Both rent and utilities are overdue',
'Current-cycle purchases remain visible but do not keep overdue open'
]
},
{
memberId: 'member-chorb',
displayName: 'Chorbanaut',
predictedUtilityShareMajor: '88.00',
rentShareMajor: '603.75',
utilityShareMajor: '88.00',
purchaseOffsetMajor: '36.00',
netDueMajor: '727.75',
paidMajor: '0.00',
remainingMajor: '727.75',
overduePayments: [
{
kind: 'rent',
amountMajor: '603.75',
periods: ['2026-02']
},
{
kind: 'utilities',
amountMajor: '88.00',
periods: ['2026-02']
}
],
explanations: ['No backfilled payments have been entered yet']
},
{
memberId: 'member-el',
displayName: 'El',
predictedUtilityShareMajor: '0.00',
rentShareMajor: '1207.50',
utilityShareMajor: '0.00',
purchaseOffsetMajor: '-12.00',
netDueMajor: '1195.50',
paidMajor: '200.00',
remainingMajor: '995.50',
overduePayments: [
{
kind: 'rent',
amountMajor: '1207.50',
periods: ['2026-02']
}
],
explanations: [
'Away policy still charges rent',
'One partial rent payment was entered late'
]
}
],
ledger: [
...baseLedger(),
{
id: 'payment-overdue-rent-el',
kind: 'payment',
title: 'rent',
memberId: 'member-el',
paymentKind: 'rent',
amountMajor: '200.00',
currency: 'GEL',
displayAmountMajor: '200.00',
displayCurrency: 'GEL',
fxRateMicros: null,
fxEffectiveDate: null,
actorDisplayName: 'El',
occurredAt: '2026-02-23T14:40:00.000Z'
}
]
}),
pendingMembers,
adminSettings,
cycleState
}
}
export function getDemoScenarioState(id: DemoScenarioId): DemoScenarioState {
return structuredClone(demoScenarioCatalog[id])
}
const defaultScenarioState = getDemoScenarioState('current-cycle')
export const demoDashboard = defaultScenarioState.dashboard
export const demoPendingMembers = defaultScenarioState.pendingMembers
export const demoAdminSettings = defaultScenarioState.adminSettings
export const demoCycleState = defaultScenarioState.cycleState

View File

@@ -67,6 +67,9 @@ export const dictionary = {
homeUtilitiesTitle: 'Utilities payment',
homeRentTitle: 'Rent payment',
homeNoPaymentTitle: 'No payment period',
homeOverdueRentTitle: 'Overdue rent',
homeOverdueUtilitiesTitle: 'Overdue utilities',
homeOverduePeriodsLabel: 'Overdue periods: {periods}',
homeUtilitiesUpcomingLabel: 'Utilities starts {date}',
homeRentUpcomingLabel: 'Rent starts {date}',
homeFillUtilitiesTitle: 'Fill utilities',
@@ -139,7 +142,7 @@ export const dictionary = {
shareRent: 'Rent',
shareUtilities: 'Utilities',
shareOffset: 'Shared buys',
rentFxTitle: 'House rent FX',
rentFxTitle: 'Rent exchange rate',
sourceAmountLabel: 'Source',
settlementAmountLabel: 'Settlement',
fxEffectiveDateLabel: 'Locked',
@@ -158,6 +161,17 @@ export const dictionary = {
testingPreviewResidentAction: 'Preview resident',
testingCurrentRoleLabel: 'Real access',
testingPreviewRoleLabel: 'Previewing',
testingScenarioLabel: 'Demo scenario',
testingScenarioCurrentCycle: 'Current cycle',
testingScenarioCurrentCycleBody:
'Balanced current-period data with resolved and unresolved purchases, current utility bills, and partial payments from other members.',
testingScenarioOverdueUtilities: 'Overdue utilities',
testingScenarioOverdueUtilitiesBody:
'Shows utility overdue cards, current-cycle utility debt, and purchase carry-over that should survive after overdue closes.',
testingScenarioOverdueBoth: 'Overdue rent + utilities',
testingScenarioOverdueBothBody:
'Shows both overdue cards at once so you can test oldest-first payment routing and admin backfill flows.',
testingResetDemoStateAction: 'Reset demo state',
testingPeriodCurrentLabel: 'Dashboard period',
testingPeriodOverrideLabel: 'Period override',
testingPeriodOverridePlaceholder: 'YYYY-MM',
@@ -184,6 +198,8 @@ export const dictionary = {
copiedToast: 'Copied!',
quickPaymentTitle: 'Record payment',
quickPaymentBody: 'Quickly record a {type} payment for the current cycle.',
quickPaymentCurrentBody: 'Quickly record a {type} payment for the current cycle.',
quickPaymentOverdueBody: 'Quickly record a {type} payment for overdue periods.',
quickPaymentAmountLabel: 'Amount',
quickPaymentCurrencyLabel: 'Currency',
quickPaymentSubmitAction: 'Save payment',
@@ -202,6 +218,10 @@ export const dictionary = {
purchaseSaveAction: 'Save purchase',
purchaseBalanceAction: 'Balance',
purchaseRebalanceAction: 'Rebalance',
unresolvedPurchasesTitle: 'Outstanding purchases',
resolvedPurchasesTitle: 'Settled purchases',
unresolvedPurchasesEmpty: 'No unresolved purchases.',
resolvedPurchasesEmpty: 'No resolved purchases yet.',
purchaseDeleteAction: 'Delete',
deletingPurchase: 'Deleting purchase…',
savingPurchase: 'Saving purchase…',
@@ -320,6 +340,9 @@ export const dictionary = {
saveDisplayName: 'Save name',
savingDisplayName: 'Saving name…',
memberStatusLabel: 'Member status',
memberRoleLabel: 'Role',
memberRoleResident: 'Resident',
memberRoleAdmin: 'Admin',
saveMemberStatusAction: 'Save status',
savingMemberStatus: 'Saving status…',
memberStatusActive: 'Active',
@@ -341,6 +364,8 @@ export const dictionary = {
promoteAdminAction: 'Promote to admin',
promoteAdminLabel: 'Admin access',
promotingAdmin: 'Promoting…',
demoteAdminAction: 'Remove admin access',
demotingAdmin: 'Removing…',
residentHouseTitle: 'Household access',
residentHouseBody:
'Your admins manage household settings and approvals here. You can still switch your own language above.',
@@ -422,6 +447,9 @@ export const dictionary = {
homeUtilitiesTitle: 'Оплата коммуналки',
homeRentTitle: 'Оплата аренды',
homeNoPaymentTitle: 'Период без оплаты',
homeOverdueRentTitle: 'Просроченная аренда',
homeOverdueUtilitiesTitle: 'Просроченная коммуналка',
homeOverduePeriodsLabel: 'Просроченные периоды: {periods}',
homeUtilitiesUpcomingLabel: 'Коммуналка с {date}',
homeRentUpcomingLabel: 'Аренда с {date}',
homeFillUtilitiesTitle: 'Внести коммуналку',
@@ -494,7 +522,7 @@ export const dictionary = {
shareRent: 'Аренда',
shareUtilities: 'Коммуналка',
shareOffset: 'Общие покупки',
rentFxTitle: 'FX по аренде дома',
rentFxTitle: 'Курс для аренды',
sourceAmountLabel: 'Исходник',
settlementAmountLabel: 'Расчёт',
fxEffectiveDateLabel: 'Зафиксировано',
@@ -513,6 +541,17 @@ export const dictionary = {
testingPreviewResidentAction: 'Вид жителя',
testingCurrentRoleLabel: 'Реальный доступ',
testingPreviewRoleLabel: 'Сейчас показан',
testingScenarioLabel: 'Демо-сценарий',
testingScenarioCurrentCycle: 'Текущий цикл',
testingScenarioCurrentCycleBody:
'Сбалансированный текущий период: есть закрытые и незакрытые покупки, актуальные коммунальные счета и частичные оплаты от других участников.',
testingScenarioOverdueUtilities: 'Просроченная коммуналка',
testingScenarioOverdueUtilitiesBody:
'Показывает карточку просроченной коммуналки, долг текущего цикла и перенос покупок, который должен остаться после закрытия просрочки.',
testingScenarioOverdueBoth: 'Просрочены аренда и коммуналка',
testingScenarioOverdueBothBody:
'Показывает обе просроченные карточки сразу, чтобы можно было проверить oldest-first распределение оплат и админский ввод задним числом.',
testingResetDemoStateAction: 'Сбросить демо-данные',
testingPeriodCurrentLabel: 'Период (из API)',
testingPeriodOverrideLabel: 'Переопределить период',
testingPeriodOverridePlaceholder: 'YYYY-MM',
@@ -541,6 +580,8 @@ export const dictionary = {
copiedToast: 'Скопировано!',
quickPaymentTitle: 'Записать оплату',
quickPaymentBody: 'Быстро запиши оплату {type} за текущий цикл.',
quickPaymentCurrentBody: 'Быстро запиши оплату {type} за текущий цикл.',
quickPaymentOverdueBody: 'Быстро запиши оплату {type} за просроченные периоды.',
quickPaymentAmountLabel: 'Сумма',
quickPaymentCurrencyLabel: 'Валюта',
quickPaymentSubmitAction: 'Сохранить оплату',
@@ -559,6 +600,10 @@ export const dictionary = {
purchaseSaveAction: 'Сохранить покупку',
purchaseBalanceAction: 'Сбалансировать',
purchaseRebalanceAction: 'Перераспределить',
unresolvedPurchasesTitle: 'Незакрытые покупки',
resolvedPurchasesTitle: 'Закрытые покупки',
unresolvedPurchasesEmpty: 'Незакрытых покупок нет.',
resolvedPurchasesEmpty: 'Закрытых покупок пока нет.',
purchaseDeleteAction: 'Удалить',
deletingPurchase: 'Удаляем покупку…',
savingPurchase: 'Сохраняем покупку…',
@@ -678,6 +723,9 @@ export const dictionary = {
saveDisplayName: 'Сохранить имя',
savingDisplayName: 'Сохраняем имя…',
memberStatusLabel: 'Статус участника',
memberRoleLabel: 'Роль',
memberRoleResident: 'Житель',
memberRoleAdmin: 'Админ',
saveMemberStatusAction: 'Сохранить статус',
savingMemberStatus: 'Сохраняем статус…',
memberStatusActive: 'Активный',
@@ -699,6 +747,8 @@ export const dictionary = {
promoteAdminAction: 'Сделать админом',
promoteAdminLabel: 'Доступ админа',
promotingAdmin: 'Повышаем…',
demoteAdminAction: 'Убрать доступ админа',
demotingAdmin: 'Убираем…',
residentHouseTitle: 'Доступ к дому',
residentHouseBody:
'Настройками дома и подтверждением заявок управляют админы. Свой язык можно менять переключателем выше.',

View File

@@ -58,6 +58,13 @@ a {
border-width: 0;
}
.ui-icon {
width: 18px;
height: 18px;
display: block;
flex-shrink: 0;
}
.empty-state {
color: var(--text-muted);
font-size: var(--text-sm);
@@ -822,9 +829,9 @@ a {
}
.modal-sheet {
width: 100%;
width: min(100%, 480px);
max-width: 480px;
max-height: 85dvh;
max-height: min(92dvh, 900px);
background: var(--bg-surface);
border: 1px solid var(--border);
border-radius: var(--radius-xl) var(--radius-xl) 0 0;
@@ -848,6 +855,7 @@ a {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: var(--spacing-md);
padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-md);
position: sticky;
top: 0;
@@ -869,6 +877,17 @@ a {
.modal-close-button {
flex-shrink: 0;
width: 40px;
min-width: 40px;
min-height: 40px;
color: var(--text-primary);
background: var(--bg-root);
border-color: var(--border);
}
.modal-close-button:hover:not(:disabled) {
background: var(--bg-elevated);
border-color: var(--border-hover);
}
.modal-sheet__body {
@@ -887,6 +906,7 @@ a {
display: flex;
gap: var(--spacing-sm);
justify-content: flex-end;
flex-wrap: wrap;
}
.modal-action-row--single {
@@ -1677,6 +1697,8 @@ a {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--spacing-sm);
flex-wrap: wrap;
padding: var(--spacing-sm);
background: var(--bg-root);
border-radius: var(--radius-sm);
@@ -1687,11 +1709,67 @@ a {
color: var(--text-muted);
}
.testing-card__section strong {
margin-left: auto;
text-align: right;
}
.testing-card__section--stack {
align-items: flex-start;
}
.testing-card__section--stack strong {
margin-left: 0;
text-align: left;
}
.testing-card__section-content {
flex: 1 1 220px;
min-width: 0;
}
.testing-card__section-description {
margin-top: 4px;
color: var(--text-secondary);
line-height: 1.45;
}
.testing-card__actions {
display: flex;
gap: var(--spacing-sm);
}
.testing-card__actions--wrap {
flex-wrap: wrap;
}
.testing-card__actions--wrap .ui-button {
flex: 1 1 160px;
}
.testing-card__actions--stack {
flex-direction: column;
gap: 12px;
}
@media (max-width: 480px) {
.modal-sheet {
width: calc(100% - 16px);
}
.modal-sheet__header,
.modal-sheet__body,
.modal-sheet__footer {
padding-left: var(--spacing-lg);
padding-right: var(--spacing-lg);
}
.testing-card__actions .ui-button,
.modal-action-row .ui-button {
flex: 1 1 100%;
}
}
/* ── Balance Item (legacy compat) ─────────────────────── */
.balance-item {

View File

@@ -44,6 +44,7 @@ export type PaymentDraft = {
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
period: string
}
/* ── Pure helpers ───────────────────────────────────── */
@@ -170,7 +171,8 @@ export function paymentDrafts(
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
currency: entry.currency,
period: ''
}
])
)
@@ -181,7 +183,8 @@ export function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]):
memberId: entry.memberId ?? '',
kind: entry.paymentKind ?? 'rent',
amountMajor: entry.amountMajor,
currency: entry.currency
currency: entry.currency,
period: ''
}
}

View File

@@ -130,6 +130,11 @@ export interface MiniAppDashboard {
netDueMajor: string
paidMajor: string
remainingMajor: string
overduePayments: readonly {
kind: 'rent' | 'utilities'
amountMajor: string
periods: readonly string[]
}[]
explanations: readonly string[]
}[]
ledger: {
@@ -147,6 +152,13 @@ export interface MiniAppDashboard {
actorDisplayName: string | null
occurredAt: string | null
purchaseSplitMode?: 'equal' | 'custom_amounts'
originPeriod?: string | null
resolutionStatus?: 'unresolved' | 'resolved'
resolvedAt?: string | null
outstandingByMember?: readonly {
memberId: string
amountMajor: string
}[]
purchaseParticipants?: readonly {
memberId: string
included: boolean
@@ -711,6 +723,35 @@ export async function updateMiniAppMemberStatus(
return payload.member
}
export async function demoteMiniAppMember(
initData: string,
memberId: string
): Promise<MiniAppMember> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/demote`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
memberId
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
member?: MiniAppMember
error?: string
}
if (!response.ok || !payload.member) {
throw new Error(payload.error ?? 'Failed to remove admin access')
}
return payload.member
}
export async function updateMiniAppMemberAbsencePolicy(
initData: string,
memberId: string,
@@ -1085,6 +1126,7 @@ export async function addMiniAppPayment(
kind: 'rent' | 'utilities'
amountMajor: string
currency: 'USD' | 'GEL'
period?: string
}
): Promise<void> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/payments/add`, {

View File

@@ -92,6 +92,9 @@ export default function HomeRoute() {
const [copiedValue, setCopiedValue] = createSignal<string | null>(null)
const [quickPaymentOpen, setQuickPaymentOpen] = createSignal(false)
const [quickPaymentType, setQuickPaymentType] = createSignal<'rent' | 'utilities'>('rent')
const [quickPaymentContext, setQuickPaymentContext] = createSignal<'current' | 'overdue'>(
'current'
)
const [quickPaymentAmount, setQuickPaymentAmount] = createSignal('')
const [submittingPayment, setSubmittingPayment] = createSignal(false)
const [toastState, setToastState] = createSignal<{
@@ -203,10 +206,10 @@ export default function HomeRoute() {
return override
})
const homeMode = createMemo(() => {
const currentPaymentModes = createMemo(() => {
const data = dashboard()
const member = currentMemberLine()
if (!data || !member) return 'none' as const
if (!data || !member) return [] as ('rent' | 'utilities')[]
const period = effectivePeriod() ?? data.period
const today = todayOverride()
@@ -229,17 +232,21 @@ export default function HomeRoute() {
const utilitiesActive = utilities.active && utilitiesDueMinor > 0n
const rentActive = rent.active && rentDueMinor > 0n
if (utilitiesActive && rentActive) {
const utilitiesDays = utilities.daysUntilDue ?? Number.POSITIVE_INFINITY
const rentDays = rent.daysUntilDue ?? Number.POSITIVE_INFINITY
return utilitiesDays <= rentDays ? ('utilities' as const) : ('rent' as const)
const modes: ('rent' | 'utilities')[] = []
if (utilitiesActive) {
modes.push('utilities')
}
if (rentActive) {
modes.push('rent')
}
if (utilitiesActive) return 'utilities' as const
if (rentActive) return 'rent' as const
return 'none' as const
return modes
})
function overduePaymentFor(kind: 'rent' | 'utilities') {
return currentMemberLine()?.overduePayments.find((payment) => payment.kind === kind) ?? null
}
async function handleSubmitUtilities() {
const data = initData()
const current = dashboard()
@@ -265,14 +272,21 @@ export default function HomeRoute() {
}
}
function openQuickPayment(type: 'rent' | 'utilities') {
function openQuickPayment(
type: 'rent' | 'utilities',
context: 'current' | 'overdue' = 'current'
) {
const data = dashboard()
if (!data || !currentMemberLine()) return
const member = currentMemberLine()!
const amount = minorToMajorString(paymentRemainingMinor(data, member, type))
const amount =
context === 'overdue'
? (overduePaymentFor(type)?.amountMajor ?? '0.00')
: minorToMajorString(paymentRemainingMinor(data, member, type))
setQuickPaymentType(type)
setQuickPaymentContext(context)
setQuickPaymentAmount(amount)
setQuickPaymentOpen(true)
}
@@ -365,7 +379,7 @@ export default function HomeRoute() {
const utilitiesRemainingMinor = () =>
paymentRemainingMinor(data(), member(), 'utilities')
const mode = () => homeMode()
const modes = () => currentPaymentModes()
const currency = () => data().currency
const timezone = () => data().timezone
const period = () => effectivePeriod() ?? data().period
@@ -431,7 +445,93 @@ export default function HomeRoute() {
return (
<>
<Show when={mode() === 'utilities'}>
<Show when={overduePaymentFor('utilities')}>
{(overdue) => (
<Card accent>
<div class="balance-card">
<div class="balance-card__header">
<span class="balance-card__label">
{copy().homeOverdueUtilitiesTitle}
</span>
<div
style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}
>
<Badge variant="danger">{copy().overdueLabel}</Badge>
<Button
variant="primary"
size="sm"
onClick={() => openQuickPayment('utilities', 'overdue')}
>
<CreditCard size={14} />
{copy().quickPaymentSubmitAction}
</Button>
</div>
</div>
<div class="balance-card__amounts">
<div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span>
<strong>
{overdue().amountMajor} {currency()}
</strong>
</div>
<div class="balance-card__row">
<span>
{copy().homeOverduePeriodsLabel.replace(
'{periods}',
overdue().periods.join(', ')
)}
</span>
</div>
</div>
</div>
</Card>
)}
</Show>
<Show when={overduePaymentFor('rent')}>
{(overdue) => (
<Card accent>
<div class="balance-card">
<div class="balance-card__header">
<span class="balance-card__label">
{copy().homeOverdueRentTitle}
</span>
<div
style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}
>
<Badge variant="danger">{copy().overdueLabel}</Badge>
<Button
variant="primary"
size="sm"
onClick={() => openQuickPayment('rent', 'overdue')}
>
<CreditCard size={14} />
{copy().quickPaymentSubmitAction}
</Button>
</div>
</div>
<div class="balance-card__amounts">
<div class="balance-card__row balance-card__row--subtotal">
<span>{copy().finalDue}</span>
<strong>
{overdue().amountMajor} {currency()}
</strong>
</div>
<div class="balance-card__row">
<span>
{copy().homeOverduePeriodsLabel.replace(
'{periods}',
overdue().periods.join(', ')
)}
</span>
</div>
</div>
</div>
</Card>
)}
</Show>
<Show when={modes().includes('utilities')}>
<Card accent>
<div class="balance-card">
<div class="balance-card__header">
@@ -441,7 +541,7 @@ export default function HomeRoute() {
<Button
variant="primary"
size="sm"
onClick={() => openQuickPayment('utilities')}
onClick={() => openQuickPayment('utilities', 'current')}
>
<CreditCard size={14} />
{copy().quickPaymentSubmitAction}
@@ -496,7 +596,7 @@ export default function HomeRoute() {
</Card>
</Show>
<Show when={mode() === 'rent'}>
<Show when={modes().includes('rent')}>
<Card accent>
<div class="balance-card">
<div class="balance-card__header">
@@ -506,7 +606,7 @@ export default function HomeRoute() {
<Button
variant="primary"
size="sm"
onClick={() => openQuickPayment('rent')}
onClick={() => openQuickPayment('rent', 'current')}
>
<CreditCard size={14} />
{copy().quickPaymentSubmitAction}
@@ -543,7 +643,13 @@ export default function HomeRoute() {
</Card>
</Show>
<Show when={mode() === 'none'}>
<Show
when={
modes().length === 0 &&
!overduePaymentFor('utilities') &&
!overduePaymentFor('rent')
}
>
<Card muted>
<div class="balance-card">
<div class="balance-card__header">
@@ -587,7 +693,7 @@ export default function HomeRoute() {
</Card>
</Show>
<Show when={mode() === 'utilities' && utilityLedger().length === 0}>
<Show when={modes().includes('utilities') && utilityLedger().length === 0}>
<Card>
<div class="balance-card">
<div class="balance-card__header">
@@ -643,7 +749,9 @@ export default function HomeRoute() {
</Card>
</Show>
<Show when={mode() === 'rent' && data().rentPaymentDestinations?.length}>
<Show
when={modes().includes('rent') && data().rentPaymentDestinations?.length}
>
<div style={{ display: 'grid', gap: '12px' }}>
<For each={data().rentPaymentDestinations ?? []}>
{(destination) => (
@@ -852,7 +960,10 @@ export default function HomeRoute() {
<Modal
open={quickPaymentOpen()}
title={copy().quickPaymentTitle}
description={copy().quickPaymentBody.replace(
description={(quickPaymentContext() === 'overdue'
? copy().quickPaymentOverdueBody
: copy().quickPaymentCurrentBody
).replace(
'{type}',
quickPaymentType() === 'rent' ? copy().shareRent : copy().shareUtilities
)}

View File

@@ -206,6 +206,34 @@ export default function LedgerRoute() {
const { copy } = useI18n()
const { dashboard, loading, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
useDashboard()
const unresolvedPurchaseLedger = createMemo(() =>
purchaseLedger().filter((entry) => entry.resolutionStatus !== 'resolved')
)
const resolvedPurchaseLedger = createMemo(() =>
purchaseLedger().filter((entry) => entry.resolutionStatus === 'resolved')
)
const paymentPeriodOptions = createMemo(() => {
const periods = new Set<string>()
if (dashboard()?.period) {
periods.add(dashboard()!.period)
}
for (const entry of purchaseLedger()) {
if (entry.originPeriod) {
periods.add(entry.originPeriod)
}
}
for (const member of dashboard()?.members ?? []) {
for (const overdue of member.overduePayments) {
for (const period of overdue.periods) {
periods.add(period)
}
}
}
return [...periods].sort().map((period) => ({ value: period, label: period }))
})
// ── Purchase editor ──────────────────────────────
const [editingPurchase, setEditingPurchase] = createSignal<
@@ -262,7 +290,8 @@ export default function LedgerRoute() {
memberId: '',
kind: 'rent',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
period: dashboard()?.period ?? ''
})
const [addingPayment, setAddingPayment] = createSignal(false)
@@ -518,14 +547,16 @@ export default function LedgerRoute() {
memberId: draft.memberId,
kind: draft.kind,
amountMajor: draft.amountMajor,
currency: draft.currency
currency: draft.currency,
...(draft.period ? { period: draft.period } : {})
})
setAddPaymentOpen(false)
setNewPayment({
memberId: '',
kind: 'rent',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
period: dashboard()?.period ?? ''
})
await refreshHouseholdData(true, true)
} finally {
@@ -615,31 +646,84 @@ export default function LedgerRoute() {
when={purchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().purchasesEmpty}</p>}
>
<div class="editable-list">
<For each={purchaseLedger()}>
{(entry) => (
<button
class="editable-list-row"
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="editable-list-row__main">
<span class="editable-list-row__title">{entry.title}</span>
<span class="editable-list-row__subtitle">
{entry.actorDisplayName}
</span>
</div>
<div class="editable-list-row__meta">
<strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="editable-list-row__secondary">{secondary()}</span>
)}
</Show>
</div>
</button>
)}
</For>
<div style={{ display: 'flex', 'flex-direction': 'column', gap: '12px' }}>
<div>
<strong>{copy().unresolvedPurchasesTitle}</strong>
<Show
when={unresolvedPurchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().unresolvedPurchasesEmpty}</p>}
>
<div class="editable-list">
<For each={unresolvedPurchaseLedger()}>
{(entry) => (
<button
class="editable-list-row"
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="editable-list-row__main">
<span class="editable-list-row__title">{entry.title}</span>
<span class="editable-list-row__subtitle">
{[entry.actorDisplayName, entry.originPeriod, 'Unresolved']
.filter(Boolean)
.join(' · ')}
</span>
</div>
<div class="editable-list-row__meta">
<strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="editable-list-row__secondary">
{secondary()}
</span>
)}
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
<div>
<strong>{copy().resolvedPurchasesTitle}</strong>
<Show
when={resolvedPurchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().resolvedPurchasesEmpty}</p>}
>
<div class="editable-list">
<For each={resolvedPurchaseLedger()}>
{(entry) => (
<button
class="editable-list-row"
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="editable-list-row__main">
<span class="editable-list-row__title">{entry.title}</span>
<span class="editable-list-row__subtitle">
{[entry.actorDisplayName, entry.originPeriod, entry.resolvedAt]
.filter(Boolean)
.join(' · ')}
</span>
</div>
<div class="editable-list-row__meta">
<strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="editable-list-row__secondary">
{secondary()}
</span>
)}
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</div>
</div>
</Show>
</Collapsible>
@@ -691,7 +775,17 @@ export default function LedgerRoute() {
>
<Show when={effectiveIsAdmin()}>
<div class="editable-list-actions">
<Button variant="primary" size="sm" onClick={() => setAddPaymentOpen(true)}>
<Button
variant="primary"
size="sm"
onClick={() => {
setNewPayment((payment) => ({
...payment,
period: dashboard()?.period ?? ''
}))
setAddPaymentOpen(true)
}}
>
<Plus size={14} />
{copy().paymentsAddAction}
</Button>
@@ -1023,6 +1117,15 @@ export default function LedgerRoute() {
}
/>
</Field>
<Field label="Billing period">
<Select
value={newPayment().period ?? ''}
placeholder="—"
ariaLabel="Billing period"
options={[{ value: '', label: '—' }, ...paymentPeriodOptions()]}
onChange={(value) => setNewPayment((p) => ({ ...p, period: value }))}
/>
</Field>
<Field label={copy().paymentAmount}>
<Input
type="number"

View File

@@ -18,6 +18,7 @@ import {
updateMiniAppMemberDisplayName,
updateMiniAppMemberRentWeight,
updateMiniAppMemberStatus,
demoteMiniAppMember,
promoteMiniAppMember,
approveMiniAppPendingMember,
rejectMiniAppPendingMember,
@@ -260,6 +261,10 @@ export default function SettingsRoute() {
if (form.isAdmin && !currentMember.isAdmin) {
updatedMember = await promoteMiniAppMember(data, memberId)
}
// Remove admin access if requested and currently admin
if (!form.isAdmin && currentMember.isAdmin) {
updatedMember = await demoteMiniAppMember(data, memberId)
}
// Update local state
setAdminSettings((prev) => {
@@ -906,16 +911,17 @@ export default function SettingsRoute() {
}
/>
</Field>
<Show when={!editMemberForm().isAdmin}>
<Field label={copy().promoteAdminLabel}>
<Button
variant="secondary"
onClick={() => setEditMemberForm((f) => ({ ...f, isAdmin: true }))}
>
{copy().promoteAdminAction}
</Button>
</Field>
</Show>
<Field label={copy().memberRoleLabel}>
<Select
value={editMemberForm().isAdmin ? 'admin' : 'resident'}
ariaLabel={copy().memberRoleLabel}
options={[
{ value: 'resident', label: copy().memberRoleResident },
{ value: 'admin', label: copy().memberRoleAdmin }
]}
onChange={(value) => setEditMemberForm((f) => ({ ...f, isAdmin: value === 'admin' }))}
/>
</Field>
</div>
</Modal>