refactor(miniapp): extract ledger screen

This commit is contained in:
2026-03-11 19:41:37 +04:00
parent 63f31a46db
commit 8891c5d471
2 changed files with 726 additions and 525 deletions

View File

@@ -1,4 +1,4 @@
import { Match, Show, Switch, createMemo, createSignal, onMount, type JSX } from 'solid-js'
import { Match, Show, Switch, createMemo, createSignal, onMount } from 'solid-js'
import { dictionary, type Locale } from './i18n'
import {
@@ -45,6 +45,7 @@ import { LoadingState } from './components/session/loading-state'
import { OnboardingState } from './components/session/onboarding-state'
import { BalancesScreen } from './screens/balances-screen'
import { HomeScreen } from './screens/home-screen'
import { LedgerScreen } from './screens/ledger-screen'
import {
demoAdminSettings,
demoCycleState,
@@ -1868,523 +1869,131 @@ function App() {
)
case 'ledger':
return (
<div class="ledger-list">
<ShowDashboard
dashboard={dashboard()}
fallback={<p>{copy().emptyDashboard}</p>}
render={() => (
<>
<article class="balance-item">
<header>
<strong>
{readySession()?.member.isAdmin
? copy().purchaseReviewTitle
: copy().purchasesTitle}
</strong>
</header>
<Show when={readySession()?.member.isAdmin}>
<p>{copy().purchaseReviewBody}</p>
</Show>
{purchaseLedger().length === 0 ? (
<p>{copy().purchasesEmpty}</p>
) : (
<div class="ledger-list">
{purchaseLedger().map((entry) => (
<article class="ledger-compact-card">
<div class="ledger-compact-card__main">
<header>
<strong>{entry.title}</strong>
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
</header>
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
<div class="ledger-compact-card__meta">
<span class="mini-chip">{ledgerPrimaryAmount(entry)}</span>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="mini-chip mini-chip--muted">{secondary()}</span>
)}
</Show>
<Show when={entry.kind === 'purchase'}>
<span class="mini-chip mini-chip--muted">
{purchaseParticipantSummary(entry)}
</span>
</Show>
</div>
</div>
<Show when={readySession()?.member.isAdmin}>
<div class="ledger-compact-card__actions">
<IconButton
label={copy().editEntryAction}
onClick={() => setEditingPurchaseId(entry.id)}
>
...
</IconButton>
</div>
</Show>
</article>
))}
</div>
)}
</article>
<Modal
open={Boolean(editingPurchaseEntry())}
title={copy().purchaseReviewTitle}
description={copy().purchaseEditorBody}
closeLabel={copy().closeEditorAction}
onClose={() => setEditingPurchaseId(null)}
footer={(() => {
const entry = editingPurchaseEntry()
if (!entry) {
return null
<LedgerScreen
copy={copy()}
dashboard={dashboard()}
readyIsAdmin={readySession()?.member.isAdmin === true}
adminMembers={adminSettings()?.members ?? []}
purchaseEntries={purchaseLedger()}
utilityEntries={utilityLedger()}
paymentEntries={paymentLedger()}
editingPurchaseEntry={editingPurchaseEntry()}
editingPaymentEntry={editingPaymentEntry()}
purchaseDraftMap={purchaseDraftMap()}
paymentDraftMap={paymentDraftMap()}
paymentForm={paymentForm()}
addingPaymentOpen={addingPaymentOpen()}
savingPurchaseId={savingPurchaseId()}
deletingPurchaseId={deletingPurchaseId()}
savingPaymentId={savingPaymentId()}
deletingPaymentId={deletingPaymentId()}
addingPayment={addingPayment()}
ledgerTitle={ledgerTitle}
ledgerPrimaryAmount={ledgerPrimaryAmount}
ledgerSecondaryAmount={ledgerSecondaryAmount}
purchaseParticipantSummary={purchaseParticipantSummary}
purchaseDraftForEntry={purchaseDraftForEntry}
paymentDraftForEntry={paymentDraftForEntry}
purchaseSplitPreview={purchaseSplitPreview}
paymentMemberName={paymentMemberName}
onOpenPurchaseEditor={setEditingPurchaseId}
onClosePurchaseEditor={() => setEditingPurchaseId(null)}
onDeletePurchase={handleDeletePurchase}
onSavePurchase={handleUpdatePurchase}
onPurchaseDescriptionChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
description: value
}))
}
onPurchaseAmountChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
amountMajor: value
}))
}
onPurchaseCurrencyChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
currency: value
}))
}
onPurchaseSplitModeChange={(purchaseId, entry, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
splitMode: value
}))
}
onTogglePurchaseParticipant={togglePurchaseParticipant}
onPurchaseParticipantShareChange={(purchaseId, entry, memberId, value) =>
updatePurchaseDraft(purchaseId, entry, (current) => ({
...current,
participants: current.participants.map((participant) =>
participant.memberId === memberId
? {
...participant,
shareAmountMajor: value
}
return (
<div class="modal-action-row">
<Button
variant="danger"
onClick={() => void handleDeletePurchase(entry.id)}
>
{deletingPurchaseId() === entry.id
? copy().deletingPurchase
: copy().purchaseDeleteAction}
</Button>
<div class="modal-action-row__primary">
<Button variant="ghost" onClick={() => setEditingPurchaseId(null)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
disabled={savingPurchaseId() === entry.id}
onClick={() => void handleUpdatePurchase(entry.id)}
>
{savingPurchaseId() === entry.id
? copy().savingPurchase
: copy().purchaseSaveAction}
</Button>
</div>
</div>
)
})()}
>
{(() => {
const entry = editingPurchaseEntry()
if (!entry) {
return null
}
const draft = purchaseDraftMap()[entry.id] ?? purchaseDraftForEntry(entry)
const splitPreview = purchaseSplitPreview(entry.id)
return (
<>
<div class="editor-grid">
<Field label={copy().purchaseReviewTitle} wide>
<input
value={draft.description}
onInput={(event) =>
updatePurchaseDraft(entry.id, entry, (current) => ({
...current,
description: event.currentTarget.value
}))
}
/>
</Field>
<Field label={copy().paymentAmount}>
<input
value={draft.amountMajor}
onInput={(event) =>
updatePurchaseDraft(entry.id, entry, (current) => ({
...current,
amountMajor: event.currentTarget.value
}))
}
/>
</Field>
<Field label={copy().settlementCurrency}>
<select
value={draft.currency}
onChange={(event) =>
updatePurchaseDraft(entry.id, entry, (current) => ({
...current,
currency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
<option value="GEL">GEL</option>
<option value="USD">USD</option>
</select>
</Field>
</div>
<section class="editor-panel">
<header class="editor-panel__header">
<strong>{copy().purchaseSplitTitle}</strong>
<span>
{draft.splitMode === 'custom_amounts'
? copy().purchaseSplitCustom
: copy().purchaseSplitEqual}
</span>
</header>
<div class="editor-grid">
<Field label={copy().purchaseSplitModeLabel} wide>
<select
value={draft.splitMode}
onChange={(event) =>
updatePurchaseDraft(entry.id, entry, (current) => ({
...current,
splitMode: event.currentTarget.value as
| 'equal'
| 'custom_amounts'
}))
}
>
<option value="equal">{copy().purchaseSplitEqual}</option>
<option value="custom_amounts">
{copy().purchaseSplitCustom}
</option>
</select>
</Field>
</div>
<div class="participant-list">
{(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 (
<article class="participant-card">
<header>
<strong>{member.displayName}</strong>
<span>
{previewAmount} {draft.currency}
</span>
</header>
<div class="participant-card__controls">
<Button
variant={included ? 'primary' : 'secondary'}
onClick={() =>
togglePurchaseParticipant(
entry.id,
entry,
member.id,
!included
)
}
>
{included
? copy().participantIncluded
: copy().participantExcluded}
</Button>
<Show when={included && draft.splitMode === 'custom_amounts'}>
<Field
label={copy().purchaseCustomShareLabel}
class="participant-card__field"
>
<input
value={
draft.participants.find(
(participant) => 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
)
}))
}
/>
</Field>
</Show>
</div>
</article>
)
})}
</div>
</section>
</>
)
})()}
</Modal>
<article class="balance-item">
<header>
<strong>{copy().utilityLedgerTitle}</strong>
</header>
{utilityLedger().length === 0 ? (
<p>{copy().utilityLedgerEmpty}</p>
) : (
<div class="ledger-list">
{utilityLedger().map((entry) => (
<article class="ledger-item">
<header>
<strong>{ledgerTitle(entry)}</strong>
<span>{ledgerPrimaryAmount(entry)}</span>
</header>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => <p>{secondary()}</p>}
</Show>
<p>{entry.actorDisplayName ?? copy().ledgerActorFallback}</p>
</article>
))}
</div>
)}
</article>
<article class="balance-item">
<header>
<strong>{copy().paymentsAdminTitle}</strong>
</header>
<Show when={readySession()?.member.isAdmin}>
<p>{copy().paymentsAdminBody}</p>
<div class="panel-toolbar">
<Button variant="secondary" onClick={() => setAddingPaymentOpen(true)}>
{copy().paymentsAddAction}
</Button>
</div>
</Show>
{paymentLedger().length === 0 ? (
<p>{copy().paymentsEmpty}</p>
) : (
<div class="ledger-list">
{paymentLedger().map((entry) => (
<article class="ledger-compact-card">
<div class="ledger-compact-card__main">
<header>
<strong>{paymentMemberName(entry)}</strong>
<span>{entry.occurredAt?.slice(0, 10) ?? '—'}</span>
</header>
<p>{ledgerTitle(entry)}</p>
<div class="ledger-compact-card__meta">
<span class="mini-chip">{ledgerPrimaryAmount(entry)}</span>
<Show when={ledgerSecondaryAmount(entry)}>
{(secondary) => (
<span class="mini-chip mini-chip--muted">{secondary()}</span>
)}
</Show>
</div>
</div>
<Show when={readySession()?.member.isAdmin}>
<div class="ledger-compact-card__actions">
<IconButton
label={copy().editEntryAction}
onClick={() => setEditingPaymentId(entry.id)}
>
...
</IconButton>
</div>
</Show>
</article>
))}
</div>
)}
</article>
<Modal
open={addingPaymentOpen()}
title={copy().paymentsAddAction}
description={copy().paymentCreateBody}
closeLabel={copy().closeEditorAction}
onClose={() => setAddingPaymentOpen(false)}
footer={
<div class="modal-action-row modal-action-row--single">
<Button variant="ghost" onClick={() => setAddingPaymentOpen(false)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
disabled={
addingPayment() || paymentForm().amountMajor.trim().length === 0
}
onClick={() => void handleAddPayment()}
>
{addingPayment() ? copy().addingPayment : copy().paymentsAddAction}
</Button>
</div>
}
>
<div class="editor-grid">
<Field label={copy().paymentMember} wide>
<select
value={paymentForm().memberId}
onChange={(event) =>
setPaymentForm((current) => ({
...current,
memberId: event.currentTarget.value
}))
}
>
{adminSettings()?.members.map((member) => (
<option value={member.id}>{member.displayName}</option>
))}
</select>
</Field>
<Field label={copy().paymentKind}>
<select
value={paymentForm().kind}
onChange={(event) =>
setPaymentForm((current) => ({
...current,
kind: event.currentTarget.value as 'rent' | 'utilities'
}))
}
>
<option value="rent">{copy().paymentLedgerRent}</option>
<option value="utilities">{copy().paymentLedgerUtilities}</option>
</select>
</Field>
<Field label={copy().paymentAmount}>
<input
value={paymentForm().amountMajor}
onInput={(event) =>
setPaymentForm((current) => ({
...current,
amountMajor: event.currentTarget.value
}))
}
/>
</Field>
<Field label={copy().settlementCurrency}>
<select
value={paymentForm().currency}
onChange={(event) =>
setPaymentForm((current) => ({
...current,
currency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
<option value="GEL">GEL</option>
<option value="USD">USD</option>
</select>
</Field>
</div>
</Modal>
<Modal
open={Boolean(editingPaymentEntry())}
title={copy().paymentsAdminTitle}
description={copy().paymentEditorBody}
closeLabel={copy().closeEditorAction}
onClose={() => setEditingPaymentId(null)}
footer={(() => {
const entry = editingPaymentEntry()
if (!entry) {
return null
}
return (
<div class="modal-action-row">
<Button
variant="danger"
onClick={() => void handleDeletePayment(entry.id)}
>
{deletingPaymentId() === entry.id
? copy().deletingPayment
: copy().paymentDeleteAction}
</Button>
<div class="modal-action-row__primary">
<Button variant="ghost" onClick={() => setEditingPaymentId(null)}>
{copy().closeEditorAction}
</Button>
<Button
variant="primary"
disabled={savingPaymentId() === entry.id}
onClick={() => void handleUpdatePayment(entry.id)}
>
{savingPaymentId() === entry.id
? copy().addingPayment
: copy().paymentSaveAction}
</Button>
</div>
</div>
)
})()}
>
{(() => {
const entry = editingPaymentEntry()
if (!entry) {
return null
}
const draft = paymentDraftMap()[entry.id] ?? paymentDraftForEntry(entry)
return (
<div class="editor-grid">
<Field label={copy().paymentMember} wide>
<select
value={draft.memberId}
onChange={(event) =>
updatePaymentDraft(entry.id, entry, (current) => ({
...current,
memberId: event.currentTarget.value
}))
}
>
{adminSettings()?.members.map((member) => (
<option value={member.id}>{member.displayName}</option>
))}
</select>
</Field>
<Field label={copy().paymentKind}>
<select
value={draft.kind}
onChange={(event) =>
updatePaymentDraft(entry.id, entry, (current) => ({
...current,
kind: event.currentTarget.value as 'rent' | 'utilities'
}))
}
>
<option value="rent">{copy().paymentLedgerRent}</option>
<option value="utilities">{copy().paymentLedgerUtilities}</option>
</select>
</Field>
<Field label={copy().paymentAmount}>
<input
value={draft.amountMajor}
onInput={(event) =>
updatePaymentDraft(entry.id, entry, (current) => ({
...current,
amountMajor: event.currentTarget.value
}))
}
/>
</Field>
<Field label={copy().settlementCurrency}>
<select
value={draft.currency}
onChange={(event) =>
updatePaymentDraft(entry.id, entry, (current) => ({
...current,
currency: event.currentTarget.value as 'USD' | 'GEL'
}))
}
>
<option value="GEL">GEL</option>
<option value="USD">USD</option>
</select>
</Field>
</div>
)
})()}
</Modal>
</>
)}
/>
</div>
: participant
)
}))
}
onOpenAddPayment={() => setAddingPaymentOpen(true)}
onCloseAddPayment={() => setAddingPaymentOpen(false)}
onAddPayment={handleAddPayment}
onPaymentFormMemberChange={(value) =>
setPaymentForm((current) => ({
...current,
memberId: value
}))
}
onPaymentFormKindChange={(value) =>
setPaymentForm((current) => ({
...current,
kind: value
}))
}
onPaymentFormAmountChange={(value) =>
setPaymentForm((current) => ({
...current,
amountMajor: value
}))
}
onPaymentFormCurrencyChange={(value) =>
setPaymentForm((current) => ({
...current,
currency: value
}))
}
onOpenPaymentEditor={setEditingPaymentId}
onClosePaymentEditor={() => setEditingPaymentId(null)}
onDeletePayment={handleDeletePayment}
onSavePayment={handleUpdatePayment}
onPaymentDraftMemberChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
memberId: value
}))
}
onPaymentDraftKindChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
kind: value
}))
}
onPaymentDraftAmountChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
amountMajor: value
}))
}
onPaymentDraftCurrencyChange={(paymentId, entry, value) =>
updatePaymentDraft(paymentId, entry, (current) => ({
...current,
currency: value
}))
}
/>
)
case 'house':
return readySession()?.member.isAdmin ? (
@@ -3663,12 +3272,4 @@ function App() {
)
}
function ShowDashboard(props: {
dashboard: MiniAppDashboard | null
fallback: JSX.Element
render: (dashboard: MiniAppDashboard) => JSX.Element
}) {
return <>{props.dashboard ? props.render(props.dashboard) : props.fallback}</>
}
export default App