feat(member): add away billing policies

This commit is contained in:
2026-03-11 14:05:52 +04:00
parent 773abf2531
commit 98988159eb
34 changed files with 4218 additions and 39 deletions

View File

@@ -17,10 +17,12 @@ import {
joinMiniAppHousehold,
openMiniAppBillingCycle,
promoteMiniAppMember,
updateMiniAppMemberAbsencePolicy,
updateMiniAppMemberStatus,
updateMiniAppMemberRentWeight,
type MiniAppAdminCycleState,
type MiniAppAdminSettingsPayload,
type MiniAppMemberAbsencePolicy,
updateMiniAppLocalePreference,
updateMiniAppBillingSettings,
updateMiniAppCycleRent,
@@ -282,10 +284,16 @@ function App() {
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
const [savingRentWeightMemberId, setSavingRentWeightMemberId] = createSignal<string | null>(null)
const [savingMemberStatusId, setSavingMemberStatusId] = createSignal<string | null>(null)
const [savingMemberAbsencePolicyId, setSavingMemberAbsencePolicyId] = createSignal<string | null>(
null
)
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
Record<string, 'active' | 'away' | 'left'>
>({})
const [memberAbsencePolicyDrafts, setMemberAbsencePolicyDrafts] = createSignal<
Record<string, MiniAppMemberAbsencePolicy>
>({})
const [savingMemberLocale, setSavingMemberLocale] = createSignal(false)
const [savingHouseholdLocale, setSavingHouseholdLocale] = createSignal(false)
const [savingBillingSettings, setSavingBillingSettings] = createSignal(false)
@@ -400,6 +408,39 @@ function App() {
}
}
function defaultAbsencePolicyForStatus(
status: 'active' | 'away' | 'left'
): MiniAppMemberAbsencePolicy {
if (status === 'away') {
return 'away_rent_and_utilities'
}
if (status === 'left') {
return 'inactive'
}
return 'resident'
}
function resolvedMemberAbsencePolicy(
memberId: string,
status: 'active' | 'away' | 'left',
settings = adminSettings()
) {
const current = settings?.memberAbsencePolicies
.filter((policy) => policy.memberId === memberId)
.sort((left, right) => left.effectiveFromPeriod.localeCompare(right.effectiveFromPeriod))
.at(-1)
return (
current ?? {
memberId,
effectiveFromPeriod: '',
policy: defaultAbsencePolicyForStatus(status)
}
)
}
async function loadDashboard(initData: string) {
try {
const nextDashboard = await fetchMiniAppDashboard(initData)
@@ -441,6 +482,14 @@ function App() {
setMemberStatusDrafts(
Object.fromEntries(payload.members.map((member) => [member.id, member.status]))
)
setMemberAbsencePolicyDrafts(
Object.fromEntries(
payload.members.map((member) => [
member.id,
resolvedMemberAbsencePolicy(member.id, member.status, payload).policy
])
)
)
setCycleForm((current) => ({
...current,
rentCurrency: payload.settings.rentCurrency,
@@ -1276,11 +1325,66 @@ function App() {
...current,
[member.id]: member.status
}))
setMemberAbsencePolicyDrafts((current) => ({
...current,
[member.id]:
current[member.id] ??
resolvedMemberAbsencePolicy(member.id, member.status).policy ??
defaultAbsencePolicyForStatus(member.status)
}))
} finally {
setSavingMemberStatusId(null)
}
}
async function handleSaveMemberAbsencePolicy(memberId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const member = adminSettings()?.members.find((entry) => entry.id === memberId)
const nextPolicy = memberAbsencePolicyDrafts()[memberId]
const effectiveStatus = memberStatusDrafts()[memberId] ?? member?.status
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!member ||
!nextPolicy ||
effectiveStatus !== 'away'
) {
return
}
setSavingMemberAbsencePolicyId(memberId)
try {
const savedPolicy = await updateMiniAppMemberAbsencePolicy(initData, memberId, nextPolicy)
setAdminSettings((current) =>
current
? {
...current,
memberAbsencePolicies: [
...current.memberAbsencePolicies.filter(
(policy) =>
!(
policy.memberId === savedPolicy.memberId &&
policy.effectiveFromPeriod === savedPolicy.effectiveFromPeriod
)
),
savedPolicy
]
}
: current
)
setMemberAbsencePolicyDrafts((current) => ({
...current,
[memberId]: savedPolicy.policy
}))
} finally {
setSavingMemberAbsencePolicyId(null)
}
}
const renderPanel = () => {
switch (activeNav()) {
case 'balances':
@@ -2447,6 +2551,44 @@ function App() {
<option value="left">{copy().memberStatusLeft}</option>
</select>
</label>
<label class="settings-field settings-field--wide">
<span>{copy().absencePolicyLabel}</span>
<select
value={
memberAbsencePolicyDrafts()[member.id] ??
resolvedMemberAbsencePolicy(member.id, member.status).policy
}
disabled={
(memberStatusDrafts()[member.id] ?? member.status) !== 'away'
}
onChange={(event) =>
setMemberAbsencePolicyDrafts((current) => ({
...current,
[member.id]: event.currentTarget
.value as MiniAppMemberAbsencePolicy
}))
}
>
<option value="away_rent_and_utilities">
{copy().absencePolicyAwayRentAndUtilities}
</option>
<option value="away_rent_only">
{copy().absencePolicyAwayRentOnly}
</option>
<option value="inactive">{copy().absencePolicyInactive}</option>
<option value="resident">{copy().absencePolicyResident}</option>
</select>
<small>
{resolvedMemberAbsencePolicy(member.id, member.status)
.effectiveFromPeriod
? copy().absencePolicyEffectiveFrom.replace(
'{period}',
resolvedMemberAbsencePolicy(member.id, member.status)
.effectiveFromPeriod
)
: copy().absencePolicyHint}
</small>
</label>
<label class="settings-field settings-field--wide">
<span>{copy().rentWeightLabel}</span>
<input
@@ -2474,6 +2616,19 @@ function App() {
? copy().savingMemberStatus
: copy().saveMemberStatusAction}
</button>
<button
class="ghost-button"
type="button"
disabled={
savingMemberAbsencePolicyId() === member.id ||
(memberStatusDrafts()[member.id] ?? member.status) !== 'away'
}
onClick={() => void handleSaveMemberAbsencePolicy(member.id)}
>
{savingMemberAbsencePolicyId() === member.id
? copy().savingAbsencePolicy
: copy().saveAbsencePolicyAction}
</button>
<button
class="ghost-button"
type="button"

View File

@@ -144,6 +144,15 @@ export const dictionary = {
memberStatusActive: 'Active',
memberStatusAway: 'Away',
memberStatusLeft: 'Left',
absencePolicyLabel: 'Away billing policy',
absencePolicyResident: 'Resident billing',
absencePolicyAwayRentAndUtilities: 'Away: rent and utilities',
absencePolicyAwayRentOnly: 'Away: rent only',
absencePolicyInactive: 'Inactive / moved out',
absencePolicyHint: 'Applies to future cycle calculations for away members.',
absencePolicyEffectiveFrom: 'Effective from {period}',
saveAbsencePolicyAction: 'Save away policy',
savingAbsencePolicy: 'Saving policy…',
memberStatusSummary: 'Your household status: {status}.',
rentWeightLabel: 'Rent weight',
saveRentWeightAction: 'Save rent weight',
@@ -309,6 +318,15 @@ export const dictionary = {
memberStatusActive: 'Активный',
memberStatusAway: 'В отъезде',
memberStatusLeft: 'Выехал',
absencePolicyLabel: 'Политика начислений в отъезде',
absencePolicyResident: 'Как у проживающего',
absencePolicyAwayRentAndUtilities: 'В отъезде: аренда и коммуналка',
absencePolicyAwayRentOnly: 'В отъезде: только аренда',
absencePolicyInactive: 'Неактивен / выехал',
absencePolicyHint: 'Применяется к будущим расчётам для участников со статусом «В отъезде».',
absencePolicyEffectiveFrom: 'Действует с {period}',
saveAbsencePolicyAction: 'Сохранить политику',
savingAbsencePolicy: 'Сохраняем политику…',
memberStatusSummary: 'Твой статус в household: {status}.',
rentWeightLabel: 'Вес аренды',
saveRentWeightAction: 'Сохранить вес аренды',

View File

@@ -37,6 +37,18 @@ export interface MiniAppPendingMember {
languageCode: string | null
}
export type MiniAppMemberAbsencePolicy =
| 'resident'
| 'away_rent_and_utilities'
| 'away_rent_only'
| 'inactive'
export interface MiniAppMemberAbsencePolicyRecord {
memberId: string
effectiveFromPeriod: string
policy: MiniAppMemberAbsencePolicy
}
export interface MiniAppMember {
id: string
displayName: string
@@ -116,6 +128,7 @@ export interface MiniAppAdminSettingsPayload {
topics: readonly MiniAppTopicBinding[]
categories: readonly MiniAppUtilityCategory[]
members: readonly MiniAppMember[]
memberAbsencePolicies: readonly MiniAppMemberAbsencePolicyRecord[]
}
export interface MiniAppAdminCycleState {
@@ -362,6 +375,7 @@ export async function fetchMiniAppAdminSettings(
topics?: MiniAppTopicBinding[]
categories?: MiniAppUtilityCategory[]
members?: MiniAppMember[]
memberAbsencePolicies?: MiniAppMemberAbsencePolicyRecord[]
error?: string
}
@@ -371,7 +385,8 @@ export async function fetchMiniAppAdminSettings(
!payload.settings ||
!payload.topics ||
!payload.categories ||
!payload.members
!payload.members ||
!payload.memberAbsencePolicies
) {
throw new Error(payload.error ?? 'Failed to load admin settings')
}
@@ -380,7 +395,8 @@ export async function fetchMiniAppAdminSettings(
settings: payload.settings,
topics: payload.topics,
categories: payload.categories,
members: payload.members
members: payload.members,
memberAbsencePolicies: payload.memberAbsencePolicies
}
}
@@ -547,6 +563,37 @@ export async function updateMiniAppMemberStatus(
return payload.member
}
export async function updateMiniAppMemberAbsencePolicy(
initData: string,
memberId: string,
policy: MiniAppMemberAbsencePolicy
): Promise<MiniAppMemberAbsencePolicyRecord> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/absence-policy`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
memberId,
policy
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
policy?: MiniAppMemberAbsencePolicyRecord
error?: string
}
if (!response.ok || !payload.policy) {
throw new Error(payload.error ?? 'Failed to update member absence policy')
}
return payload.policy
}
export async function fetchMiniAppBillingCycle(initData: string): Promise<MiniAppAdminCycleState> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/billing-cycle`, {
method: 'POST',