fix: purchase split rebalancing and input focus issues

- Add onBlur handler to Input component for rebalancing on blur
- Rewrite rebalancePurchaseSplit to calculate delta vs total and distribute
- Extract ParticipantSplitInputs component with proper SolidJS reactivity
- Button shows 'Balance' when validation fails, 'Save' when valid
- Add i18n keys for purchaseBalanceAction and purchaseRebalanceAction
This commit is contained in:
2026-03-13 10:40:42 +04:00
parent 588174fa52
commit 31dd1dc2ee
4 changed files with 303 additions and 190 deletions

View File

@@ -18,6 +18,7 @@ type InputProps = {
id?: string id?: string
onInput?: JSX.EventHandlerUnion<HTMLInputElement, InputEvent> onInput?: JSX.EventHandlerUnion<HTMLInputElement, InputEvent>
onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event> onChange?: JSX.EventHandlerUnion<HTMLInputElement, Event>
onBlur?: JSX.EventHandlerUnion<HTMLInputElement, FocusEvent>
} }
export function Input(props: InputProps) { export function Input(props: InputProps) {
@@ -38,6 +39,7 @@ export function Input(props: InputProps) {
class={cn('ui-input', props.class)} class={cn('ui-input', props.class)}
onInput={props.onInput} onInput={props.onInput}
onChange={props.onChange} onChange={props.onChange}
onBlur={props.onBlur}
/> />
) )
} }

View File

@@ -159,6 +159,8 @@ export const dictionary = {
paymentEditorBody: 'Review the payment record in one focused editor.', paymentEditorBody: 'Review the payment record in one focused editor.',
deletingPayment: 'Deleting payment…', deletingPayment: 'Deleting payment…',
purchaseSaveAction: 'Save purchase', purchaseSaveAction: 'Save purchase',
purchaseBalanceAction: 'Balance',
purchaseRebalanceAction: 'Rebalance',
purchaseDeleteAction: 'Delete', purchaseDeleteAction: 'Delete',
deletingPurchase: 'Deleting purchase…', deletingPurchase: 'Deleting purchase…',
savingPurchase: 'Saving purchase…', savingPurchase: 'Saving purchase…',
@@ -460,6 +462,8 @@ export const dictionary = {
paymentEditorBody: 'Проверь оплату в отдельном редакторе.', paymentEditorBody: 'Проверь оплату в отдельном редакторе.',
deletingPayment: 'Удаляем оплату…', deletingPayment: 'Удаляем оплату…',
purchaseSaveAction: 'Сохранить покупку', purchaseSaveAction: 'Сохранить покупку',
purchaseBalanceAction: 'Сбалансировать',
purchaseRebalanceAction: 'Перераспределить',
purchaseDeleteAction: 'Удалить', purchaseDeleteAction: 'Удалить',
deletingPurchase: 'Удаляем покупку…', deletingPurchase: 'Удаляем покупку…',
savingPurchase: 'Сохраняем покупку…', savingPurchase: 'Сохраняем покупку…',

View File

@@ -182,63 +182,56 @@ export function rebalancePurchaseSplit(
newAmountMajor: string | null newAmountMajor: string | null
): PurchaseDraft { ): PurchaseDraft {
const totalMinor = majorStringToMinor(draft.amountMajor) const totalMinor = majorStringToMinor(draft.amountMajor)
if (totalMinor <= 0n) return draft
let participants = draft.participants.map((p) => ({ ...p })) let participants = draft.participants.map((p) => ({ ...p }))
// 1. Update the changed participant if any
if (changedMemberId !== null && newAmountMajor !== null) { if (changedMemberId !== null && newAmountMajor !== null) {
const changedIdx = participants.findIndex((p) => p.memberId === changedMemberId) const idx = participants.findIndex((p) => p.memberId === changedMemberId)
if (changedIdx === -1) return draft if (idx !== -1) {
participants[idx] = {
const newAmountMinor = majorStringToMinor(newAmountMajor) ...participants[idx]!,
shareAmountMajor: newAmountMajor,
if (newAmountMinor > totalMinor) { lastUpdatedAt: Date.now(),
return draft isAutoCalculated: false
}
const oldAmountMinor = majorStringToMinor(participants[changedIdx]!.shareAmountMajor || '0')
const delta = oldAmountMinor - newAmountMinor
participants[changedIdx] = {
...participants[changedIdx]!,
shareAmountMajor: newAmountMajor,
lastUpdatedAt: Date.now(),
isAutoCalculated: false
}
if (delta === 0n) {
return recalculatePercentages({ ...draft, participants })
}
const included = participants
.map((p, idx) => ({ ...p, idx }))
.filter((p) => p.included && p.memberId !== changedMemberId)
if (included.length === 0) {
return recalculatePercentages({ ...draft, participants })
}
let remainingDelta = delta
const sorted = [...included].sort((a, b) => {
if (a.isAutoCalculated !== b.isAutoCalculated) {
return a.isAutoCalculated ? -1 : 1
} }
}
}
// 2. Identify included participants to balance against
const included = participants
.map((p, idx) => ({ ...p, idx }))
.filter((p) => p.included && p.memberId !== changedMemberId)
// 3. Calculate current allocation and delta
const currentAllocated = participants
.filter((p) => p.included)
.reduce((sum, p) => sum + majorStringToMinor(p.shareAmountMajor || '0'), 0n)
let delta = currentAllocated - totalMinor
if (delta !== 0n && included.length > 0) {
// 4. Distribute delta among others (preferring auto-calculated)
const sorted = [...included].sort((a, b) => {
// Prefer auto-calculated for absorbing changes
if (a.isAutoCalculated !== b.isAutoCalculated) {
return a.isAutoCalculated === false ? 1 : -1
}
// Then oldest updated
const aTime = a.lastUpdatedAt ?? 0 const aTime = a.lastUpdatedAt ?? 0
const bTime = b.lastUpdatedAt ?? 0 const bTime = b.lastUpdatedAt ?? 0
return aTime - bTime return aTime - bTime
}) })
for (const p of sorted) { for (const p of sorted) {
if (remainingDelta === 0n) break if (delta === 0n) break
const currentMinor = majorStringToMinor(participants[p.idx]!.shareAmountMajor || '0') const currentMinor = majorStringToMinor(participants[p.idx]!.shareAmountMajor || '0')
let newValue = currentMinor - remainingDelta let newValue = currentMinor - delta
if (newValue < 0n) { if (newValue < 0n) {
remainingDelta = -newValue delta = -newValue
newValue = 0n newValue = 0n
} else { } else {
remainingDelta = 0n delta = 0n
} }
participants[p.idx] = { participants[p.idx] = {
@@ -246,32 +239,25 @@ export function rebalancePurchaseSplit(
shareAmountMajor: minorToMajorString(newValue), shareAmountMajor: minorToMajorString(newValue),
isAutoCalculated: true isAutoCalculated: true
} }
if (newValue === 0n) {
participants[p.idx]!.included = false
}
} }
} else { }
const included = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
if (included.length === 0) { // Special case: if it's 'equal' mode and we aren't handling a specific change, force equal
return { ...draft, participants } if (draft.splitInputMode === 'equal' && changedMemberId === null) {
const active = participants.map((p, idx) => ({ ...p, idx })).filter((p) => p.included)
if (active.length > 0) {
const count = BigInt(active.length)
const baseShare = totalMinor / count
const remainder = totalMinor % count
active.forEach((p, i) => {
const share = baseShare + (BigInt(i) < remainder ? 1n : 0n)
participants[p.idx] = {
...participants[p.idx]!,
shareAmountMajor: minorToMajorString(share),
isAutoCalculated: true
}
})
} }
const count = BigInt(included.length)
const baseShare = totalMinor / count
const remainder = totalMinor % count
included.forEach((p, i) => {
const share = baseShare + (BigInt(i) < remainder ? 1n : 0n)
const existing = participants[p.idx]!
participants[p.idx] = {
...existing,
shareAmountMajor: minorToMajorString(share),
...(existing.lastUpdatedAt !== undefined ? { lastUpdatedAt: existing.lastUpdatedAt } : {}),
isAutoCalculated: true
}
})
} }
return recalculatePercentages({ ...draft, participants }) return recalculatePercentages({ ...draft, participants })

View File

@@ -1,4 +1,5 @@
import { Show, For, createSignal, createMemo } from 'solid-js' import { Show, For, Index, createSignal, createMemo } from 'solid-js'
import { produce } from 'solid-js/store'
import { Plus } from 'lucide-solid' import { Plus } from 'lucide-solid'
import { useSession } from '../contexts/session-context' import { useSession } from '../contexts/session-context'
@@ -20,11 +21,10 @@ import {
computePaymentPrefill, computePaymentPrefill,
rebalancePurchaseSplit, rebalancePurchaseSplit,
validatePurchaseDraft, validatePurchaseDraft,
calculateRemainingToAllocate,
type PurchaseDraft, type PurchaseDraft,
type PaymentDraft type PaymentDraft
} from '../lib/ledger-helpers' } from '../lib/ledger-helpers'
import { minorToMajorString } from '../lib/money' import { minorToMajorString, majorStringToMinor } from '../lib/money'
import { import {
addMiniAppPurchase, addMiniAppPurchase,
updateMiniAppPurchase, updateMiniAppPurchase,
@@ -38,6 +38,168 @@ import {
type MiniAppDashboard type MiniAppDashboard
} from '../miniapp-api' } from '../miniapp-api'
interface ParticipantSplitInputsProps {
draft: PurchaseDraft
updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void
}
function ParticipantSplitInputs(props: ParticipantSplitInputsProps) {
const { dashboard } = useDashboard()
const validation = () => validatePurchaseDraft(props.draft)
return (
<div
class="split-configuration"
style={{ display: 'flex', 'flex-direction': 'column', gap: '8px', 'margin-top': '8px' }}
>
<Index each={props.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) => {
props.updateDraft((prev) => {
const participants = prev.participants.map((p, i) =>
i === idx
? {
...p,
included: checked,
lastUpdatedAt: Date.now(),
isAutoCalculated: false
}
: p
)
return rebalancePurchaseSplit({ ...prev, participants }, null, null)
})
}}
/>
<span style={{ flex: 1 }}>{member()?.displayName ?? 'Unknown'}</span>
<Show when={participant().included && props.draft.splitInputMode === 'exact'}>
<Input
type="number"
style={{ width: '100px' }}
placeholder="0.00"
value={participant().shareAmountMajor}
onInput={(e) => {
const value = e.currentTarget.value
props.updateDraft(
produce((d: PurchaseDraft) => {
if (d.participants[idx]) {
d.participants[idx].shareAmountMajor = value
d.participants[idx].isAutoCalculated = false
d.participants[idx].lastUpdatedAt = Date.now()
}
})
)
}}
onBlur={(e) => {
const value = e.currentTarget.value
const minor = majorStringToMinor(value)
props.updateDraft((prev) => {
if (minor <= 0n) {
const participants = prev.participants.map((p, i) =>
i === idx
? {
...p,
included: false,
shareAmountMajor: '0.00',
sharePercentage: ''
}
: p
)
return rebalancePurchaseSplit({ ...prev, participants }, null, null)
}
return rebalancePurchaseSplit(prev, participant().memberId, value)
})
}}
/>
</Show>
<Show when={participant().included && props.draft.splitInputMode === 'percentage'}>
<Input
type="number"
style={{ width: '80px' }}
placeholder="%"
value={participant().sharePercentage}
onInput={(e) => {
const value = e.currentTarget.value
props.updateDraft(
produce((d: PurchaseDraft) => {
if (d.participants[idx]) {
d.participants[idx].sharePercentage = value
d.participants[idx].isAutoCalculated = false
d.participants[idx].lastUpdatedAt = Date.now()
}
})
)
}}
onBlur={(e) => {
const value = e.currentTarget.value
const percentage = parseFloat(value) || 0
props.updateDraft((prev) => {
if (percentage <= 0) {
const participants = prev.participants.map((p, i) =>
i === idx
? {
...p,
included: false,
shareAmountMajor: '0.00',
sharePercentage: ''
}
: p
)
return rebalancePurchaseSplit({ ...prev, participants }, null, null)
}
const totalMinor = majorStringToMinor(prev.amountMajor)
const shareMinor =
(totalMinor * BigInt(Math.round(percentage * 100))) / 10000n
const amountMajor = minorToMajorString(shareMinor)
const updated = rebalancePurchaseSplit(
prev,
participant().memberId,
amountMajor
)
// Preserve the typed percentage string
const participants = updated.participants.map((p, i) =>
i === idx ? { ...p, sharePercentage: value } : p
)
return { ...updated, participants }
})
}}
/>
</Show>
</div>
)
}}
</Index>
<Show
when={
props.draft.splitInputMode !== 'equal' &&
props.draft.participants.some((p) => p.included) &&
!validation().valid
}
>
<div style={{ display: 'flex', gap: '8px', 'align-items': 'center' }}>
<div
style={{
'font-size': '12px',
color: '#ef4444'
}}
>
{validation().error}
</div>
</div>
</Show>
</div>
)
}
export default function LedgerRoute() { export default function LedgerRoute() {
const { initData, refreshHouseholdData } = useSession() const { initData, refreshHouseholdData } = useSession()
const { copy } = useI18n() const { copy } = useI18n()
@@ -103,6 +265,22 @@ export default function LedgerRoute() {
}) })
const [addingPayment, setAddingPayment] = createSignal(false) const [addingPayment, setAddingPayment] = createSignal(false)
const addPurchaseButtonText = createMemo(() => {
if (addingPurchase()) return copy().purchaseSaveAction // or maybe adding...
if (newPurchase().splitInputMode === 'equal') return copy().purchaseSaveAction
if (!validatePurchaseDraft(newPurchase()).valid) return copy().purchaseBalanceAction
return copy().purchaseSaveAction
})
const editPurchaseButtonText = createMemo(() => {
const draft = purchaseDraft()
if (savingPurchase()) return copy().savingPurchase
if (!draft) return copy().purchaseSaveAction
if (draft.splitInputMode === 'equal') return copy().purchaseSaveAction
if (!validatePurchaseDraft(draft).valid) return copy().purchaseBalanceAction
return copy().purchaseSaveAction
})
function openPurchaseEditor(entry: MiniAppDashboard['ledger'][number]) { function openPurchaseEditor(entry: MiniAppDashboard['ledger'][number]) {
setEditingPurchase(entry) setEditingPurchase(entry)
setPurchaseDraft(purchaseDraftForEntry(entry)) setPurchaseDraft(purchaseDraftForEntry(entry))
@@ -361,105 +539,6 @@ export default function LedgerRoute() {
{ value: 'percentage', label: 'Percentages' } { value: 'percentage', label: 'Percentages' }
] ]
function renderParticipantSplitInputs(
draft: PurchaseDraft,
updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void
) {
const remaining = () => calculateRemainingToAllocate(draft)
const validation = () => validatePurchaseDraft(draft)
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 }
const updated = { ...d, participants: newParticipants }
return rebalancePurchaseSplit(updated, null, null)
})
}}
/>
<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) => {
const value = e.currentTarget.value
updateDraft((d) => {
const newParticipants = [...d.participants]
newParticipants[idx()] = {
...participant,
shareAmountMajor: value
}
const updated = { ...d, participants: newParticipants }
return rebalancePurchaseSplit(updated, participant.memberId, value)
})
}}
/>
</Show>
<Show when={participant.included && draft.splitInputMode === 'percentage'}>
<Input
type="number"
style={{ width: '80px' }}
placeholder="%"
value={participant.sharePercentage}
onInput={(e) => {
const value = e.currentTarget.value
const percentage = parseFloat(value) || 0
const totalMajor = parseFloat(draft.amountMajor) || 0
const exactAmount = (totalMajor * percentage) / 100
const newAmountMajor = exactAmount > 0 ? exactAmount.toFixed(2) : ''
updateDraft((d) => {
const newParticipants = [...d.participants]
newParticipants[idx()] = {
...participant,
sharePercentage: value,
shareAmountMajor: newAmountMajor
}
const updated = { ...d, participants: newParticipants }
return rebalancePurchaseSplit(updated, participant.memberId, newAmountMajor)
})
}}
/>
</Show>
</div>
)
}}
</For>
<Show when={draft.splitInputMode !== 'equal' && draft.participants.some((p) => p.included)}>
<div
style={{
'font-size': '12px',
'margin-top': '4px',
color: validation().valid ? '#22c55e' : '#ef4444'
}}
>
{validation().error
? validation().error
: `Remaining: ${minorToMajorString(remaining() > 0n ? remaining() : -remaining())} ${draft.currency}`}
</div>
</Show>
</div>
)
}
return ( return (
<div class="route route--ledger"> <div class="route route--ledger">
<Show <Show
@@ -633,15 +712,19 @@ export default function LedgerRoute() {
<Button <Button
variant="primary" variant="primary"
loading={addingPurchase()} loading={addingPurchase()}
disabled={ disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()}
!newPurchase().description.trim() || onClick={() => {
!newPurchase().amountMajor.trim() || if (
(newPurchase().splitInputMode !== 'equal' && newPurchase().splitInputMode !== 'equal' &&
!validatePurchaseDraft(newPurchase()).valid) !validatePurchaseDraft(newPurchase()).valid
} ) {
onClick={() => void handleAddPurchase()} setNewPurchase((p) => rebalancePurchaseSplit(p, null, null))
} else {
void handleAddPurchase()
}
}}
> >
{copy().purchaseSaveAction} {addPurchaseButtonText()}
</Button> </Button>
</div> </div>
} }
@@ -657,7 +740,13 @@ export default function LedgerRoute() {
<Input <Input
type="number" type="number"
value={newPurchase().amountMajor} value={newPurchase().amountMajor}
onInput={(e) => setNewPurchase((p) => ({ ...p, amountMajor: e.currentTarget.value }))} onInput={(e) => {
const amountMajor = e.currentTarget.value
setNewPurchase((p) => {
const updated = { ...p, amountMajor }
return rebalancePurchaseSplit(updated, null, null)
})
}}
/> />
</Field> </Field>
<Field label={copy().currencyLabel}> <Field label={copy().currencyLabel}>
@@ -680,14 +769,20 @@ export default function LedgerRoute() {
setNewPurchase((p) => { setNewPurchase((p) => {
const splitInputMode = value as 'equal' | 'exact' | 'percentage' const splitInputMode = value as 'equal' | 'exact' | 'percentage'
const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts' const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts'
return { ...p, splitInputMode, splitMode } const updated = {
...p,
splitInputMode,
splitMode: splitMode as 'equal' | 'custom_amounts'
}
return rebalancePurchaseSplit(updated, null, null)
}) })
} }
/> />
</Field> </Field>
{renderParticipantSplitInputs(newPurchase(), (updater) => <ParticipantSplitInputs
setNewPurchase((prev) => updater(prev)) draft={newPurchase()}
)} updateDraft={(updater) => setNewPurchase((prev) => updater(prev))}
/>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -711,10 +806,23 @@ export default function LedgerRoute() {
<Button <Button
variant="primary" variant="primary"
loading={savingPurchase()} loading={savingPurchase()}
disabled={purchaseDraft() ? !validatePurchaseDraft(purchaseDraft()!).valid : false} disabled={
onClick={() => void handleSavePurchase()} !purchaseDraft()?.description.trim() || !purchaseDraft()?.amountMajor.trim()
}
onClick={() => {
const draft = purchaseDraft()
if (
draft &&
draft.splitInputMode !== 'equal' &&
!validatePurchaseDraft(draft).valid
) {
setPurchaseDraft((d) => (d ? rebalancePurchaseSplit(d, null, null) : d))
} else {
void handleSavePurchase()
}
}}
> >
{savingPurchase() ? copy().savingPurchase : copy().purchaseSaveAction} {editPurchaseButtonText()}
</Button> </Button>
</div> </div>
} }
@@ -734,9 +842,14 @@ export default function LedgerRoute() {
<Input <Input
type="number" type="number"
value={draft().amountMajor} value={draft().amountMajor}
onInput={(e) => onInput={(e) => {
setPurchaseDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d)) const amountMajor = e.currentTarget.value
} setPurchaseDraft((d) => {
if (!d) return d
const updated = { ...d, amountMajor }
return rebalancePurchaseSplit(updated, null, null)
})
}}
/> />
</Field> </Field>
<Field label={copy().currencyLabel}> <Field label={copy().currencyLabel}>
@@ -760,14 +873,22 @@ export default function LedgerRoute() {
if (!d) return d if (!d) return d
const splitInputMode = value as 'equal' | 'exact' | 'percentage' const splitInputMode = value as 'equal' | 'exact' | 'percentage'
const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts' const splitMode = splitInputMode === 'equal' ? 'equal' : 'custom_amounts'
return { ...d, splitInputMode, splitMode } const updated = {
...d,
splitInputMode,
splitMode: splitMode as 'equal' | 'custom_amounts'
}
return rebalancePurchaseSplit(updated, null, null)
}) })
} }
/> />
</Field> </Field>
{renderParticipantSplitInputs(draft(), (updater) => <ParticipantSplitInputs
setPurchaseDraft((prev) => (prev ? updater(prev) : prev)) draft={draft()}
)} updateDraft={(updater) =>
setPurchaseDraft((prev) => (prev ? updater(prev) : prev))
}
/>
</div> </div>
</div> </div>
)} )}