diff --git a/apps/miniapp/src/components/ui/input.tsx b/apps/miniapp/src/components/ui/input.tsx index 148dd40..e2a618a 100644 --- a/apps/miniapp/src/components/ui/input.tsx +++ b/apps/miniapp/src/components/ui/input.tsx @@ -18,6 +18,7 @@ type InputProps = { id?: string onInput?: JSX.EventHandlerUnion onChange?: JSX.EventHandlerUnion + onBlur?: JSX.EventHandlerUnion } export function Input(props: InputProps) { @@ -38,6 +39,7 @@ export function Input(props: InputProps) { class={cn('ui-input', props.class)} onInput={props.onInput} onChange={props.onChange} + onBlur={props.onBlur} /> ) } diff --git a/apps/miniapp/src/i18n.ts b/apps/miniapp/src/i18n.ts index 3ba6184..d0fbf80 100644 --- a/apps/miniapp/src/i18n.ts +++ b/apps/miniapp/src/i18n.ts @@ -159,6 +159,8 @@ export const dictionary = { paymentEditorBody: 'Review the payment record in one focused editor.', deletingPayment: 'Deleting payment…', purchaseSaveAction: 'Save purchase', + purchaseBalanceAction: 'Balance', + purchaseRebalanceAction: 'Rebalance', purchaseDeleteAction: 'Delete', deletingPurchase: 'Deleting purchase…', savingPurchase: 'Saving purchase…', @@ -460,6 +462,8 @@ export const dictionary = { paymentEditorBody: 'Проверь оплату в отдельном редакторе.', deletingPayment: 'Удаляем оплату…', purchaseSaveAction: 'Сохранить покупку', + purchaseBalanceAction: 'Сбалансировать', + purchaseRebalanceAction: 'Перераспределить', purchaseDeleteAction: 'Удалить', deletingPurchase: 'Удаляем покупку…', savingPurchase: 'Сохраняем покупку…', diff --git a/apps/miniapp/src/lib/ledger-helpers.ts b/apps/miniapp/src/lib/ledger-helpers.ts index ace415d..cbdd420 100644 --- a/apps/miniapp/src/lib/ledger-helpers.ts +++ b/apps/miniapp/src/lib/ledger-helpers.ts @@ -182,63 +182,56 @@ export function rebalancePurchaseSplit( newAmountMajor: string | null ): PurchaseDraft { const totalMinor = majorStringToMinor(draft.amountMajor) - if (totalMinor <= 0n) return draft - let participants = draft.participants.map((p) => ({ ...p })) + // 1. Update the changed participant if any if (changedMemberId !== null && newAmountMajor !== null) { - const changedIdx = participants.findIndex((p) => p.memberId === changedMemberId) - if (changedIdx === -1) return draft - - const newAmountMinor = majorStringToMinor(newAmountMajor) - - if (newAmountMinor > totalMinor) { - return draft - } - - 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 + const idx = participants.findIndex((p) => p.memberId === changedMemberId) + if (idx !== -1) { + participants[idx] = { + ...participants[idx]!, + shareAmountMajor: newAmountMajor, + lastUpdatedAt: Date.now(), + isAutoCalculated: false } + } + } + + // 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 bTime = b.lastUpdatedAt ?? 0 return aTime - bTime }) for (const p of sorted) { - if (remainingDelta === 0n) break - + if (delta === 0n) break const currentMinor = majorStringToMinor(participants[p.idx]!.shareAmountMajor || '0') - let newValue = currentMinor - remainingDelta + let newValue = currentMinor - delta if (newValue < 0n) { - remainingDelta = -newValue + delta = -newValue newValue = 0n } else { - remainingDelta = 0n + delta = 0n } participants[p.idx] = { @@ -246,32 +239,25 @@ export function rebalancePurchaseSplit( shareAmountMajor: minorToMajorString(newValue), 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) { - return { ...draft, participants } + // Special case: if it's 'equal' mode and we aren't handling a specific change, force equal + 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 }) diff --git a/apps/miniapp/src/routes/ledger.tsx b/apps/miniapp/src/routes/ledger.tsx index 5b65fed..1a42349 100644 --- a/apps/miniapp/src/routes/ledger.tsx +++ b/apps/miniapp/src/routes/ledger.tsx @@ -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 { useSession } from '../contexts/session-context' @@ -20,11 +21,10 @@ import { computePaymentPrefill, rebalancePurchaseSplit, validatePurchaseDraft, - calculateRemainingToAllocate, type PurchaseDraft, type PaymentDraft } from '../lib/ledger-helpers' -import { minorToMajorString } from '../lib/money' +import { minorToMajorString, majorStringToMinor } from '../lib/money' import { addMiniAppPurchase, updateMiniAppPurchase, @@ -38,6 +38,168 @@ import { type MiniAppDashboard } 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 ( +
+ + {(participant, idx) => { + const member = () => + dashboard()?.members.find((m) => m.memberId === participant().memberId) + return ( +
+ { + 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) + }) + }} + /> + {member()?.displayName ?? 'Unknown'} + + { + 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) + }) + }} + /> + + + { + 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 } + }) + }} + /> + +
+ ) + }} +
+ p.included) && + !validation().valid + } + > +
+
+ {validation().error} +
+
+
+
+ ) +} + export default function LedgerRoute() { const { initData, refreshHouseholdData } = useSession() const { copy } = useI18n() @@ -103,6 +265,22 @@ export default function LedgerRoute() { }) 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]) { setEditingPurchase(entry) setPurchaseDraft(purchaseDraftForEntry(entry)) @@ -361,105 +539,6 @@ export default function LedgerRoute() { { value: 'percentage', label: 'Percentages' } ] - function renderParticipantSplitInputs( - draft: PurchaseDraft, - updateDraft: (fn: (d: PurchaseDraft) => PurchaseDraft) => void - ) { - const remaining = () => calculateRemainingToAllocate(draft) - const validation = () => validatePurchaseDraft(draft) - - return ( -
- - {(participant, idx) => { - const member = dashboard()?.members.find((m) => m.memberId === participant.memberId) - return ( -
- { - updateDraft((d) => { - const newParticipants = [...d.participants] - newParticipants[idx()] = { ...participant, included: checked } - const updated = { ...d, participants: newParticipants } - return rebalancePurchaseSplit(updated, null, null) - }) - }} - /> - {member?.displayName ?? 'Unknown'} - - { - 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) - }) - }} - /> - - - { - 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) - }) - }} - /> - -
- ) - }} -
- p.included)}> -
- {validation().error - ? validation().error - : `Remaining: ${minorToMajorString(remaining() > 0n ? remaining() : -remaining())} ${draft.currency}`} -
-
-
- ) - } - return (
void handleAddPurchase()} + disabled={!newPurchase().description.trim() || !newPurchase().amountMajor.trim()} + onClick={() => { + if ( + newPurchase().splitInputMode !== 'equal' && + !validatePurchaseDraft(newPurchase()).valid + ) { + setNewPurchase((p) => rebalancePurchaseSplit(p, null, null)) + } else { + void handleAddPurchase() + } + }} > - {copy().purchaseSaveAction} + {addPurchaseButtonText()}
} @@ -657,7 +740,13 @@ export default function LedgerRoute() { 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) + }) + }} /> @@ -680,14 +769,20 @@ export default function LedgerRoute() { setNewPurchase((p) => { const splitInputMode = value as 'equal' | 'exact' | 'percentage' 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) }) } /> - {renderParticipantSplitInputs(newPurchase(), (updater) => - setNewPurchase((prev) => updater(prev)) - )} + setNewPurchase((prev) => updater(prev))} + /> @@ -711,10 +806,23 @@ export default function LedgerRoute() { } @@ -734,9 +842,14 @@ export default function LedgerRoute() { - setPurchaseDraft((d) => (d ? { ...d, amountMajor: e.currentTarget.value } : d)) - } + onInput={(e) => { + const amountMajor = e.currentTarget.value + setPurchaseDraft((d) => { + if (!d) return d + const updated = { ...d, amountMajor } + return rebalancePurchaseSplit(updated, null, null) + }) + }} /> @@ -760,14 +873,22 @@ export default function LedgerRoute() { if (!d) return d const splitInputMode = value as 'equal' | 'exact' | 'percentage' 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) }) } /> - {renderParticipantSplitInputs(draft(), (updater) => - setPurchaseDraft((prev) => (prev ? updater(prev) : prev)) - )} + + setPurchaseDraft((prev) => (prev ? updater(prev) : prev)) + } + /> )}