mirror of
https://github.com/whekin/household-bot.git
synced 2026-03-31 17:54:02 +00:00
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:
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: 'Сохраняем покупку…',
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user