feat(miniapp): refine UI and add utility bill management

- Fix collapsible padding and button spacing
- Add subtotal to balance card
- Add utility bill management for admins
- Fix lints and type checks across the monorepo
- Implement rejectPendingHouseholdMember in repository and service
This commit is contained in:
2026-03-13 05:52:34 +04:00
parent 25c4928ca9
commit 94a5904f54
58 changed files with 5400 additions and 7006 deletions

View File

@@ -0,0 +1,151 @@
import { Show, For } from 'solid-js'
import { BarChart3 } from 'lucide-solid'
import { useI18n } from '../contexts/i18n-context'
import { useDashboard } from '../contexts/dashboard-context'
import { Card } from '../components/ui/card'
import { memberRemainingClass } from '../lib/ledger-helpers'
export default function BalancesRoute() {
const { copy } = useI18n()
const { dashboard, memberBalanceVisuals, purchaseInvestmentChart } = useDashboard()
return (
<div class="route route--balances">
<Show
when={dashboard()}
fallback={
<Card>
<p class="empty-state">{copy().balancesEmpty}</p>
</Card>
}
>
{(data) => (
<>
{/* ── Household balances ─────────────────── */}
<Card>
<div class="section-header">
<strong>{copy().householdBalancesTitle}</strong>
<p>{copy().householdBalancesBody}</p>
</div>
<div class="member-balance-list">
<For each={data().members}>
{(member) => (
<div class={`member-balance-row ${memberRemainingClass(member)}`}>
<span class="member-balance-row__name">{member.displayName}</span>
<div class="member-balance-row__amounts">
<span class="member-balance-row__due">
{member.netDueMajor} {data().currency}
</span>
<span class="member-balance-row__remaining">
{member.remainingMajor} {data().currency}
</span>
</div>
</div>
)}
</For>
</div>
</Card>
{/* ── Balance breakdown bars ──────────────── */}
<Card>
<div class="section-header">
<BarChart3 size={16} />
<strong>{copy().financeVisualsTitle}</strong>
<p>{copy().financeVisualsBody}</p>
</div>
<div class="balance-visuals">
<For each={memberBalanceVisuals()}>
{(item) => (
<div class="balance-bar-row">
<span class="balance-bar-row__name">{item.member.displayName}</span>
<div
class="balance-bar-row__track"
style={{ width: `${Math.max(item.barWidthPercent, 8)}%` }}
>
<For each={item.segments}>
{(segment) => (
<div
class={`balance-bar-row__segment balance-bar-row__segment--${segment.key}`}
style={{ width: `${segment.widthPercent}%` }}
title={`${segment.label}: ${segment.amountMajor} ${data().currency}`}
/>
)}
</For>
</div>
<span class={`balance-bar-row__label ${memberRemainingClass(item.member)}`}>
{item.member.remainingMajor} {data().currency}
</span>
</div>
)}
</For>
<div class="balance-bar-legend">
<span class="balance-bar-legend__item balance-bar-legend__item--rent">
{copy().shareRent}
</span>
<span class="balance-bar-legend__item balance-bar-legend__item--utilities">
{copy().shareUtilities}
</span>
<span class="balance-bar-legend__item balance-bar-legend__item--purchase">
{copy().shareOffset}
</span>
</div>
</div>
</Card>
{/* ── Purchase investment donut ───────────── */}
<Card>
<div class="section-header">
<strong>{copy().purchaseInvestmentsTitle}</strong>
<p>{copy().purchaseInvestmentsBody}</p>
</div>
<Show
when={purchaseInvestmentChart().slices.length > 0}
fallback={<p class="empty-state">{copy().purchaseInvestmentsEmpty}</p>}
>
<div class="donut-chart">
<svg viewBox="0 0 100 100" class="donut-chart__svg">
<For each={purchaseInvestmentChart().slices}>
{(slice) => (
<circle
cx="50"
cy="50"
r="42"
fill="none"
stroke={slice.color}
stroke-width="12"
stroke-dasharray={slice.dasharray}
stroke-dashoffset={slice.dashoffset}
class="donut-chart__slice"
/>
)}
</For>
<text x="50" y="48" text-anchor="middle" class="donut-chart__total">
{purchaseInvestmentChart().totalMajor}
</text>
<text x="50" y="58" text-anchor="middle" class="donut-chart__label">
{data().currency}
</text>
</svg>
<div class="donut-chart__legend">
<For each={purchaseInvestmentChart().slices}>
{(slice) => (
<div class="donut-chart__legend-item">
<span class="donut-chart__color" style={{ background: slice.color }} />
<span>{slice.label}</span>
<strong>
{slice.amountMajor} ({slice.percentage}%)
</strong>
</div>
)}
</For>
</div>
</div>
</Show>
</Card>
</>
)}
</Show>
</div>
)
}

View File

@@ -0,0 +1,158 @@
import { Show, For } from 'solid-js'
import { Clock } from 'lucide-solid'
import { useSession } from '../contexts/session-context'
import { useI18n } from '../contexts/i18n-context'
import { useDashboard } from '../contexts/dashboard-context'
import { Card } from '../components/ui/card'
import { Badge } from '../components/ui/badge'
import { memberRemainingClass, ledgerPrimaryAmount } from '../lib/ledger-helpers'
import { majorStringToMinor, minorToMajorString } from '../lib/money'
export default function HomeRoute() {
const { readySession } = useSession()
const { copy } = useI18n()
const { dashboard, currentMemberLine } = useDashboard()
function dueStatusBadge() {
const data = dashboard()
if (!data) return null
const remaining = majorStringToMinor(data.totalRemainingMajor)
if (remaining <= 0n) return { label: copy().homeSettledTitle, variant: 'accent' as const }
return { label: copy().homeDueTitle, variant: 'danger' as const }
}
return (
<div class="route route--home">
{/* ── Welcome hero ────────────────────────────── */}
<div class="home-hero">
<p class="home-hero__greeting">{copy().welcome},</p>
<h2 class="home-hero__name">{readySession()?.member.displayName}</h2>
</div>
{/* ── Dashboard stats ─────────────────────────── */}
<Show
when={dashboard()}
fallback={
<Card>
<p class="empty-state">{copy().emptyDashboard}</p>
</Card>
}
>
{(data) => (
<>
{/* Your balance card */}
<Show when={currentMemberLine()}>
{(member) => {
const subtotalMinor =
majorStringToMinor(member().rentShareMajor) +
majorStringToMinor(member().utilityShareMajor)
const subtotalMajor = minorToMajorString(subtotalMinor)
return (
<Card accent>
<div class="balance-card">
<div class="balance-card__header">
<span class="balance-card__label">{copy().yourBalanceTitle}</span>
<Show when={dueStatusBadge()}>
{(badge) => <Badge variant={badge().variant}>{badge().label}</Badge>}
</Show>
</div>
<div class="balance-card__amounts">
<div class="balance-card__row">
<span>{copy().shareRent}</span>
<strong>
{member().rentShareMajor} {data().currency}
</strong>
</div>
<div class="balance-card__row">
<span>{copy().shareUtilities}</span>
<strong>
{member().utilityShareMajor} {data().currency}
</strong>
</div>
<div class="balance-card__row balance-card__row--subtotal">
<span>{copy().totalDueLabel}</span>
<strong>
{subtotalMajor} {data().currency}
</strong>
</div>
<div class="balance-card__row">
<span>{copy().balanceAdjustmentLabel}</span>
<strong>
{member().purchaseOffsetMajor} {data().currency}
</strong>
</div>
<div
class={`balance-card__row balance-card__remaining ${memberRemainingClass(member())}`}
>
<span>{copy().remainingLabel}</span>
<strong>
{member().remainingMajor} {data().currency}
</strong>
</div>
</div>
</div>
</Card>
)
}}
</Show>
{/* Rent FX card */}
<Show when={data().rentSourceCurrency !== data().currency}>
<Card muted>
<div class="fx-card">
<strong class="fx-card__title">{copy().rentFxTitle}</strong>
<div class="fx-card__row">
<span>{copy().sourceAmountLabel}</span>
<strong>
{data().rentSourceAmountMajor} {data().rentSourceCurrency}
</strong>
</div>
<div class="fx-card__row">
<span>{copy().settlementAmountLabel}</span>
<strong>
{data().rentDisplayAmountMajor} {data().currency}
</strong>
</div>
<Show when={data().rentFxEffectiveDate}>
<div class="fx-card__row fx-card__row--muted">
<span>{copy().fxEffectiveDateLabel}</span>
<span>{data().rentFxEffectiveDate}</span>
</div>
</Show>
</div>
</Card>
</Show>
{/* Latest activity */}
<Card>
<div class="activity-card">
<div class="activity-card__header">
<Clock size={16} />
<span>{copy().latestActivityTitle}</span>
</div>
<Show
when={data().ledger.length > 0}
fallback={<p class="empty-state">{copy().latestActivityEmpty}</p>}
>
<div class="activity-card__list">
<For each={data().ledger.slice(0, 5)}>
{(entry) => (
<div class="activity-card__item">
<span class="activity-card__title">{entry.title}</span>
<span class="activity-card__amount">{ledgerPrimaryAmount(entry)}</span>
</div>
)}
</For>
</div>
</Show>
</div>
</Card>
</>
)}
</Show>
</div>
)
}

View File

@@ -0,0 +1,971 @@
import { Show, For, createSignal, createMemo } from 'solid-js'
import { Plus } from 'lucide-solid'
import { useSession } from '../contexts/session-context'
import { useI18n } from '../contexts/i18n-context'
import { useDashboard } from '../contexts/dashboard-context'
import { Card } from '../components/ui/card'
import { Button } from '../components/ui/button'
import { Modal } from '../components/ui/dialog'
import { Input } from '../components/ui/input'
import { Select } from '../components/ui/select'
import { Field } from '../components/ui/field'
import { Collapsible } from '../components/ui/collapsible'
import { Toggle } from '../components/ui/toggle'
import {
ledgerPrimaryAmount,
ledgerSecondaryAmount,
purchaseDraftForEntry,
paymentDraftForEntry,
computePaymentPrefill,
type PurchaseDraft,
type PaymentDraft
} from '../lib/ledger-helpers'
import {
addMiniAppPurchase,
updateMiniAppPurchase,
deleteMiniAppPurchase,
addMiniAppPayment,
updateMiniAppPayment,
deleteMiniAppPayment,
addMiniAppUtilityBill,
updateMiniAppUtilityBill,
deleteMiniAppUtilityBill,
type MiniAppDashboard
} from '../miniapp-api'
export default function LedgerRoute() {
const { initData, refreshHouseholdData } = useSession()
const { copy } = useI18n()
const { dashboard, effectiveIsAdmin, purchaseLedger, utilityLedger, paymentLedger } =
useDashboard()
// ── Purchase editor ──────────────────────────────
const [editingPurchase, setEditingPurchase] = createSignal<
MiniAppDashboard['ledger'][number] | null
>(null)
const [purchaseDraft, setPurchaseDraft] = createSignal<PurchaseDraft | null>(null)
const [savingPurchase, setSavingPurchase] = createSignal(false)
const [deletingPurchase, setDeletingPurchase] = createSignal(false)
// ── New purchase form (Bug #4 fix) ───────────────
const [addPurchaseOpen, setAddPurchaseOpen] = createSignal(false)
const [newPurchase, setNewPurchase] = createSignal<PurchaseDraft>({
description: '',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
splitMode: 'equal',
splitInputMode: 'equal',
participants: []
})
const [addingPurchase, setAddingPurchase] = createSignal(false)
// ── Utility bill editor ──────────────────────────
const [editingUtility, setEditingUtility] = createSignal<
MiniAppDashboard['ledger'][number] | null
>(null)
const [utilityDraft, setUtilityDraft] = createSignal<{
billName: string
amountMajor: string
currency: 'USD' | 'GEL'
} | null>(null)
const [savingUtility, setSavingUtility] = createSignal(false)
const [deletingUtility, setDeletingUtility] = createSignal(false)
// ── New utility bill form ────────────────────────
const [addUtilityOpen, setAddUtilityOpen] = createSignal(false)
const [newUtility, setNewUtility] = createSignal({
billName: '',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
})
const [addingUtility, setAddingUtility] = createSignal(false)
// ── Payment editor ───────────────────────────────
const [editingPayment, setEditingPayment] = createSignal<
MiniAppDashboard['ledger'][number] | null
>(null)
const [paymentDraftState, setPaymentDraft] = createSignal<PaymentDraft | null>(null)
const [savingPayment, setSavingPayment] = createSignal(false)
const [deletingPayment, setDeletingPayment] = createSignal(false)
// ── New payment form ─────────────────────────────
const [addPaymentOpen, setAddPaymentOpen] = createSignal(false)
const [newPayment, setNewPayment] = createSignal<PaymentDraft>({
memberId: '',
kind: 'rent',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
})
const [addingPayment, setAddingPayment] = createSignal(false)
function openPurchaseEditor(entry: MiniAppDashboard['ledger'][number]) {
setEditingPurchase(entry)
setPurchaseDraft(purchaseDraftForEntry(entry))
}
function closePurchaseEditor() {
setEditingPurchase(null)
setPurchaseDraft(null)
}
function openPaymentEditor(entry: MiniAppDashboard['ledger'][number]) {
setEditingPayment(entry)
setPaymentDraft(paymentDraftForEntry(entry))
}
function closePaymentEditor() {
setEditingPayment(null)
setPaymentDraft(null)
}
function openUtilityEditor(entry: MiniAppDashboard['ledger'][number]) {
setEditingUtility(entry)
setUtilityDraft({
billName: entry.title,
amountMajor: entry.amountMajor,
currency: entry.currency as 'USD' | 'GEL'
})
}
function closeUtilityEditor() {
setEditingUtility(null)
setUtilityDraft(null)
}
async function handleAddUtility() {
const data = initData()
const draft = newUtility()
if (!data || !draft.billName.trim() || !draft.amountMajor.trim()) return
setAddingUtility(true)
try {
await addMiniAppUtilityBill(data, draft)
setAddUtilityOpen(false)
setNewUtility({
billName: '',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
})
await refreshHouseholdData(true, true)
} finally {
setAddingUtility(false)
}
}
async function handleSaveUtility() {
const data = initData()
const entry = editingUtility()
const draft = utilityDraft()
if (!data || !entry || !draft) return
setSavingUtility(true)
try {
await updateMiniAppUtilityBill(data, {
billId: entry.id,
...draft
})
closeUtilityEditor()
await refreshHouseholdData(true, true)
} finally {
setSavingUtility(false)
}
}
async function handleDeleteUtility() {
const data = initData()
const entry = editingUtility()
if (!data || !entry) return
setDeletingUtility(true)
try {
await deleteMiniAppUtilityBill(data, entry.id)
closeUtilityEditor()
await refreshHouseholdData(true, true)
} finally {
setDeletingUtility(false)
}
}
async function handleSavePurchase() {
const data = initData()
const entry = editingPurchase()
const draft = purchaseDraft()
if (!data || !entry || !draft) return
setSavingPurchase(true)
try {
await updateMiniAppPurchase(data, {
purchaseId: entry.id,
description: draft.description,
amountMajor: draft.amountMajor,
currency: draft.currency,
split: {
mode: draft.splitMode,
participants: draft.participants.map((p) => ({
memberId: p.memberId,
included: p.included,
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts'
? { shareAmountMajor: p.shareAmountMajor }
: {})
}))
}
})
closePurchaseEditor()
await refreshHouseholdData(true, true)
} finally {
setSavingPurchase(false)
}
}
async function handleDeletePurchase() {
const data = initData()
const entry = editingPurchase()
if (!data || !entry) return
setDeletingPurchase(true)
try {
await deleteMiniAppPurchase(data, entry.id)
closePurchaseEditor()
await refreshHouseholdData(true, true)
} finally {
setDeletingPurchase(false)
}
}
async function handleAddPurchase() {
const data = initData()
const draft = newPurchase()
if (!data || !draft.description.trim() || !draft.amountMajor.trim()) return
setAddingPurchase(true)
try {
await addMiniAppPurchase(data, {
description: draft.description,
amountMajor: draft.amountMajor,
currency: draft.currency,
...(draft.participants.length > 0
? {
split: {
mode: draft.splitMode,
participants: draft.participants.map((p) => ({
memberId: p.memberId,
included: p.included,
...(p.shareAmountMajor && draft.splitMode === 'custom_amounts'
? { shareAmountMajor: p.shareAmountMajor }
: {})
}))
}
}
: {})
})
setAddPurchaseOpen(false)
setNewPurchase({
description: '',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL',
splitMode: 'equal',
splitInputMode: 'equal',
participants: []
})
await refreshHouseholdData(true, true)
} finally {
setAddingPurchase(false)
}
}
async function handleSavePayment() {
const data = initData()
const entry = editingPayment()
const draft = paymentDraftState()
if (!data || !entry || !draft) return
setSavingPayment(true)
try {
await updateMiniAppPayment(data, {
paymentId: entry.id,
memberId: draft.memberId,
kind: draft.kind,
amountMajor: draft.amountMajor,
currency: draft.currency
})
closePaymentEditor()
await refreshHouseholdData(true, true)
} finally {
setSavingPayment(false)
}
}
async function handleDeletePayment() {
const data = initData()
const entry = editingPayment()
if (!data || !entry) return
setDeletingPayment(true)
try {
await deleteMiniAppPayment(data, entry.id)
closePaymentEditor()
await refreshHouseholdData(true, true)
} finally {
setDeletingPayment(false)
}
}
async function handleAddPayment() {
const data = initData()
const draft = newPayment()
if (!data || !draft.memberId || !draft.amountMajor.trim()) return
setAddingPayment(true)
try {
await addMiniAppPayment(data, {
memberId: draft.memberId,
kind: draft.kind,
amountMajor: draft.amountMajor,
currency: draft.currency
})
setAddPaymentOpen(false)
setNewPayment({
memberId: '',
kind: 'rent',
amountMajor: '',
currency: (dashboard()?.currency as 'USD' | 'GEL') ?? 'GEL'
})
await refreshHouseholdData(true, true)
} finally {
setAddingPayment(false)
}
}
const currencyOptions = () => [
{ value: 'GEL', label: 'GEL' },
{ value: 'USD', label: 'USD' }
]
const kindOptions = () => [
{ value: 'rent', label: copy().shareRent },
{ value: 'utilities', label: copy().shareUtilities }
]
const memberOptions = createMemo(() =>
(dashboard()?.members ?? []).map((m) => ({ value: m.memberId, label: m.displayName }))
)
const splitModeOptions = () => [
{ value: 'equal', label: copy().purchaseSplitEqual },
{ value: 'exact', label: 'Exact amounts' },
{ value: 'percentage', label: 'Percentages' }
]
function renderParticipantSplitInputs(
draft: PurchaseDraft,
updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void
) {
return (
<div
class="split-configuration"
style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'margin-top': '8px' }}
>
<For each={draft.participants}>
{(participant, idx) => {
const member = dashboard()?.members.find((m) => m.memberId === participant.memberId)
return (
<div
class="split-participant"
style={{ display: 'flex', 'align-items': 'center', gap: '12px' }}
>
<Toggle
checked={participant.included}
onChange={(checked) => {
updateDraft((d) => {
const newParticipants = [...d.participants]
newParticipants[idx()] = { ...participant, included: checked }
return { ...d, participants: newParticipants }
})
}}
/>
<span style={{ flex: 1 }}>{member?.displayName ?? 'Unknown'}</span>
<Show when={participant.included && draft.splitInputMode === 'exact'}>
<Input
type="number"
style={{ width: '100px' }}
placeholder="0.00"
value={participant.shareAmountMajor}
onInput={(e) => {
updateDraft((d) => {
const newParticipants = [...d.participants]
newParticipants[idx()] = {
...participant,
shareAmountMajor: e.currentTarget.value
}
return { ...d, participants: newParticipants }
})
}}
/>
</Show>
<Show when={participant.included && draft.splitInputMode === 'percentage'}>
<Input
type="number"
style={{ width: '80px' }}
placeholder="%"
value={participant.sharePercentage}
onInput={(e) => {
updateDraft((d) => {
const newParticipants = [...d.participants]
newParticipants[idx()] = {
...participant,
sharePercentage: e.currentTarget.value
}
// Calculate exact amount based on percentage
const percentage = parseFloat(e.currentTarget.value) || 0
const totalMajor = parseFloat(d.amountMajor) || 0
const exactAmount = (totalMajor * percentage) / 100
const nextParticipant = newParticipants[idx()]
if (nextParticipant) {
nextParticipant.shareAmountMajor =
exactAmount > 0 ? exactAmount.toFixed(2) : ''
}
return { ...d, participants: newParticipants }
})
}}
/>
</Show>
</div>
)
}}
</For>
</div>
)
}
return (
<div class="route route--ledger">
<Show
when={dashboard()}
fallback={
<Card>
<p class="empty-state">{copy().ledgerEmpty}</p>
</Card>
}
>
{(_data) => (
<>
{/* ── Purchases ──────────────────────────── */}
<Collapsible title={copy().purchasesTitle} body={copy().purchaseReviewBody} defaultOpen>
<Show when={effectiveIsAdmin()}>
<div class="ledger-actions">
<Button variant="primary" size="sm" onClick={() => setAddPurchaseOpen(true)}>
<Plus size={14} />
{copy().purchaseSaveAction}
</Button>
</div>
</Show>
<Show
when={purchaseLedger().length > 0}
fallback={<p class="empty-state">{copy().purchasesEmpty}</p>}
>
<div class="ledger-list">
<For each={purchaseLedger()}>
{(entry) => (
<button
class="ledger-entry"
onClick={() => effectiveIsAdmin() && openPurchaseEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="ledger-entry__main">
<span class="ledger-entry__title">{entry.title}</span>
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
</div>
<div class="ledger-entry__amounts">
<strong>{ledgerPrimaryAmount(entry)}</strong>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="ledger-entry__secondary">{secondary()}</span>
)}
</Show>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
{/* ── Utility bills ──────────────────────── */}
<Collapsible title={copy().utilityLedgerTitle}>
<Show when={effectiveIsAdmin()}>
<div class="ledger-actions">
<Button variant="primary" size="sm" onClick={() => setAddUtilityOpen(true)}>
<Plus size={14} />
{copy().addUtilityBillAction}
</Button>
</div>
</Show>
<Show
when={utilityLedger().length > 0}
fallback={<p class="empty-state">{copy().utilityLedgerEmpty}</p>}
>
<div class="ledger-list">
<For each={utilityLedger()}>
{(entry) => (
<button
class="ledger-entry"
onClick={() => effectiveIsAdmin() && openUtilityEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="ledger-entry__main">
<span class="ledger-entry__title">{entry.title}</span>
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
</div>
<div class="ledger-entry__amounts">
<strong>{ledgerPrimaryAmount(entry)}</strong>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
{/* ── Payments ───────────────────────────── */}
<Collapsible
title={copy().paymentsTitle}
{...(effectiveIsAdmin() && copy().paymentsAdminBody
? { body: copy().paymentsAdminBody }
: {})}
>
<Show when={effectiveIsAdmin()}>
<div class="ledger-actions">
<Button variant="primary" size="sm" onClick={() => setAddPaymentOpen(true)}>
<Plus size={14} />
{copy().paymentsAddAction}
</Button>
</div>
</Show>
<Show
when={paymentLedger().length > 0}
fallback={<p class="empty-state">{copy().paymentsEmpty}</p>}
>
<div class="ledger-list">
<For each={paymentLedger()}>
{(entry) => (
<button
class="ledger-entry"
onClick={() => effectiveIsAdmin() && openPaymentEditor(entry)}
disabled={!effectiveIsAdmin()}
>
<div class="ledger-entry__main">
<span class="ledger-entry__title">
{entry.paymentKind === 'rent'
? copy().paymentLedgerRent
: copy().paymentLedgerUtilities}
</span>
<span class="ledger-entry__actor">{entry.actorDisplayName}</span>
</div>
<div class="ledger-entry__amounts">
<strong>{ledgerPrimaryAmount(entry)}</strong>
</div>
</button>
)}
</For>
</div>
</Show>
</Collapsible>
</>
)}
</Show>
{/* ──────── Add Purchase Modal (Bug #4 fix) ──── */}
<Modal
open={addPurchaseOpen()}
title={copy().purchaseSaveAction}
description={copy().purchaseEditorBody}
closeLabel={copy().closeEditorAction}
onClose={() => setAddPurchaseOpen(false)}
footer={
<div class="modal-action-row">
<Button variant="ghost" onClick={() => setAddPurchaseOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
loading={addingPurchase()}
disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()}
onClick={() => void handleAddPurchase()}
>
{copy().purchaseSaveAction}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().purchaseReviewTitle}>
<Input
value={newPurchase().description}
onInput={(e) => setNewPurchase((p) => ({ ...p, description: e.currentTarget.value }))}
/>
</Field>
<Field label={copy().paymentAmount}>
<Input
type="number"
value={newPurchase().amountMajor}
onInput={(e) => setNewPurchase((p) => ({ ...p, amountMajor: e.currentTarget.value }))}
/>
</Field>
<Field label={copy().currencyLabel}>
<Select
value={newPurchase().currency}
ariaLabel={copy().currencyLabel}
options={currencyOptions()}
onChange={(value) =>
setNewPurchase((p) => ({ ...p, currency: value as 'USD' | 'GEL' }))
}
/>
</Field>
<div style={{ 'grid-column': '1 / -1' }}>
<Field label="Split By">
<Select
value={newPurchase().splitInputMode}
ariaLabel="Split By"
options={splitModeOptions()}
onChange={(value) =>
setNewPurchase((p) => {
const splitInputMode = value as 'equal' | 'exact' | 'percentage'
const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts'
return { ...p, splitInputMode, splitMode }
})
}
/>
</Field>
{renderParticipantSplitInputs(newPurchase(), (updater) =>
setNewPurchase((prev) => updater(prev))
)}
</div>
</div>
</Modal>
{/* ──────── Edit Purchase Modal ───────────────── */}
<Modal
open={!!editingPurchase()}
title={copy().editEntryAction}
description={copy().purchaseEditorBody}
closeLabel={copy().closeEditorAction}
onClose={closePurchaseEditor}
footer={
<div class="modal-action-row">
<Button
variant="danger"
loading={deletingPurchase()}
onClick={() => void handleDeletePurchase()}
>
{deletingPurchase() ? copy().deletingPurchase : copy().purchaseDeleteAction}
</Button>
<Button
variant="primary"
loading={savingPurchase()}
onClick={() => void handleSavePurchase()}
>
{savingPurchase() ? copy().savingPurchase : copy().purchaseSaveAction}
</Button>
</div>
}
>
<Show when={purchaseDraft()}>
{(draft) => (
<div class="editor-grid">
<Field label={copy().purchaseReviewTitle}>
<Input
value={draft().description}
onInput={(e) =>
setPurchaseDraft((d) => (d ? { ...d, description: e.currentTarget.value } : d))
}
/>
</Field>
<Field label={copy().paymentAmount}>
<Input
type="number"
value={draft().amountMajor}
onInput={(e) =>
setPurchaseDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d))
}
/>
</Field>
<Field label={copy().currencyLabel}>
<Select
value={draft().currency}
ariaLabel={copy().currencyLabel}
options={currencyOptions()}
onChange={(value) =>
setPurchaseDraft((d) => (d ? { ...d, currency: value as 'USD' | 'GEL' } : d))
}
/>
</Field>
<div style={{ 'grid-column': '1 / -1' }}>
<Field label="Split By">
<Select
value={draft().splitInputMode}
ariaLabel="Split By"
options={splitModeOptions()}
onChange={(value) =>
setPurchaseDraft((d) => {
if (!d) return d
const splitInputMode = value as 'equal' | 'exact' | 'percentage'
const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts'
return { ...d, splitInputMode, splitMode }
})
}
/>
</Field>
{renderParticipantSplitInputs(draft(), (updater) =>
setPurchaseDraft((prev) => (prev ? updater(prev) : prev))
)}
</div>
</div>
)}
</Show>
</Modal>
{/* ──────── Add Payment Modal ─────────────────── */}
<Modal
open={addPaymentOpen()}
title={copy().paymentsAddAction}
description={copy().paymentCreateBody}
closeLabel={copy().closeEditorAction}
onClose={() => setAddPaymentOpen(false)}
footer={
<div class="modal-action-row">
<Button variant="ghost" onClick={() => setAddPaymentOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
loading={addingPayment()}
disabled={!newPayment().memberId || !newPayment().amountMajor.trim()}
onClick={() => void handleAddPayment()}
>
{addingPayment() ? copy().addingPayment : copy().paymentSaveAction}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().paymentMember}>
<Select
value={newPayment().memberId}
ariaLabel={copy().paymentMember}
placeholder="—"
options={[{ value: '', label: '—' }, ...memberOptions()]}
onChange={(memberId) => {
const member = dashboard()?.members.find((m) => m.memberId === memberId)
const prefill = computePaymentPrefill(member, newPayment().kind)
setNewPayment((p) => ({ ...p, memberId, amountMajor: prefill }))
}}
/>
</Field>
<Field label={copy().paymentKind}>
<Select
value={newPayment().kind}
ariaLabel={copy().paymentKind}
options={kindOptions()}
onChange={(value) =>
setNewPayment((p) => ({ ...p, kind: value as 'rent' | 'utilities' }))
}
/>
</Field>
<Field label={copy().paymentAmount}>
<Input
type="number"
value={newPayment().amountMajor}
onInput={(e) => setNewPayment((p) => ({ ...p, amountMajor: e.currentTarget.value }))}
/>
</Field>
<Field label={copy().currencyLabel}>
<Select
value={newPayment().currency}
ariaLabel={copy().currencyLabel}
options={currencyOptions()}
onChange={(value) =>
setNewPayment((p) => ({ ...p, currency: value as 'USD' | 'GEL' }))
}
/>
</Field>
</div>
</Modal>
{/* ──────── Add Utility Modal ─────────────────── */}
<Modal
open={addUtilityOpen()}
title={copy().addUtilityBillAction}
description={copy().utilityBillCreateBody}
closeLabel={copy().closeEditorAction}
onClose={() => setAddUtilityOpen(false)}
footer={
<div class="modal-action-row">
<Button variant="ghost" onClick={() => setAddUtilityOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
loading={addingUtility()}
disabled={!newUtility().billName.trim() || !newUtility().amountMajor.trim()}
onClick={() => void handleAddUtility()}
>
{addingUtility() ? copy().savingUtilityBill : copy().addUtilityBillAction}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().utilityCategoryLabel}>
<Input
value={newUtility().billName}
onInput={(e) => setNewUtility((p) => ({ ...p, billName: e.currentTarget.value }))}
/>
</Field>
<Field label={copy().utilityAmount}>
<Input
type="number"
value={newUtility().amountMajor}
onInput={(e) => setNewUtility((p) => ({ ...p, amountMajor: e.currentTarget.value }))}
/>
</Field>
<Field label={copy().currencyLabel}>
<Select
value={newUtility().currency}
ariaLabel={copy().currencyLabel}
options={currencyOptions()}
onChange={(value) =>
setNewUtility((p) => ({ ...p, currency: value as 'USD' | 'GEL' }))
}
/>
</Field>
</div>
</Modal>
{/* ──────── Edit Utility Modal ────────────────── */}
<Modal
open={!!editingUtility()}
title={copy().editUtilityBillAction}
description={copy().utilityBillEditorBody}
closeLabel={copy().closeEditorAction}
onClose={closeUtilityEditor}
footer={
<div class="modal-action-row">
<Button
variant="danger"
loading={deletingUtility()}
onClick={() => void handleDeleteUtility()}
>
{deletingUtility() ? copy().deletingUtilityBill : copy().deleteUtilityBillAction}
</Button>
<Button
variant="primary"
loading={savingUtility()}
onClick={() => void handleSaveUtility()}
>
{savingUtility() ? copy().savingUtilityBill : copy().saveUtilityBillAction}
</Button>
</div>
}
>
<Show when={utilityDraft()}>
{(draft) => (
<div class="editor-grid">
<Field label={copy().utilityCategoryLabel}>
<Input
value={draft().billName}
onInput={(e) =>
setUtilityDraft((d) => (d ? { ...d, billName: e.currentTarget.value } : d))
}
/>
</Field>
<Field label={copy().utilityAmount}>
<Input
type="number"
value={draft().amountMajor}
onInput={(e) =>
setUtilityDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d))
}
/>
</Field>
<Field label={copy().currencyLabel}>
<Select
value={draft().currency}
ariaLabel={copy().currencyLabel}
options={currencyOptions()}
onChange={(value) =>
setUtilityDraft((d) => (d ? { ...d, currency: value as 'USD' | 'GEL' } : d))
}
/>
</Field>
</div>
)}
</Show>
</Modal>
{/* ──────── Edit Payment Modal ────────────────── */}
<Modal
open={!!editingPayment()}
title={copy().editEntryAction}
description={copy().paymentEditorBody}
closeLabel={copy().closeEditorAction}
onClose={closePaymentEditor}
footer={
<div class="modal-action-row">
<Button
variant="danger"
loading={deletingPayment()}
onClick={() => void handleDeletePayment()}
>
{deletingPayment() ? copy().deletingPayment : copy().paymentDeleteAction}
</Button>
<Button
variant="primary"
loading={savingPayment()}
onClick={() => void handleSavePayment()}
>
{savingPayment() ? copy().savingPurchase : copy().paymentSaveAction}
</Button>
</div>
}
>
<Show when={paymentDraftState()}>
{(draft) => (
<div class="editor-grid">
<Field label={copy().paymentMember}>
<Select
value={draft().memberId}
ariaLabel={copy().paymentMember}
placeholder="—"
options={[{ value: '', label: '—' }, ...memberOptions()]}
onChange={(value) => setPaymentDraft((d) => (d ? { ...d, memberId: value } : d))}
/>
</Field>
<Field label={copy().paymentKind}>
<Select
value={draft().kind}
ariaLabel={copy().paymentKind}
options={kindOptions()}
onChange={(value) =>
setPaymentDraft((d) => (d ? { ...d, kind: value as 'rent' | 'utilities' } : d))
}
/>
</Field>
<Field label={copy().paymentAmount}>
<Input
type="number"
value={draft().amountMajor}
onInput={(e) =>
setPaymentDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d))
}
/>
</Field>
</div>
)}
</Show>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,618 @@
import { Show, For, createSignal } from 'solid-js'
import { ArrowLeft, Globe, User } from 'lucide-solid'
import { useNavigate } from '@solidjs/router'
import { useSession } from '../contexts/session-context'
import { useI18n } from '../contexts/i18n-context'
import { useDashboard } from '../contexts/dashboard-context'
import { Card } from '../components/ui/card'
import { Button } from '../components/ui/button'
import { Badge } from '../components/ui/badge'
import { Select } from '../components/ui/select'
import { Input, Textarea } from '../components/ui/input'
import { Modal } from '../components/ui/dialog'
import { Collapsible } from '../components/ui/collapsible'
import { Field } from '../components/ui/field'
import {
updateMiniAppBillingSettings,
updateMiniAppMemberDisplayName,
updateMiniAppMemberRentWeight,
updateMiniAppMemberStatus,
promoteMiniAppMember,
approveMiniAppPendingMember,
rejectMiniAppPendingMember
} from '../miniapp-api'
import { minorToMajorString } from '../lib/money'
export default function SettingsRoute() {
const navigate = useNavigate()
const {
readySession,
initData,
handleMemberLocaleChange,
displayNameDraft,
setDisplayNameDraft,
savingOwnDisplayName,
handleSaveOwnDisplayName
} = useSession()
const { copy, locale } = useI18n()
const {
effectiveIsAdmin,
adminSettings,
setAdminSettings,
cycleState,
pendingMembers,
setPendingMembers
} = useDashboard()
// ── Profile settings ─────────────────────────────
const [profileEditorOpen, setProfileEditorOpen] = createSignal(false)
// ── Billing settings form ────────────────────────
const [billingEditorOpen, setBillingEditorOpen] = createSignal(false)
const [savingSettings, setSavingSettings] = createSignal(false)
const [billingForm, setBillingForm] = createSignal({
householdName: adminSettings()?.householdName ?? '',
settlementCurrency: adminSettings()?.settings.settlementCurrency ?? 'GEL',
paymentBalanceAdjustmentPolicy:
adminSettings()?.settings.paymentBalanceAdjustmentPolicy ?? 'utilities',
rentAmountMajor: adminSettings()
? minorToMajorString(BigInt(adminSettings()!.settings.rentAmountMinor ?? '0'))
: '',
rentCurrency: adminSettings()?.settings.rentCurrency ?? 'USD',
rentDueDay: adminSettings()?.settings.rentDueDay ?? 20,
rentWarningDay: adminSettings()?.settings.rentWarningDay ?? 17,
utilitiesDueDay: adminSettings()?.settings.utilitiesDueDay ?? 4,
utilitiesReminderDay: adminSettings()?.settings.utilitiesReminderDay ?? 3,
timezone: adminSettings()?.settings.timezone ?? 'Asia/Tbilisi',
assistantContext: adminSettings()?.assistantConfig?.assistantContext ?? '',
assistantTone: adminSettings()?.assistantConfig?.assistantTone ?? ''
})
// ── Pending members ──────────────────────────────
const [approvingId, setApprovingId] = createSignal<string | null>(null)
const [rejectingId, setRejectingId] = createSignal<string | null>(null)
async function handleApprove(telegramUserId: string) {
const data = initData()
if (!data || approvingId()) return
setApprovingId(telegramUserId)
try {
await approveMiniAppPendingMember(data, telegramUserId)
setPendingMembers((prev) => prev.filter((m) => m.telegramUserId !== telegramUserId))
} finally {
setApprovingId(null)
}
}
async function handleReject(telegramUserId: string) {
const data = initData()
if (!data || rejectingId()) return
setRejectingId(telegramUserId)
try {
await rejectMiniAppPendingMember(data, telegramUserId)
setPendingMembers((prev) => prev.filter((m) => m.telegramUserId !== telegramUserId))
} finally {
setRejectingId(null)
}
}
async function handleSaveSettings() {
const data = initData()
if (!data) return
setSavingSettings(true)
try {
const { householdName, settings, assistantConfig } = await updateMiniAppBillingSettings(
data,
billingForm()
)
setAdminSettings((prev) =>
prev ? { ...prev, householdName, settings, assistantConfig } : prev
)
setBillingEditorOpen(false)
} finally {
setSavingSettings(false)
}
}
// ── Member Editing ──────────────────────────────
const [editMemberId, setEditMemberId] = createSignal<string | null>(null)
const [savingMember, setSavingMember] = createSignal(false)
const [editMemberForm, setEditMemberForm] = createSignal({
displayName: '',
rentShareWeight: 1,
status: 'active' as 'active' | 'away' | 'left',
isAdmin: false
})
function openEditMember(
member: NonNullable<ReturnType<typeof adminSettings>>['members'][number]
) {
setEditMemberId(member.id)
setEditMemberForm({
displayName: member.displayName,
rentShareWeight: member.rentShareWeight,
status: member.status,
isAdmin: member.isAdmin
})
}
async function handleSaveMember() {
const data = initData()
const memberId = editMemberId()
const settings = adminSettings()
if (!data || !memberId || !settings) return
setSavingMember(true)
try {
const form = editMemberForm()
const currentMember = settings.members.find((m) => m.id === memberId)
if (!currentMember) return
let updatedMember = currentMember
// Update display name if changed
if (form.displayName !== currentMember.displayName) {
updatedMember = await updateMiniAppMemberDisplayName(data, memberId, form.displayName)
}
// Update rent weight if changed
if (form.rentShareWeight !== currentMember.rentShareWeight) {
updatedMember = await updateMiniAppMemberRentWeight(data, memberId, form.rentShareWeight)
}
// Update status if changed
if (form.status !== currentMember.status) {
updatedMember = await updateMiniAppMemberStatus(data, memberId, form.status)
}
// Promote to admin if requested and not already admin
if (form.isAdmin && !currentMember.isAdmin) {
updatedMember = await promoteMiniAppMember(data, memberId)
}
// Update local state
setAdminSettings((prev) => {
if (!prev) return prev
return {
...prev,
members: prev.members.map((m) => (m.id === memberId ? updatedMember : m))
}
})
setEditMemberId(null)
} finally {
setSavingMember(false)
}
}
return (
<div class="route route--settings">
{/* ── Back + header ────────────────────────────── */}
<div class="settings-header">
<Button variant="ghost" size="sm" class="ui-button--very-left" onClick={() => navigate(-1)}>
<ArrowLeft size={16} />
{copy().closeEditorAction}
</Button>
<h2>{effectiveIsAdmin() ? copy().householdSettingsTitle : copy().residentHouseTitle}</h2>
<p>{effectiveIsAdmin() ? copy().householdSettingsBody : copy().residentHouseBody}</p>
</div>
{/* ── Profile ──────────────────────────────────── */}
<Collapsible title={copy().houseSectionGeneral} body={copy().generalSettingsBody} defaultOpen>
<Card>
<div class="settings-profile">
<div
class="settings-profile__row interactive"
style={{ cursor: 'pointer' }}
onClick={() => setProfileEditorOpen(true)}
>
<User size={16} />
<div>
<span class="settings-profile__label">{copy().displayNameLabel}</span>
<strong>{readySession()?.member.displayName}</strong>
</div>
</div>
<div class="settings-profile__row">
<Globe size={16} />
<div>
<span class="settings-profile__label">{copy().language}</span>
<div class="locale-switch locale-switch--compact">
<div class="locale-switch__buttons">
<button
classList={{ 'is-active': locale() === 'en' }}
type="button"
onClick={() => void handleMemberLocaleChange('en')}
>
EN
</button>
<button
classList={{ 'is-active': locale() === 'ru' }}
type="button"
onClick={() => void handleMemberLocaleChange('ru')}
>
RU
</button>
</div>
</div>
</div>
</div>
</div>
</Card>
</Collapsible>
{/* ── Admin sections ───────────────────────────── */}
<Show when={effectiveIsAdmin()}>
{/* Billing settings */}
<Collapsible title={copy().houseSectionBilling} body={copy().billingSettingsTitle}>
<Card>
<Show when={adminSettings()}>
{(settings) => (
<div class="settings-billing-summary">
<div class="settings-row">
<span>{copy().householdNameLabel}</span>
<strong>{settings().householdName}</strong>
</div>
<div class="settings-row">
<span>{copy().settlementCurrency}</span>
<Badge variant="muted">{settings().settings.settlementCurrency}</Badge>
</div>
<div class="settings-row">
<span>{copy().defaultRentAmount}</span>
<strong>
{minorToMajorString(BigInt(settings().settings.rentAmountMinor ?? '0'))}{' '}
{settings().settings.rentCurrency}
</strong>
</div>
<div class="settings-row">
<span>{copy().timezone}</span>
<Badge variant="muted">{settings().settings.timezone}</Badge>
</div>
<Button variant="secondary" onClick={() => setBillingEditorOpen(true)}>
{copy().manageSettingsAction}
</Button>
</div>
)}
</Show>
</Card>
</Collapsible>
{/* Billing cycle */}
<Collapsible title={copy().billingCycleTitle}>
<Card>
<Show
when={cycleState()?.cycle}
fallback={<p class="empty-state">{copy().billingCycleEmpty}</p>}
>
{(cycle) => (
<div class="settings-billing-summary">
<div class="settings-row">
<span>{copy().billingCyclePeriod}</span>
<Badge variant="accent">{cycle().period}</Badge>
</div>
<div class="settings-row">
<span>{copy().currencyLabel}</span>
<Badge variant="muted">{cycle().currency}</Badge>
</div>
</div>
)}
</Show>
</Card>
</Collapsible>
{/* Pending members */}
<Collapsible title={copy().pendingMembersTitle} body={copy().pendingMembersBody}>
<Show
when={pendingMembers().length > 0}
fallback={<p class="empty-state">{copy().pendingMembersEmpty}</p>}
>
<div class="pending-list">
<For each={pendingMembers()}>
{(member) => (
<Card>
<div class="pending-member-row">
<div>
<strong>{member.displayName}</strong>
<Show when={member.username}>
{(username) => (
<span class="pending-member-row__handle">@{username()}</span>
)}
</Show>
</div>
<div class="pending-member-actions">
<Button
variant="ghost"
size="sm"
loading={rejectingId() === member.telegramUserId}
disabled={approvingId() === member.telegramUserId}
onClick={() => void handleReject(member.telegramUserId)}
>
{rejectingId() === member.telegramUserId
? copy().rejectingMember
: copy().rejectMemberAction}
</Button>
<Button
variant="primary"
size="sm"
loading={approvingId() === member.telegramUserId}
disabled={rejectingId() === member.telegramUserId}
onClick={() => void handleApprove(member.telegramUserId)}
>
{approvingId() === member.telegramUserId
? copy().approvingMember
: copy().approveMemberAction}
</Button>
</div>
</div>
</Card>
)}
</For>
</div>
</Show>
</Collapsible>
{/* Members */}
<Collapsible title={copy().houseSectionMembers} body={copy().membersBody}>
<Show when={adminSettings()?.members}>
{(members) => (
<div class="members-list">
<For each={members()}>
{(member) => (
<Card>
<div
class="member-row interactive"
style={{ cursor: 'pointer' }}
onClick={() => openEditMember(member)}
>
<div class="member-row__info">
<strong>{member.displayName}</strong>
<div class="member-row__badges">
<Badge variant={member.isAdmin ? 'accent' : 'muted'}>
{member.isAdmin ? copy().adminTag : copy().residentTag}
</Badge>
<Badge variant="muted">
{member.status === 'active'
? copy().memberStatusActive
: member.status === 'away'
? copy().memberStatusAway
: copy().memberStatusLeft}
</Badge>
</div>
</div>
<div class="member-row__weight">
<span>
{copy().rentWeightLabel}: {member.rentShareWeight}
</span>
</div>
</div>
</Card>
)}
</For>
</div>
)}
</Show>
</Collapsible>
{/* Topic bindings */}
<Collapsible title={copy().houseSectionTopics} body={copy().topicBindingsBody}>
<Show when={adminSettings()?.topics}>
{(topics) => (
<div class="topics-list">
<For each={topics()}>
{(topic) => {
const roleLabel = () => {
const labels: Record<string, string> = {
purchase: copy().topicPurchase,
feedback: copy().topicFeedback,
reminders: copy().topicReminders,
payments: copy().topicPayments
}
return labels[topic.role] ?? topic.role
}
return (
<div class="topic-row">
<span>{roleLabel()}</span>
<Badge variant={topic.telegramThreadId ? 'accent' : 'muted'}>
{topic.telegramThreadId ? copy().topicBound : copy().topicUnbound}
</Badge>
</div>
)
}}
</For>
</div>
)}
</Show>
</Collapsible>
</Show>
{/* ── Billing Settings Editor Modal ────────────── */}
<Modal
open={billingEditorOpen()}
title={copy().billingSettingsTitle}
description={copy().billingSettingsEditorBody}
closeLabel={copy().closeEditorAction}
onClose={() => setBillingEditorOpen(false)}
footer={
<div class="modal-action-row">
<Button variant="ghost" onClick={() => setBillingEditorOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
loading={savingSettings()}
onClick={() => void handleSaveSettings()}
>
{savingSettings() ? copy().savingSettings : copy().saveSettingsAction}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().householdNameLabel} hint={copy().householdNameHint} wide>
<Input
value={billingForm().householdName}
onInput={(e) =>
setBillingForm((f) => ({ ...f, householdName: e.currentTarget.value }))
}
/>
</Field>
<Field label={copy().settlementCurrency}>
<Select
value={billingForm().settlementCurrency}
ariaLabel={copy().settlementCurrency}
options={[
{ value: 'GEL', label: 'GEL' },
{ value: 'USD', label: 'USD' }
]}
onChange={(value) =>
setBillingForm((f) => ({ ...f, settlementCurrency: value as 'USD' | 'GEL' }))
}
/>
</Field>
<Field label={copy().defaultRentAmount}>
<Input
type="number"
value={billingForm().rentAmountMajor}
onInput={(e) =>
setBillingForm((f) => ({ ...f, rentAmountMajor: e.currentTarget.value }))
}
/>
</Field>
<Field label={copy().timezone} hint={copy().timezoneHint}>
<Input
value={billingForm().timezone}
onInput={(e) => setBillingForm((f) => ({ ...f, timezone: e.currentTarget.value }))}
/>
</Field>
<Field label={copy().assistantToneLabel} hint={copy().assistantTonePlaceholder}>
<Input
value={billingForm().assistantTone}
onInput={(e) =>
setBillingForm((f) => ({ ...f, assistantTone: e.currentTarget.value }))
}
/>
</Field>
<Field label={copy().assistantContextLabel} wide>
<Textarea
value={billingForm().assistantContext}
placeholder={copy().assistantContextPlaceholder}
onInput={(e) =>
setBillingForm((f) => ({ ...f, assistantContext: e.currentTarget.value }))
}
/>
</Field>
</div>
</Modal>
{/* ── Member Editor Modal ────────────── */}
<Modal
open={!!editMemberId()}
title={copy().inspectMemberTitle}
description={copy().memberEditorBody}
closeLabel={copy().closeEditorAction}
onClose={() => setEditMemberId(null)}
footer={
<div class="modal-action-row">
<Button variant="ghost" onClick={() => setEditMemberId(null)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
loading={savingMember()}
onClick={() => void handleSaveMember()}
>
{copy().saveMemberChangesAction}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().displayNameLabel} wide>
<Input
value={editMemberForm().displayName}
onInput={(e) =>
setEditMemberForm((f) => ({ ...f, displayName: e.currentTarget.value }))
}
/>
</Field>
<Field label={copy().rentWeightLabel}>
<Input
type="number"
step="0.1"
value={String(editMemberForm().rentShareWeight)}
onInput={(e) =>
setEditMemberForm((f) => ({
...f,
rentShareWeight: parseFloat(e.currentTarget.value) || 0
}))
}
/>
</Field>
<Field label={copy().memberStatusLabel}>
<Select
value={editMemberForm().status}
ariaLabel={copy().memberStatusLabel}
options={[
{ value: 'active', label: copy().memberStatusActive },
{ value: 'away', label: copy().memberStatusAway },
{ value: 'left', label: copy().memberStatusLeft }
]}
onChange={(value) =>
setEditMemberForm((f) => ({ ...f, status: value as 'active' | 'away' | 'left' }))
}
/>
</Field>
<Show when={!editMemberForm().isAdmin}>
<Field label="Admin Access">
<Button
variant="secondary"
onClick={() => setEditMemberForm((f) => ({ ...f, isAdmin: true }))}
>
{copy().promoteAdminAction}
</Button>
</Field>
</Show>
</div>
</Modal>
{/* ── Own Profile Editor Modal ───────── */}
<Modal
open={profileEditorOpen()}
title={copy().displayNameLabel}
description={copy().profileEditorBody}
closeLabel={copy().closeEditorAction}
onClose={() => setProfileEditorOpen(false)}
footer={
<div class="modal-action-row modal-action-row--single">
<Button variant="ghost" onClick={() => setProfileEditorOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
disabled={
savingOwnDisplayName() ||
displayNameDraft().trim().length < 2 ||
displayNameDraft().trim() === readySession()?.member.displayName
}
loading={savingOwnDisplayName()}
onClick={async () => {
await handleSaveOwnDisplayName()
setProfileEditorOpen(false)
}}
>
{savingOwnDisplayName() ? copy().savingDisplayName : copy().saveDisplayName}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().displayNameLabel} hint={copy().displayNameHint} wide>
<Input
value={displayNameDraft()}
onInput={(event) => setDisplayNameDraft(event.currentTarget.value)}
/>
</Field>
</div>
</Modal>
</div>
)
}