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"