mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 11:54:03 +00:00
feat(miniapp): carry overdue billing and admin role flows
This commit is contained in:
@@ -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 ?? ''}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
'Настройками дома и подтверждением заявок управляют админы. Свой язык можно менять переключателем выше.',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: ''
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user