From e07dfeadf5bc114af1fc176e8d5a7619697fd4f5 Mon Sep 17 00:00:00 2001 From: whekin Date: Wed, 11 Mar 2026 17:22:48 +0400 Subject: [PATCH] refactor(miniapp): compact finance and admin editors --- apps/miniapp/src/App.tsx | 2525 ++++++++++++++++------------ apps/miniapp/src/components/ui.tsx | 119 ++ apps/miniapp/src/i18n.ts | 46 + apps/miniapp/src/index.css | 253 ++- 4 files changed, 1831 insertions(+), 1112 deletions(-) create mode 100644 apps/miniapp/src/components/ui.tsx diff --git a/apps/miniapp/src/App.tsx b/apps/miniapp/src/App.tsx index adbb127..3c1b312 100644 --- a/apps/miniapp/src/App.tsx +++ b/apps/miniapp/src/App.tsx @@ -35,6 +35,7 @@ import { type MiniAppDashboard, type MiniAppPendingMember } from './miniapp-api' +import { Button, Field, IconButton, Modal } from './components/ui' import { getTelegramWebApp } from './telegram-webapp' type SessionState = @@ -297,6 +298,15 @@ function paymentDrafts( ) } +function paymentDraftForEntry(entry: MiniAppDashboard['ledger'][number]): PaymentDraft { + return { + memberId: entry.memberId ?? '', + kind: entry.paymentKind ?? 'rent', + amountMajor: entry.amountMajor, + currency: entry.currency + } +} + function App() { const [locale, setLocale] = createSignal('en') const [session, setSession] = createSignal({ @@ -350,6 +360,15 @@ function App() { const [deletingPurchaseId, setDeletingPurchaseId] = createSignal(null) const [savingPaymentId, setSavingPaymentId] = createSignal(null) const [deletingPaymentId, setDeletingPaymentId] = createSignal(null) + const [editingPurchaseId, setEditingPurchaseId] = createSignal(null) + const [editingPaymentId, setEditingPaymentId] = createSignal(null) + const [editingUtilityBillId, setEditingUtilityBillId] = createSignal(null) + const [editingMemberId, setEditingMemberId] = createSignal(null) + const [editingCategorySlug, setEditingCategorySlug] = createSignal(null) + const [billingSettingsOpen, setBillingSettingsOpen] = createSignal(false) + const [cycleRentOpen, setCycleRentOpen] = createSignal(false) + const [addingUtilityBillOpen, setAddingUtilityBillOpen] = createSignal(false) + const [addingPaymentOpen, setAddingPaymentOpen] = createSignal(false) const [addingPayment, setAddingPayment] = createSignal(false) const [billingForm, setBillingForm] = createSignal({ settlementCurrency: 'GEL' as 'USD' | 'GEL', @@ -410,6 +429,23 @@ function App() { const paymentLedger = createMemo(() => (dashboard()?.ledger ?? []).filter((entry) => entry.kind === 'payment') ) + const editingPurchaseEntry = createMemo( + () => purchaseLedger().find((entry) => entry.id === editingPurchaseId()) ?? null + ) + const editingPaymentEntry = createMemo( + () => paymentLedger().find((entry) => entry.id === editingPaymentId()) ?? null + ) + const editingUtilityBill = createMemo( + () => cycleState()?.utilityBills.find((bill) => bill.id === editingUtilityBillId()) ?? null + ) + const editingMember = createMemo( + () => adminSettings()?.members.find((member) => member.id === editingMemberId()) ?? null + ) + const editingCategory = createMemo( + () => + adminSettings()?.categories.find((category) => category.slug === editingCategorySlug()) ?? + null + ) const utilityTotalMajor = createMemo(() => minorToMajorString( utilityLedger().reduce((sum, entry) => sum + majorStringToMinor(entry.displayAmountMajor), 0n) @@ -563,6 +599,34 @@ function App() { : copy().paymentLedgerRent } + function purchaseParticipantSummary(entry: MiniAppDashboard['ledger'][number]): string { + if (entry.kind !== 'purchase') { + return '' + } + + const includedCount = + entry.purchaseParticipants?.filter((participant) => participant.included).length ?? 0 + const splitLabel = + entry.purchaseSplitMode === 'custom_amounts' + ? copy().purchaseSplitCustom + : copy().purchaseSplitEqual + + return `${includedCount} ${copy().participantsLabel} · ${splitLabel}` + } + + function paymentMemberName(entry: MiniAppDashboard['ledger'][number]): string { + if (!entry.memberId) { + return entry.actorDisplayName ?? copy().ledgerActorFallback + } + + return ( + adminSettings()?.members.find((member) => member.id === entry.memberId)?.displayName ?? + dashboard()?.members.find((member) => member.memberId === entry.memberId)?.displayName ?? + entry.actorDisplayName ?? + copy().ledgerActorFallback + ) + } + function topicRoleLabel(role: 'purchase' | 'feedback' | 'reminders' | 'payments'): string { switch (role) { case 'purchase': @@ -679,6 +743,73 @@ function App() { })) } + function updatePurchaseDraft( + purchaseId: string, + entry: MiniAppDashboard['ledger'][number], + update: (draft: PurchaseDraft) => PurchaseDraft + ) { + setPurchaseDraftMap((current) => { + const draft = current[purchaseId] ?? purchaseDraftForEntry(entry) + return { + ...current, + [purchaseId]: update(draft) + } + }) + } + + function updatePaymentDraft( + paymentId: string, + entry: MiniAppDashboard['ledger'][number], + update: (draft: PaymentDraft) => PaymentDraft + ) { + setPaymentDraftMap((current) => { + const draft = current[paymentId] ?? paymentDraftForEntry(entry) + return { + ...current, + [paymentId]: update(draft) + } + }) + } + + function togglePurchaseParticipant( + purchaseId: string, + entry: MiniAppDashboard['ledger'][number], + memberId: string, + included: boolean + ) { + updatePurchaseDraft(purchaseId, entry, (draft) => ({ + ...draft, + participants: included + ? [ + ...draft.participants.filter((participant) => participant.memberId !== memberId), + { + memberId, + shareAmountMajor: '' + } + ] + : draft.participants.filter((participant) => participant.memberId !== memberId) + })) + } + + function updateUtilityBillDraft( + billId: string, + bill: MiniAppAdminCycleState['utilityBills'][number], + update: (draft: UtilityBillDraft) => UtilityBillDraft + ) { + setUtilityBillDrafts((current) => { + const draft = current[billId] ?? { + billName: bill.billName, + amountMajor: minorToMajorString(BigInt(bill.amountMinor)), + currency: bill.currency + } + + return { + ...current, + [billId]: update(draft) + } + }) + } + async function loadDashboard(initData: string) { try { const nextDashboard = await fetchMiniAppDashboard(initData) @@ -1205,6 +1336,7 @@ function App() { rentCurrency: settings.rentCurrency, utilityCurrency: settings.settlementCurrency })) + setBillingSettingsOpen(false) } finally { setSavingBillingSettings(false) } @@ -1231,6 +1363,7 @@ function App() { period: state.cycle?.period ?? current.period, utilityCurrency: billingForm().settlementCurrency })) + setCycleRentOpen(false) } finally { setOpeningCycle(false) } @@ -1249,6 +1382,7 @@ function App() { const state = await closeMiniAppBillingCycle(initData, cycleState()?.cycle?.period) setCycleState(state) setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) + setCycleRentOpen(false) } finally { setClosingCycle(false) } @@ -1275,6 +1409,7 @@ function App() { }) setCycleState(state) setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) + setCycleRentOpen(false) } finally { setSavingCycleRent(false) } @@ -1310,6 +1445,7 @@ function App() { ...current, utilityAmountMajor: '' })) + setAddingUtilityBillOpen(false) } finally { setSavingUtilityBill(false) } @@ -1342,6 +1478,7 @@ function App() { }) setCycleState(state) setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) + setEditingUtilityBillId(null) } finally { setSavingUtilityBillId(null) } @@ -1360,6 +1497,7 @@ function App() { const state = await deleteMiniAppUtilityBill(initData, billId) setCycleState(state) setUtilityBillDrafts(cycleUtilityBillDrafts(state.utilityBills)) + setEditingUtilityBillId((current) => (current === billId ? null : current)) } finally { setDeletingUtilityBillId(null) } @@ -1412,6 +1550,7 @@ function App() { } }) await refreshHouseholdData(initData, true) + setEditingPurchaseId(null) } finally { setSavingPurchaseId(null) } @@ -1429,6 +1568,7 @@ function App() { try { await deleteMiniAppPurchase(initData, purchaseId) await refreshHouseholdData(initData, true) + setEditingPurchaseId((current) => (current === purchaseId ? null : current)) } finally { setDeletingPurchaseId(null) } @@ -1457,6 +1597,7 @@ function App() { amountMajor: '' })) await refreshHouseholdData(initData, true) + setAddingPaymentOpen(false) } finally { setAddingPayment(false) } @@ -1488,6 +1629,7 @@ function App() { currency: draft.currency }) await refreshHouseholdData(initData, true) + setEditingPaymentId(null) } finally { setSavingPaymentId(null) } @@ -1505,6 +1647,7 @@ function App() { try { await deleteMiniAppPayment(initData, paymentId) await refreshHouseholdData(initData, true) + setEditingPaymentId((current) => (current === paymentId ? null : current)) } finally { setDeletingPaymentId(null) } @@ -1544,6 +1687,8 @@ function App() { if (!input.slug) { setNewCategoryName('') } + + setEditingCategorySlug(null) } finally { setSavingCategorySlug(null) } @@ -1572,6 +1717,7 @@ function App() { ...current, [member.id]: String(member.rentShareWeight) })) + setEditingMemberId(null) } finally { setPromotingMemberId(null) } @@ -1607,6 +1753,7 @@ function App() { ...current, [member.id]: String(member.rentShareWeight) })) + setEditingMemberId(null) } finally { setSavingRentWeightMemberId(null) } @@ -1643,6 +1790,7 @@ function App() { resolvedMemberAbsencePolicy(member.id, member.status).policy ?? defaultAbsencePolicyForStatus(member.status) })) + setEditingMemberId(null) } finally { setSavingMemberStatusId(null) } @@ -1691,6 +1839,7 @@ function App() { ...current, [memberId]: savedPolicy.policy })) + setEditingMemberId(null) } finally { setSavingMemberAbsencePolicyId(null) } @@ -1978,250 +2127,236 @@ function App() { ) : (
{purchaseLedger().map((entry) => ( -
-
- - {entry.actorDisplayName ?? copy().ledgerActorFallback} - - {entry.occurredAt?.slice(0, 10) ?? '—'} -
- {readySession()?.member.isAdmin ? ( - <> -
- - - -
-
-
- {copy().purchaseSplitTitle} - - {purchaseDraftMap()[entry.id]?.splitMode === 'custom_amounts' - ? copy().purchaseSplitCustom - : copy().purchaseSplitEqual} - -
-
- -
-
- {(adminSettings()?.members ?? []).map((member) => { - const draft = - purchaseDraftMap()[entry.id] ?? purchaseDraftForEntry(entry) - const included = draft.participants.some( - (participant) => participant.memberId === member.id - ) - - return ( -
-
- {member.displayName} - - {purchaseSplitPreview(entry.id).find( - (participant) => participant.memberId === member.id - )?.amountMajor ?? '0.00'}{' '} - {draft.currency} - -
-
- - - - -
-
- ) - })} -
-
-
- - -
- - ) : ( - <> -

{ledgerPrimaryAmount(entry)}

+
+
+
+ {entry.title} + {entry.occurredAt?.slice(0, 10) ?? '—'} +
+

{entry.actorDisplayName ?? copy().ledgerActorFallback}

+
+ {ledgerPrimaryAmount(entry)} - {(secondary) =>

{secondary()}

} + {(secondary) => ( + {secondary()} + )}
-

{entry.title}

- - )} + + + {purchaseParticipantSummary(entry)} + + +
+
+ +
+ setEditingPurchaseId(entry.id)} + > + ... + +
+
))}
)} + setEditingPurchaseId(null)} + footer={(() => { + const entry = editingPurchaseEntry() + + if (!entry) { + return null + } + + return ( + + ) + })()} + > + {(() => { + const entry = editingPurchaseEntry() + + if (!entry) { + return null + } + + const draft = purchaseDraftMap()[entry.id] ?? purchaseDraftForEntry(entry) + const splitPreview = purchaseSplitPreview(entry.id) + + return ( + <> +
+ + + updatePurchaseDraft(entry.id, entry, (current) => ({ + ...current, + description: event.currentTarget.value + })) + } + /> + + + + updatePurchaseDraft(entry.id, entry, (current) => ({ + ...current, + amountMajor: event.currentTarget.value + })) + } + /> + + + + +
+ +
+
+ {copy().purchaseSplitTitle} + + {draft.splitMode === 'custom_amounts' + ? copy().purchaseSplitCustom + : copy().purchaseSplitEqual} + +
+
+ + + +
+
+ {(adminSettings()?.members ?? []).map((member) => { + const included = draft.participants.some( + (participant) => participant.memberId === member.id + ) + const previewAmount = + splitPreview.find( + (participant) => participant.memberId === member.id + )?.amountMajor ?? '0.00' + + return ( +
+
+ {member.displayName} + + {previewAmount} {draft.currency} + +
+
+ + + + participant.memberId === member.id + )?.shareAmountMajor ?? '' + } + onInput={(event) => + updatePurchaseDraft(entry.id, entry, (current) => ({ + ...current, + participants: current.participants.map( + (participant) => + participant.memberId === member.id + ? { + ...participant, + shareAmountMajor: + event.currentTarget.value + } + : participant + ) + })) + } + /> + + +
+
+ ) + })} +
+
+ + ) + })()} +
{copy().utilityLedgerTitle} @@ -2251,233 +2386,238 @@ function App() {

{copy().paymentsAdminBody}

-
- - - - +
+
- {paymentLedger().length === 0 ? (

{copy().paymentsEmpty}

) : (
{paymentLedger().map((entry) => ( -
-
- - {entry.actorDisplayName ?? copy().ledgerActorFallback} - - {entry.occurredAt?.slice(0, 10) ?? '—'} -
- {readySession()?.member.isAdmin ? ( - <> -
- - - - -
-
- - -
- - ) : ( - <> -

{ledgerPrimaryAmount(entry)}

+
+
+
+ {paymentMemberName(entry)} + {entry.occurredAt?.slice(0, 10) ?? '—'} +
+

{ledgerTitle(entry)}

+
+ {ledgerPrimaryAmount(entry)} - {(secondary) =>

{secondary()}

} + {(secondary) => ( + {secondary()} + )}
-

{ledgerTitle(entry)}

- - )} +
+
+ +
+ setEditingPaymentId(entry.id)} + > + ... + +
+
))}
)}
+ setAddingPaymentOpen(false)} + footer={ + + } + > +
+ + + + + + + + + setPaymentForm((current) => ({ + ...current, + amountMajor: event.currentTarget.value + })) + } + /> + + + + +
+
+ setEditingPaymentId(null)} + footer={(() => { + const entry = editingPaymentEntry() + + if (!entry) { + return null + } + + return ( + + ) + })()} + > + {(() => { + const entry = editingPaymentEntry() + + if (!entry) { + return null + } + + const draft = paymentDraftMap()[entry.id] ?? paymentDraftForEntry(entry) + + return ( +
+ + + + + + + + + updatePaymentDraft(entry.id, entry, (current) => ({ + ...current, + amountMajor: event.currentTarget.value + })) + } + /> + + + + +
+ ) + })()} +
)} /> @@ -2545,110 +2685,39 @@ function App() { {copy().billingCycleTitle} {cycleState()?.cycle?.period ?? copy().billingCycleEmpty} - {cycleState()?.cycle ? ( - <> -

- {copy().billingCycleStatus.replace( +

+ {cycleState()?.cycle + ? copy().billingCycleStatus.replace( '{currency}', cycleState()?.cycle?.currency ?? billingForm().settlementCurrency - )} + ) + : copy().billingCycleOpenHint} +

+ + {(data) => ( +

+ {copy().shareRent}: {data().rentSourceAmountMajor}{' '} + {data().rentSourceCurrency} + {data().rentSourceCurrency !== data().currency + ? ` -> ${data().rentDisplayAmountMajor} ${data().currency}` + : ''}

- - {(data) => ( -

- {copy().shareRent}: {data().rentSourceAmountMajor}{' '} - {data().rentSourceCurrency} - {data().rentSourceCurrency !== data().currency - ? ` -> ${data().rentDisplayAmountMajor} ${data().currency}` - : ''} -

- )} -
-
- - -
-
- - -
- - ) : ( - <> -

{copy().billingCycleOpenHint}

-
- -
- {copy().settlementCurrency} -
- {billingForm().settlementCurrency} -
-
-
- + + - - )} + {closingCycle() ? copy().closingCycle : copy().closeCycleAction} + + +
@@ -2656,153 +2725,27 @@ function App() { {copy().billingSettingsTitle} {billingForm().settlementCurrency} -
- - - - - - - - - +

+ {billingForm().paymentBalanceAdjustmentPolicy === 'utilities' + ? copy().paymentBalanceAdjustmentUtilities + : billingForm().paymentBalanceAdjustmentPolicy === 'rent' + ? copy().paymentBalanceAdjustmentRent + : copy().paymentBalanceAdjustmentSeparate} +

+
+ + {copy().rentAmount}: {billingForm().rentAmountMajor || '—'}{' '} + {billingForm().rentCurrency} + + + {copy().timezone}: {billingForm().timezone} + +
+
+
-
@@ -2835,6 +2778,243 @@ function App() {
+ setCycleRentOpen(false)} + footer={ + cycleState()?.cycle ? ( + + ) : ( + + ) + } + > + {cycleState()?.cycle ? ( +
+ + + setCycleForm((current) => ({ + ...current, + rentAmountMajor: event.currentTarget.value + })) + } + /> + + + + +
+ ) : ( +
+ + + setCycleForm((current) => ({ + ...current, + period: event.currentTarget.value + })) + } + /> + + +
{billingForm().settlementCurrency}
+
+
+ )} +
+ setBillingSettingsOpen(false)} + footer={ + + } + > +
+ + + + + + + + + setBillingForm((current) => ({ + ...current, + rentAmountMajor: event.currentTarget.value + })) + } + /> + + + + + + + setBillingForm((current) => ({ + ...current, + rentDueDay: Number(event.currentTarget.value) + })) + } + /> + + + + setBillingForm((current) => ({ + ...current, + rentWarningDay: Number(event.currentTarget.value) + })) + } + /> + + + + setBillingForm((current) => ({ + ...current, + utilitiesDueDay: Number(event.currentTarget.value) + })) + } + /> + + + + setBillingForm((current) => ({ + ...current, + utilitiesReminderDay: Number(event.currentTarget.value) + })) + } + /> + + + + setBillingForm((current) => ({ + ...current, + timezone: event.currentTarget.value + })) + } + /> + +
+
@@ -2852,159 +3032,35 @@ function App() { {copy().utilityLedgerTitle} {cycleForm().utilityCurrency} -
- - - +

{copy().utilityBillsEditorBody}

+
+
- -
+
{cycleState()?.utilityBills.length ? ( cycleState()?.utilityBills.map((bill) => ( -
-
- - {utilityBillDrafts()[bill.id]?.billName ?? bill.billName} - - {bill.createdAt.slice(0, 10)} -
-
- - - +
+
+
+ {bill.billName} + {bill.createdAt.slice(0, 10)} +
+

{copy().utilityCategoryName}

+
+ + {minorToMajorString(BigInt(bill.amountMinor))} {bill.currency} + +
-
- - + ... +
)) @@ -3019,120 +3075,329 @@ function App() { {copy().utilityCategoriesTitle} {String(adminSettings()?.categories.length ?? 0)} -
+

{copy().utilityCategoriesBody}

+
+ +
+
{adminSettings()?.categories.map((category) => ( -
-
- {category.name} - {category.isActive ? 'ON' : 'OFF'} -
-
- - + {category.isActive ? 'ON' : 'OFF'} + +
+
+
+ setEditingCategorySlug(category.slug)} + > + ... +
-
))} -
- - -
+ setAddingUtilityBillOpen(false)} + footer={ + + } + > +
+ + + + + + setCycleForm((current) => ({ + ...current, + utilityAmountMajor: event.currentTarget.value + })) + } + /> + + + + +
+
+ setEditingUtilityBillId(null)} + footer={(() => { + const bill = editingUtilityBill() + if (!bill) { + return null + } + return ( + + ) + })()} + > + {(() => { + const bill = editingUtilityBill() + if (!bill) { + return null + } + const draft = utilityBillDrafts()[bill.id] ?? { + billName: bill.billName, + amountMajor: minorToMajorString(BigInt(bill.amountMinor)), + currency: bill.currency + } + return ( +
+ + + updateUtilityBillDraft(bill.id, bill, (current) => ({ + ...current, + billName: event.currentTarget.value + })) + } + /> + + + + updateUtilityBillDraft(bill.id, bill, (current) => ({ + ...current, + amountMajor: event.currentTarget.value + })) + } + /> + + + + +
+ ) + })()} +
+ setEditingCategorySlug(null)} + footer={(() => { + const category = editingCategory() + const isNew = editingCategorySlug() === '__new__' + return ( + + ) + })()} + > + {editingCategorySlug() === '__new__' ? ( +
+ + setNewCategoryName(event.currentTarget.value)} + /> + +
+ ) : ( + (() => { + const category = editingCategory() + if (!category) { + return null + } + return ( +
+ + + setAdminSettings((current) => + current + ? { + ...current, + categories: current.categories.map((item) => + item.slug === category.slug + ? { + ...item, + name: event.currentTarget.value + } + : item + ) + } + : current + ) + } + /> + + + + +
+ ) + })() + )} +
@@ -3150,168 +3415,40 @@ function App() { {copy().adminsTitle} {String(adminSettings()?.members.length ?? 0)} -
+
{adminSettings()?.members.map((member) => ( -
-
- {member.displayName} - - {member.isAdmin ? copy().adminTag : copy().residentTag} - {` · ${memberStatusLabel(member.status)}`} - -
-
- - - - +
+
+
+ {member.displayName} + {member.isAdmin ? copy().adminTag : copy().residentTag} +
+

{memberStatusLabel(member.status)}

+
+ + {copy().rentWeightLabel}: {member.rentShareWeight} + + + {resolvedMemberAbsencePolicy(member.id, member.status).policy === + 'away_rent_only' + ? copy().absencePolicyAwayRentOnly + : resolvedMemberAbsencePolicy(member.id, member.status).policy === + 'away_rent_and_utilities' + ? copy().absencePolicyAwayRentAndUtilities + : resolvedMemberAbsencePolicy(member.id, member.status) + .policy === 'inactive' + ? copy().absencePolicyInactive + : copy().absencePolicyResident} + +
-
- - - - - {!member.isAdmin ? ( - - ) : null} + ... +
))} @@ -3355,6 +3492,176 @@ function App() { )}
+ setEditingMemberId(null)} + footer={(() => { + const member = editingMember() + if (!member) { + return null + } + + return ( + + ) + })()} + > + {(() => { + const member = editingMember() + if (!member) { + return null + } + + return ( +
+ + + setMemberDisplayNameDrafts((current) => ({ + ...current, + [member.id]: event.currentTarget.value + })) + } + /> + + + + + + + + + + setRentWeightDrafts((current) => ({ + ...current, + [member.id]: event.currentTarget.value + })) + } + /> + +
+ ) + })()} +
diff --git a/apps/miniapp/src/components/ui.tsx b/apps/miniapp/src/components/ui.tsx new file mode 100644 index 0000000..f7222c4 --- /dev/null +++ b/apps/miniapp/src/components/ui.tsx @@ -0,0 +1,119 @@ +import { Show, createEffect, onCleanup, type JSX, type ParentProps } from 'solid-js' + +type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost' | 'icon' + +export function Button( + props: ParentProps<{ + type?: 'button' | 'submit' | 'reset' + variant?: ButtonVariant + class?: string + disabled?: boolean + onClick?: JSX.EventHandlerUnion + }> +) { + return ( + + ) +} + +export function IconButton( + props: ParentProps<{ + label: string + class?: string + disabled?: boolean + onClick?: JSX.EventHandlerUnion + }> +) { + const maybeClass = props.class ? { class: props.class } : {} + const maybeDisabled = props.disabled !== undefined ? { disabled: props.disabled } : {} + const maybeOnClick = props.onClick ? { onClick: props.onClick } : {} + + return ( + + ) +} + +export function Field( + props: ParentProps<{ + label: string + hint?: string + wide?: boolean + class?: string + }> +) { + return ( + + ) +} + +export function Modal( + props: ParentProps<{ + open: boolean + title: string + description?: string + closeLabel: string + footer?: JSX.Element + onClose: () => void + }> +) { + createEffect(() => { + if (!props.open) { + return + } + + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + props.onClose() + } + } + + window.addEventListener('keydown', onKeyDown) + onCleanup(() => window.removeEventListener('keydown', onKeyDown)) + }) + + return ( + + + + ) +} diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 24965bd..6bd0a35 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -67,6 +67,7 @@ export const dictionary = { 'Purchase contributions will appear here after shared buys are logged.', purchaseShareLabel: 'Share', purchaseTotalLabel: 'Total shared buys', + participantsLabel: 'participants', purchasesTitle: 'Shared purchases', purchasesEmpty: 'No shared purchases recorded for this cycle yet.', utilityLedgerTitle: 'Utility bills', @@ -90,21 +91,28 @@ export const dictionary = { purchaseSplitEqual: 'Equal split', purchaseSplitCustom: 'Custom amounts', purchaseParticipantLabel: 'Participates', + participantIncluded: 'Included', + participantExcluded: 'Excluded', purchaseCustomShareLabel: 'Custom amount', + purchaseEditorBody: 'Review the purchase details and adjust the split only when needed.', paymentsAdminTitle: 'Payments', paymentsAdminBody: 'Add, fix, or remove payment records for the current cycle.', paymentsAddAction: 'Add payment', addingPayment: 'Adding payment…', + paymentCreateBody: 'Create a payment record in a focused editor instead of a long inline form.', paymentKind: 'Payment kind', paymentAmount: 'Payment amount', paymentMember: 'Member', paymentSaveAction: 'Save payment', paymentDeleteAction: 'Delete payment', + paymentEditorBody: 'Review the payment record in one focused editor.', deletingPayment: 'Deleting payment…', purchaseSaveAction: 'Save purchase', purchaseDeleteAction: 'Delete purchase', deletingPurchase: 'Deleting purchase…', savingPurchase: 'Saving purchase…', + editEntryAction: 'Edit entry', + closeEditorAction: 'Close', householdSettingsTitle: 'Household settings', householdSettingsBody: 'Control household defaults and approve roommates who requested access.', topicBindingsTitle: 'Topic bindings', @@ -127,6 +135,9 @@ export const dictionary = { billingCycleStatus: 'Current cycle currency: {currency}', billingCycleOpenHint: 'Open a cycle before entering rent and utility bills.', billingCyclePeriod: 'Cycle period', + manageCycleAction: 'Manage cycle', + cycleEditorBody: + 'Keep the billing cycle controls in one focused editor instead of a long page.', openCycleAction: 'Open cycle', openingCycle: 'Opening cycle…', closeCycleAction: 'Close cycle', @@ -147,19 +158,31 @@ export const dictionary = { utilitiesDueDay: 'Utilities due day', utilitiesReminderDay: 'Utilities reminder day', timezone: 'Timezone', + manageSettingsAction: 'Manage settings', + billingSettingsEditorBody: 'Household billing defaults live here when you need to change them.', saveSettingsAction: 'Save settings', savingSettings: 'Saving settings…', utilityCategoriesTitle: 'Utility categories', utilityCategoriesBody: 'Manage the categories admins use for monthly utility entry.', utilityCategoryName: 'Category name', utilityCategoryActive: 'Active', + utilityBillsEditorBody: + 'Keep utility bills short in the list and edit the details only when needed.', + utilityBillCreateBody: 'Add a utility bill in a focused editor.', + utilityBillEditorBody: 'Adjust utility bill details here.', + editUtilityBillAction: 'Edit utility bill', addCategoryAction: 'Add category', saveCategoryAction: 'Save category', savingCategory: 'Saving…', + categoryCreateBody: 'Create a new utility category without stretching the page.', + categoryEditorBody: 'Rename or disable the category in a focused editor.', + editCategoryAction: 'Edit category', 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.', + memberEditorBody: 'Member billing state and admin controls stay grouped in one editor.', + editMemberAction: 'Edit member', saveDisplayName: 'Save name', savingDisplayName: 'Saving name…', memberStatusLabel: 'Member status', @@ -262,6 +285,7 @@ export const dictionary = { 'Вклады в общие покупки появятся здесь после первых записанных покупок.', purchaseShareLabel: 'Доля', purchaseTotalLabel: 'Всего общих покупок', + participantsLabel: 'участника', purchasesTitle: 'Общие покупки', purchasesEmpty: 'Пока нет общих покупок в этом цикле.', utilityLedgerTitle: 'Коммунальные платежи', @@ -286,21 +310,29 @@ export const dictionary = { purchaseSplitEqual: 'Поровну', purchaseSplitCustom: 'Свои суммы', purchaseParticipantLabel: 'Участвует', + participantIncluded: 'Участвует', + participantExcluded: 'Не участвует', purchaseCustomShareLabel: 'Своя сумма', + purchaseEditorBody: + 'Проверь покупку и меняй детали разделения только если это действительно нужно.', paymentsAdminTitle: 'Оплаты', paymentsAdminBody: 'Добавляй, исправляй или удаляй оплаты за текущий цикл.', paymentsAddAction: 'Добавить оплату', addingPayment: 'Добавляем оплату…', + paymentCreateBody: 'Создай оплату в отдельном окне вместо длинной встроенной формы.', paymentKind: 'Тип оплаты', paymentAmount: 'Сумма оплаты', paymentMember: 'Участник', paymentSaveAction: 'Сохранить оплату', paymentDeleteAction: 'Удалить оплату', + paymentEditorBody: 'Проверь оплату в отдельном редакторе.', deletingPayment: 'Удаляем оплату…', purchaseSaveAction: 'Сохранить покупку', purchaseDeleteAction: 'Удалить покупку', deletingPurchase: 'Удаляем покупку…', savingPurchase: 'Сохраняем покупку…', + editEntryAction: 'Редактировать запись', + closeEditorAction: 'Закрыть', householdSettingsTitle: 'Настройки household', householdSettingsBody: 'Здесь можно менять язык household и подтверждать новых соседей.', topicBindingsTitle: 'Привязанные топики', @@ -323,6 +355,8 @@ export const dictionary = { billingCycleStatus: 'Валюта текущего цикла: {currency}', billingCycleOpenHint: 'Открой цикл перед тем, как вносить аренду и коммунальные счета.', billingCyclePeriod: 'Период цикла', + manageCycleAction: 'Управлять циклом', + cycleEditorBody: 'Все действия по циклу собраны в отдельном окне, а не растянуты по странице.', openCycleAction: 'Открыть цикл', openingCycle: 'Открываем цикл…', closeCycleAction: 'Закрыть цикл', @@ -343,6 +377,8 @@ export const dictionary = { utilitiesDueDay: 'День оплаты коммуналки', utilitiesReminderDay: 'День напоминания по коммуналке', timezone: 'Часовой пояс', + manageSettingsAction: 'Управлять настройками', + billingSettingsEditorBody: 'Основные правила биллинга собраны в отдельном окне.', saveSettingsAction: 'Сохранить настройки', savingSettings: 'Сохраняем настройки…', utilityCategoriesTitle: 'Категории коммуналки', @@ -350,14 +386,24 @@ export const dictionary = { 'Управляй категориями, которые админы используют для ежемесячного ввода коммунальных счетов.', utilityCategoryName: 'Название категории', utilityCategoryActive: 'Активна', + utilityBillsEditorBody: + 'В списке остаются только короткие карточки, а детали редактируются отдельно.', + utilityBillCreateBody: 'Добавь коммунальный счёт в отдельном окне.', + utilityBillEditorBody: 'Исправь детали коммунального счёта здесь.', + editUtilityBillAction: 'Редактировать счёт', addCategoryAction: 'Добавить категорию', saveCategoryAction: 'Сохранить категорию', savingCategory: 'Сохраняем…', + categoryCreateBody: 'Создай новую категорию без длинной встроенной формы.', + categoryEditorBody: 'Переименуй категорию или отключи её в отдельном окне.', + editCategoryAction: 'Редактировать категорию', adminsTitle: 'Админы', adminsBody: 'Повышай доверенных участников, чтобы они могли управлять биллингом и подтверждениями.', displayNameLabel: 'Имя в household', displayNameHint: 'Это имя будет видно в балансе, леджере и ответах ассистента.', + memberEditorBody: 'Статус, политика и админские действия по участнику собраны в одном окне.', + editMemberAction: 'Редактировать участника', saveDisplayName: 'Сохранить имя', savingDisplayName: 'Сохраняем имя…', memberStatusLabel: 'Статус участника', diff --git a/apps/miniapp/src/index.css b/apps/miniapp/src/index.css index 1958e14..6fade0f 100644 --- a/apps/miniapp/src/index.css +++ b/apps/miniapp/src/index.css @@ -13,6 +13,18 @@ box-sizing: border-box; } +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + body { margin: 0; min-height: 100vh; @@ -204,6 +216,51 @@ button { padding: 12px 16px; } +.ui-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + border: 1px solid rgb(255 255 255 / 0.12); + border-radius: 14px; + min-height: 44px; + padding: 10px 14px; + background: rgb(255 255 255 / 0.05); + color: inherit; + transition: + transform 140ms ease, + border-color 140ms ease, + background 140ms ease, + color 140ms ease; +} + +.ui-button:disabled { + opacity: 0.6; +} + +.ui-button--primary { + border-color: rgb(247 179 137 / 0.42); + background: rgb(247 179 137 / 0.16); + color: #fff4ea; +} + +.ui-button--secondary, +.ui-button--ghost, +.ui-button--icon { + background: rgb(255 255 255 / 0.04); +} + +.ui-button--danger { + border-color: rgb(247 115 115 / 0.28); + background: rgb(247 115 115 / 0.08); + color: #ffc5c5; +} + +.ui-button--icon { + min-width: 44px; + padding-inline: 0; +} + .nav-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -397,7 +454,7 @@ button { .purchase-chart__figure { position: relative; - width: min(220px, 100%); + width: min(180px, 100%); justify-self: center; } @@ -433,7 +490,7 @@ button { .purchase-chart__center span { color: #c6c2bb; - font-size: 0.82rem; + font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.08em; } @@ -451,7 +508,7 @@ button { .purchase-chart__legend-item { border: 1px solid rgb(255 255 255 / 0.08); border-radius: 16px; - padding: 12px; + padding: 10px 12px; background: rgb(255 255 255 / 0.02); } @@ -584,6 +641,11 @@ button { line-height: 1.2; } +.settings-field select { + appearance: none; + -webkit-appearance: none; +} + .settings-field__value { display: flex; align-items: center; @@ -615,6 +677,186 @@ button { color: #ffc5c5; } +.panel-toolbar { + display: flex; + justify-content: flex-end; + margin-top: 12px; +} + +.ledger-compact-card { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 14px; + align-items: start; + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 18px; + padding: 14px; + background: rgb(255 255 255 / 0.02); +} + +.ledger-compact-card__main { + min-width: 0; +} + +.ledger-compact-card header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + margin-bottom: 6px; +} + +.ledger-compact-card p { + margin: 0; + color: #d6d3cc; +} + +.ledger-compact-card__meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; +} + +.ledger-compact-card__actions { + display: flex; + align-items: start; +} + +.mini-chip { + display: inline-flex; + align-items: center; + border-radius: 999px; + padding: 6px 10px; + background: rgb(247 179 137 / 0.12); + color: #ffe6d2; + font-size: 0.82rem; +} + +.mini-chip--muted { + background: rgb(255 255 255 / 0.05); + color: #dad5ce; +} + +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: end center; + padding: 18px 14px; + background: rgb(8 10 16 / 0.7); + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); +} + +.modal-sheet { + width: min(720px, 100%); + max-height: min(88vh, 920px); + overflow: auto; + border: 1px solid rgb(255 255 255 / 0.1); + border-radius: 24px; + padding: 18px; + background: + linear-gradient(180deg, rgb(255 255 255 / 0.07), rgb(255 255 255 / 0.03)), rgb(18 26 36 / 0.96); + box-shadow: 0 28px 80px rgb(0 0 0 / 0.35); +} + +.modal-sheet__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: start; +} + +.modal-sheet__header h3 { + margin: 0; + font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif; + letter-spacing: -0.04em; + font-size: 1.4rem; +} + +.modal-sheet__header p { + margin-top: 8px; +} + +.modal-sheet__body { + display: grid; + gap: 16px; + margin-top: 18px; +} + +.modal-sheet__footer { + margin-top: 18px; +} + +.editor-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +.editor-panel { + display: grid; + gap: 12px; + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 18px; + padding: 14px; + background: rgb(255 255 255 / 0.03); +} + +.editor-panel__header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + align-items: start; +} + +.participant-list { + display: grid; + gap: 10px; +} + +.participant-card { + border: 1px solid rgb(255 255 255 / 0.08); + border-radius: 16px; + padding: 12px; + background: rgb(255 255 255 / 0.02); +} + +.participant-card header { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 10px; + margin-bottom: 10px; +} + +.participant-card__controls { + display: grid; + gap: 10px; +} + +.participant-card__field { + margin-top: 2px; +} + +.modal-action-row { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + gap: 10px; +} + +.modal-action-row--single { + justify-content: flex-end; +} + +.modal-action-row__primary { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 10px; +} + .panel--wide { min-height: 170px; } @@ -709,6 +951,10 @@ button { .settings-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + + .editor-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } @media (max-width: 759px) { @@ -742,6 +988,7 @@ button { } .activity-row header, + .ledger-compact-card header, .ledger-item header, .utility-bill-row header, .balance-item header {