feat(member): improve assistant roster awareness

This commit is contained in:
2026-03-11 15:10:20 +04:00
parent 79f96ba45b
commit 0787847c19
27 changed files with 1429 additions and 3 deletions

View File

@@ -17,9 +17,11 @@ import {
joinMiniAppHousehold,
openMiniAppBillingCycle,
promoteMiniAppMember,
updateMiniAppMemberDisplayName,
updateMiniAppMemberAbsencePolicy,
updateMiniAppMemberStatus,
updateMiniAppMemberRentWeight,
updateMiniAppOwnDisplayName,
type MiniAppAdminCycleState,
type MiniAppAdminSettingsPayload,
type MiniAppMemberAbsencePolicy,
@@ -311,11 +313,19 @@ function App() {
const [joining, setJoining] = createSignal(false)
const [approvingTelegramUserId, setApprovingTelegramUserId] = createSignal<string | null>(null)
const [promotingMemberId, setPromotingMemberId] = createSignal<string | null>(null)
const [savingOwnDisplayName, setSavingOwnDisplayName] = createSignal(false)
const [savingMemberDisplayNameId, setSavingMemberDisplayNameId] = 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 [displayNameDraft, setDisplayNameDraft] = createSignal('')
const [memberDisplayNameDrafts, setMemberDisplayNameDrafts] = createSignal<
Record<string, string>
>({})
const [rentWeightDrafts, setRentWeightDrafts] = createSignal<Record<string, string>>({})
const [memberStatusDrafts, setMemberStatusDrafts] = createSignal<
Record<string, 'active' | 'away' | 'left'>
@@ -471,6 +481,65 @@ function App() {
)
}
function syncDisplayName(memberId: string, displayName: string) {
setSession((current) =>
current.status === 'ready' && current.member.id === memberId
? {
...current,
member: {
...current.member,
displayName
}
}
: current
)
setAdminSettings((current) =>
current
? {
...current,
members: current.members.map((member) =>
member.id === memberId
? {
...member,
displayName
}
: member
)
}
: current
)
setDashboard((current) =>
current
? {
...current,
members: current.members.map((member) =>
member.memberId === memberId
? {
...member,
displayName
}
: member
),
ledger: current.ledger.map((entry) =>
entry.memberId === memberId
? {
...entry,
actorDisplayName: displayName
}
: entry
)
}
: current
)
setDisplayNameDraft((current) =>
readySession()?.member.id === memberId ? displayName : current
)
setMemberDisplayNameDrafts((current) => ({
...current,
[memberId]: displayName
}))
}
async function loadDashboard(initData: string) {
try {
const nextDashboard = await fetchMiniAppDashboard(initData)
@@ -504,6 +573,9 @@ function App() {
try {
const payload = await fetchMiniAppAdminSettings(initData)
setAdminSettings(payload)
setMemberDisplayNameDrafts(
Object.fromEntries(payload.members.map((member) => [member.id, member.displayName]))
)
setRentWeightDrafts(
Object.fromEntries(
payload.members.map((member) => [member.id, String(member.rentShareWeight)])
@@ -655,6 +727,7 @@ function App() {
}
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
setDisplayNameDraft(payload.member.displayName)
setSession({
status: 'ready',
mode: 'live',
@@ -673,6 +746,7 @@ function App() {
}
} catch {
if (import.meta.env.DEV) {
setDisplayNameDraft(demoSession.member.displayName)
setSession(demoSession)
setDashboard({
period: '2026-03',
@@ -793,6 +867,7 @@ function App() {
const payload = await joinMiniAppHousehold(initData, joinToken)
if (payload.authorized && payload.member && payload.telegramUser) {
setLocale(payload.member.preferredLocale ?? payload.member.householdDefaultLocale)
setDisplayNameDraft(payload.member.displayName)
setSession({
status: 'ready',
mode: 'live',
@@ -922,6 +997,51 @@ function App() {
}
}
async function handleSaveOwnDisplayName() {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextDisplayName = displayNameDraft().trim()
if (!initData || currentReady?.mode !== 'live' || nextDisplayName.length === 0) {
return
}
setSavingOwnDisplayName(true)
try {
const updatedMember = await updateMiniAppOwnDisplayName(initData, nextDisplayName)
syncDisplayName(updatedMember.id, updatedMember.displayName)
} finally {
setSavingOwnDisplayName(false)
}
}
async function handleSaveMemberDisplayName(memberId: string) {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
const nextDisplayName = memberDisplayNameDrafts()[memberId]?.trim()
if (
!initData ||
currentReady?.mode !== 'live' ||
!currentReady.member.isAdmin ||
!nextDisplayName
) {
return
}
setSavingMemberDisplayNameId(memberId)
try {
const updatedMember = await updateMiniAppMemberDisplayName(
initData,
memberId,
nextDisplayName
)
syncDisplayName(updatedMember.id, updatedMember.displayName)
} finally {
setSavingMemberDisplayNameId(null)
}
}
async function handleSaveBillingSettings() {
const initData = webApp?.initData?.trim()
const currentReady = readySession()
@@ -2764,6 +2884,18 @@ function App() {
</span>
</header>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().displayNameLabel}</span>
<input
value={memberDisplayNameDrafts()[member.id] ?? member.displayName}
onInput={(event) =>
setMemberDisplayNameDrafts((current) => ({
...current,
[member.id]: event.currentTarget.value
}))
}
/>
</label>
<label class="settings-field settings-field--wide">
<span>{copy().memberStatusLabel}</span>
<select
@@ -2838,6 +2970,23 @@ function App() {
</label>
</div>
<div class="inline-actions">
<button
class="ghost-button"
type="button"
disabled={
savingMemberDisplayNameId() === member.id ||
(memberDisplayNameDrafts()[member.id] ?? member.displayName).trim()
.length < 2 ||
(
memberDisplayNameDrafts()[member.id] ?? member.displayName
).trim() === member.displayName
}
onClick={() => void handleSaveMemberDisplayName(member.id)}
>
{savingMemberDisplayNameId() === member.id
? copy().savingDisplayName
: copy().saveDisplayName}
</button>
<button
class="ghost-button"
type="button"
@@ -3284,6 +3433,32 @@ function App() {
: copy().memberStatusActive
)}
</p>
<Show when={readySession()?.mode === 'live'}>
<div class="settings-grid">
<label class="settings-field settings-field--wide">
<span>{copy().displayNameLabel}</span>
<input
value={displayNameDraft()}
onInput={(event) => setDisplayNameDraft(event.currentTarget.value)}
/>
<small>{copy().displayNameHint}</small>
</label>
</div>
<div class="inline-actions">
<button
class="ghost-button"
type="button"
disabled={
savingOwnDisplayName() ||
displayNameDraft().trim().length < 2 ||
displayNameDraft().trim() === readySession()?.member.displayName
}
onClick={() => void handleSaveOwnDisplayName()}
>
{savingOwnDisplayName() ? copy().savingDisplayName : copy().saveDisplayName}
</button>
</div>
</Show>
<div>{renderPanel()}</div>
</article>
</section>

View File

@@ -148,6 +148,10 @@ export const dictionary = {
savingCategory: 'Saving…',
adminsTitle: 'Admins',
adminsBody: 'Promote trusted household members so they can manage billing and approvals.',
displayNameLabel: 'Household display name',
displayNameHint: 'This name appears in balances, ledger entries, and assistant replies.',
saveDisplayName: 'Save name',
savingDisplayName: 'Saving name…',
memberStatusLabel: 'Member status',
saveMemberStatusAction: 'Save status',
savingMemberStatus: 'Saving status…',
@@ -332,6 +336,10 @@ export const dictionary = {
adminsTitle: 'Админы',
adminsBody:
'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.',
displayNameLabel: 'Имя в household',
displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.',
saveDisplayName: 'Сохранить имя',
savingDisplayName: 'Сохраняем имя…',
memberStatusLabel: 'Статус участника',
saveMemberStatusAction: 'Сохранить статус',
savingMemberStatus: 'Сохраняем статус…',

View File

@@ -509,6 +509,66 @@ export async function promoteMiniAppMember(
return payload.member
}
export async function updateMiniAppOwnDisplayName(
initData: string,
displayName: string
): Promise<NonNullable<MiniAppSession['member']>> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/member/display-name`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
displayName
})
})
const payload = (await response.json()) as {
ok: boolean
authorized?: boolean
member?: MiniAppSession['member']
error?: string
}
if (!response.ok || !payload.authorized || !payload.member) {
throw new Error(payload.error ?? 'Failed to update display name')
}
return payload.member
}
export async function updateMiniAppMemberDisplayName(
initData: string,
memberId: string,
displayName: string
): Promise<MiniAppMember> {
const response = await fetch(`${apiBaseUrl()}/api/miniapp/admin/members/display-name`, {
method: 'POST',
headers: {
'content-type': 'application/json'
},
body: JSON.stringify({
initData,
memberId,
displayName
})
})
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 update member display name')
}
return payload.member
}
export async function updateMiniAppMemberRentWeight(
initData: string,
memberId: string,