From e9c4e0e273b03c335b165c7096f01323037bc622 Mon Sep 17 00:00:00 2001 From: 31b4 Date: Sun, 12 Jul 2026 17:02:10 +0200 Subject: [PATCH 1/3] better amount formatting --- .../budget-module/BudgetFormModal.tsx | 11 +- .../components/common/amount-input.test.tsx | 144 ++++++ client/src/components/common/amount-input.tsx | 200 +++++++++ .../dashboard-module/AccountList.tsx | 40 +- .../dashboard-module/BulkTransactionModal.tsx | 58 ++- .../RecurringTransactions.tsx | 77 +--- .../SplitTransactionModal.tsx | 93 ++-- .../dashboard-module/TransactionList.tsx | 265 ++++++----- .../dashboard-module/TransferForm.tsx | 416 ------------------ client/src/lib/amount.test.ts | 45 ++ client/src/lib/amount.ts | 167 +++++++ package.json | 2 +- 12 files changed, 836 insertions(+), 682 deletions(-) create mode 100644 client/src/components/common/amount-input.test.tsx create mode 100644 client/src/components/common/amount-input.tsx delete mode 100644 client/src/components/dashboard-module/TransferForm.tsx create mode 100644 client/src/lib/amount.test.ts create mode 100644 client/src/lib/amount.ts diff --git a/client/src/components/budget-module/BudgetFormModal.tsx b/client/src/components/budget-module/BudgetFormModal.tsx index c7f5a98..a075477 100644 --- a/client/src/components/budget-module/BudgetFormModal.tsx +++ b/client/src/components/budget-module/BudgetFormModal.tsx @@ -5,6 +5,8 @@ import { Select } from '../common/select' import { Button } from '../common/button' import { useAlert } from '../../context/AlertContext' import type { Budget, BudgetFormData, BudgetAccountScope, BudgetCategoryScope, BudgetPeriod } from './types' +import { AmountInput } from '../common/amount-input' +import { formatAmount, parseAmount } from '../../lib/amount' type Account = { id: string @@ -92,7 +94,7 @@ export function BudgetFormModal({ setForm({ name: initialData.name ?? '', - amount: String(initialData.amount), + amount: formatAmount(initialData.amount), period: initialData.period, year: year || currentYear, month: month || 1, @@ -111,7 +113,7 @@ export function BudgetFormModal({ const handleSave = async () => { setError(null) - const amountValue = Number(form.amount) + const amountValue = parseAmount(form.amount) if (!amountValue || amountValue <= 0) { setError('Enter a valid amount greater than 0.') return @@ -185,12 +187,11 @@ export function BudgetFormModal({
- setForm(prev => ({ ...prev, amount: event.target.value }))} + onValueChange={amount => setForm(prev => ({ ...prev, amount }))} />
diff --git a/client/src/components/common/amount-input.test.tsx b/client/src/components/common/amount-input.test.tsx new file mode 100644 index 0000000..7187101 --- /dev/null +++ b/client/src/components/common/amount-input.test.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it } from 'vitest' +import { AmountInput } from './amount-input' + +function Harness({ + allowNegative = false, + initialValue = '', +}: { + allowNegative?: boolean + initialValue?: string +}) { + const [value, setValue] = useState(initialValue) + return ( + + ) +} + +describe('AmountInput', () => { + it('preserves grouping while deleting and refilling an existing suffix', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement + + await user.click(input) + + await user.keyboard('{Backspace}') + expect(input).toHaveValue('120 00') + expect(input.selectionStart).toBe(6) + + await user.keyboard('{Backspace}') + expect(input).toHaveValue('120 0') + expect(input.selectionStart).toBe(5) + + await user.keyboard('{Backspace}') + expect(input).toHaveValue('120 ') + expect(input.selectionStart).toBe(4) + + await user.keyboard('3') + expect(input).toHaveValue('120 3') + + await user.keyboard('0') + expect(input).toHaveValue('120 30') + + await user.keyboard('0') + expect(input).toHaveValue('120 300') + expect(input.selectionStart).toBe(7) + }) + + it('resumes grouping when the integer grows beyond the session width', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement + + await user.click(input) + await user.keyboard('{Backspace}{Backspace}{Backspace}3000') + + expect(input).toHaveValue('1 203 000') + expect(input.selectionStart).toBe(9) + }) + + it('restores canonical grouping after growing and returning to the original width', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement + + await user.click(input) + await user.keyboard('0') + expect(input).toHaveValue('1 200 000') + + await user.keyboard('{Backspace}') + expect(input).toHaveValue('120 000') + + await user.keyboard('{Backspace}') + expect(input).toHaveValue('120 00') + }) + + it('groups a fresh value as it grows and resets after a full clear', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement + + await user.clear(input) + await user.type(input, '1200') + + expect(input).toHaveValue('1 200') + expect(input.selectionStart).toBe(5) + }) + + it('keeps the caret beside the edited digit when grouping changes in the middle', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) as HTMLInputElement + + await user.click(input) + input.setSelectionRange(1, 1) + await user.keyboard('0') + + expect(input).toHaveValue('1 023') + expect(input.selectionStart).toBe(3) + expect(input.selectionEnd).toBe(3) + }) + + it('canonicalizes a genuinely smaller value on blur', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) + + await user.click(input) + await user.keyboard('{Backspace}{Backspace}{Backspace}') + expect(input).toHaveValue('120 ') + + await user.tab() + expect(input).toHaveValue('120') + }) + + it('supports negative account-style values when enabled', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) + + await user.click(input) + await user.keyboard('{Backspace}{Backspace}{Backspace}300') + + expect(input).toHaveValue('-120 300') + }) + + it('normalizes pasted NBSP grouping and a decimal comma', async () => { + const user = userEvent.setup() + render() + const input = screen.getByRole('textbox', { name: 'Amount' }) + + await user.click(input) + await user.paste('12\u00a0345,67') + + expect(input).toHaveValue('12 345.67') + }) +}) diff --git a/client/src/components/common/amount-input.tsx b/client/src/components/common/amount-input.tsx new file mode 100644 index 0000000..32170bb --- /dev/null +++ b/client/src/components/common/amount-input.tsx @@ -0,0 +1,200 @@ +import * as React from 'react' +import { + formatAmount, + getAmountIntegerDigitCount, + normalizeAmountDraft, +} from '../../lib/amount' +import { Input, type InputProps } from './input' + +export interface AmountInputProps extends Omit< + InputProps, + 'defaultValue' | 'inputMode' | 'onChange' | 'type' | 'value' +> { + value: string + onValueChange?: (value: string) => void + onChange?: React.ChangeEventHandler + allowNegative?: boolean +} + +type PendingSelection = { + start: number + end: number + value: string +} + +const isGroupSeparator = (character: string) => /[\s\u00a0\u202f]/.test(character) + +function logicalCharacterCount(value: string, end: number): number { + let count = 0 + for (const character of value.slice(0, end)) { + if (!isGroupSeparator(character)) count += 1 + } + return count +} + +function positionAfterLogicalCharacters(value: string, logicalCount: number): number { + if (logicalCount === 0) return 0 + + let seen = 0 + for (let index = 0; index < value.length; index += 1) { + if (!isGroupSeparator(value[index])) seen += 1 + if (seen === logicalCount) return index + 1 + } + + return value.length +} + +function mapSelection( + candidate: string, + formatted: string, + selectionStart: number, + selectionEnd: number, +): Pick { + if (candidate === formatted) { + return { start: selectionStart, end: selectionEnd } + } + + return { + start: positionAfterLogicalCharacters( + formatted, + logicalCharacterCount(candidate, selectionStart), + ), + end: positionAfterLogicalCharacters( + formatted, + logicalCharacterCount(candidate, selectionEnd), + ), + } +} + +function assignRef(ref: React.ForwardedRef, value: T | null) { + if (typeof ref === 'function') { + ref(value) + } else if (ref) { + ref.current = value + } +} + +export const AmountInput = React.forwardRef( + ( + { + allowNegative = false, + onBlur, + onChange, + onFocus, + onValueChange, + value, + ...props + }, + forwardedRef, + ) => { + const inputRef = React.useRef(null) + const focusedRef = React.useRef(false) + const originalIntegerDigitsRef = React.useRef(getAmountIntegerDigitCount(value)) + const pendingSelectionRef = React.useRef(null) + const lastRenderedValueRef = React.useRef(value) + const lastEmittedValueRef = React.useRef(null) + + const setInputRef = React.useCallback((node: HTMLInputElement | null) => { + inputRef.current = node + assignRef(forwardedRef, node) + }, [forwardedRef]) + + React.useLayoutEffect(() => { + const pending = pendingSelectionRef.current + const input = inputRef.current + if (!pending || !input || input.value !== pending.value) return + + input.setSelectionRange(pending.start, pending.end) + pendingSelectionRef.current = null + }) + + React.useEffect(() => { + if (value === lastEmittedValueRef.current) { + lastEmittedValueRef.current = null + } else if (focusedRef.current && value !== lastRenderedValueRef.current) { + originalIntegerDigitsRef.current = getAmountIntegerDigitCount(value) + } + + lastRenderedValueRef.current = value + }, [value]) + + const emitValue = React.useCallback((nextValue: string) => { + lastEmittedValueRef.current = nextValue + lastRenderedValueRef.current = nextValue + onValueChange?.(nextValue) + }, [onValueChange]) + + const handleChange = (event: React.ChangeEvent) => { + const candidate = normalizeAmountDraft(event.currentTarget.value, { allowNegative }) + if (candidate === null) return + + const integerDigits = getAmountIntegerDigitCount(candidate) + const selectionStart = event.currentTarget.selectionStart ?? candidate.length + const selectionEnd = event.currentTarget.selectionEnd ?? selectionStart + + let nextValue: string + + if (candidate === '') { + originalIntegerDigitsRef.current = 0 + nextValue = '' + } else { + // Keep the user's existing group scaffold only while the value is below + // the precision it had when this edit started. If it grew first and is + // then reduced back to that original precision, canonicalize it again. + if ( + candidate.includes(' ') && + integerDigits < originalIntegerDigitsRef.current + ) { + nextValue = candidate + } else { + nextValue = formatAmount(candidate, { allowNegative }) + } + } + + const mapped = mapSelection(candidate, nextValue, selectionStart, selectionEnd) + pendingSelectionRef.current = { ...mapped, value: nextValue } + + event.currentTarget.value = nextValue + emitValue(nextValue) + onChange?.(event) + event.currentTarget.setSelectionRange(mapped.start, mapped.end) + } + + const handleFocus = (event: React.FocusEvent) => { + focusedRef.current = true + originalIntegerDigitsRef.current = getAmountIntegerDigitCount(event.currentTarget.value) + lastRenderedValueRef.current = event.currentTarget.value + onFocus?.(event) + } + + const handleBlur = (event: React.FocusEvent) => { + const canonical = formatAmount(event.currentTarget.value, { allowNegative }) + + focusedRef.current = false + originalIntegerDigitsRef.current = getAmountIntegerDigitCount(canonical) + + if (canonical !== value) { + event.currentTarget.value = canonical + emitValue(canonical) + onChange?.(event as unknown as React.ChangeEvent) + } + + onBlur?.(event) + } + + return ( + + ) + }, +) + +AmountInput.displayName = 'AmountInput' diff --git a/client/src/components/dashboard-module/AccountList.tsx b/client/src/components/dashboard-module/AccountList.tsx index 9c568f6..e8d1be1 100644 --- a/client/src/components/dashboard-module/AccountList.tsx +++ b/client/src/components/dashboard-module/AccountList.tsx @@ -12,6 +12,8 @@ import { useAlert } from '../../context/AlertContext' import { SplitTransactionModal } from './SplitTransactionModal' import type { SplitTransaction } from './SplitTransactionModal' import { AdjustmentChoiceModal } from './AdjustmentChoiceModal' +import { AmountInput } from '../common/amount-input' +import { formatAmount, parseAmount } from '../../lib/amount' type Account = { id: string @@ -269,11 +271,14 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A setIsSubmitting(true) try { const wasEditing = !!editingId - const balanceValue = formData.balance.replace(/\s/g, '').trim() + const balanceValue = parseAmount(formData.balance) + if (formData.balance.trim() !== '' && balanceValue === null) { + throw new Error('Please enter a valid balance') + } const payload: any = { name: formData.name, type: formData.type, - balance: balanceValue === '' ? 0 : parseFloat(balanceValue) || 0, + balance: balanceValue ?? 0, currency: formData.currency } @@ -334,18 +339,10 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A } const handleEdit = (account: Account) => { - const balanceStr = account.balance.toString() - const formattedBalance = balanceStr.includes('.') - ? (() => { - const [integer, decimal] = balanceStr.split('.') - return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal - })() - : balanceStr.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({ name: account.name, type: account.type, - balance: formattedBalance, + balance: formatAmount(account.balance, { maximumFractionDigits: 8 }), currency: account.currency, symbol: account.symbol || '', asset_type: account.asset_type || 'stock', @@ -637,26 +634,11 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A )}
- { - let value = e.target.value.replace(/\s/g, '') // Remove spaces - // Allow only numbers and one decimal point - if (!/^\d*\.?\d*$/.test(value)) return - - // Format with spaces - if (value.includes('.')) { - const [integer, decimal] = value.split('.') - const formatted = integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + (decimal !== undefined ? '.' + decimal : '') - setFormData({ ...formData, balance: formatted }) - } else { - const formatted = value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({ ...formData, balance: formatted }) - } - }} + onValueChange={balance => setFormData({ ...formData, balance })} + allowNegative placeholder="0" />
diff --git a/client/src/components/dashboard-module/BulkTransactionModal.tsx b/client/src/components/dashboard-module/BulkTransactionModal.tsx index 2c5822f..a17cdc9 100644 --- a/client/src/components/dashboard-module/BulkTransactionModal.tsx +++ b/client/src/components/dashboard-module/BulkTransactionModal.tsx @@ -6,6 +6,8 @@ import { Label } from '../common/label' import { Select } from '../common/select' import { Plus, Trash2, AlertCircle, Percent } from 'lucide-react' import { useAlert } from '../../context/AlertContext' +import { AmountInput } from '../common/amount-input' +import { formatAmount, formatCalculatedAmount, parseAmount } from '../../lib/amount' type Category = { id: string @@ -103,20 +105,9 @@ export function BulkTransactionModal({ })) } - const formatAmount = (value: string): string => { - const cleaned = value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(cleaned)) return value - - if (cleaned.includes('.')) { - const [integer, decimal] = cleaned.split('.') - return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + (decimal || '') - } - return cleaned.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - } - - const parsedTotal = parseFloat(totalAmount.replace(/\s/g, '')) || 0 + const parsedTotal = parseAmount(totalAmount) || 0 const allocatedAmount = transactions.reduce((sum, t) => { - return sum + (parseFloat(t.amount.replace(/\s/g, '')) || 0) + return sum + (parseAmount(t.amount) || 0) }, 0) const remaining = parsedTotal - allocatedAmount @@ -125,7 +116,7 @@ export function BulkTransactionModal({ Math.abs(remaining) < 0.01 && transactions.every(t => t.amount && - parseFloat(t.amount.replace(/\s/g, '')) > 0 && + (parseAmount(t.amount) ?? 0) > 0 && t.account_id ) @@ -154,10 +145,15 @@ export function BulkTransactionModal({ const handleEqualSplit = () => { if (parsedTotal <= 0 || transactions.length === 0) return - const amountPerSplit = parsedTotal / transactions.length - setTransactions(transactions.map(t => ({ + const totalCents = Math.round(parsedTotal * 100) + const baseCents = Math.floor(totalCents / transactions.length) + const extraCents = totalCents % transactions.length + setTransactions(transactions.map((t, index) => ({ ...t, - amount: formatAmount(amountPerSplit.toFixed(2)) + amount: formatCalculatedAmount( + (baseCents + (index < extraCents ? 1 : 0)) / 100, + { maximumFractionDigits: 2 }, + ) }))) } @@ -165,12 +161,12 @@ export function BulkTransactionModal({ // Put all remaining amount into the last transaction if (transactions.length > 0 && remaining > 0) { const lastTx = transactions[transactions.length - 1] - const currentAmount = parseFloat(lastTx.amount.replace(/\s/g, '')) || 0 + const currentAmount = parseAmount(lastTx.amount) || 0 const newAmount = currentAmount + remaining setTransactions(transactions.map((t, idx) => idx === transactions.length - 1 - ? { ...t, amount: formatAmount(newAmount.toFixed(2)) } + ? { ...t, amount: formatCalculatedAmount(newAmount, { maximumFractionDigits: 2 }) } : t )) } @@ -178,12 +174,12 @@ export function BulkTransactionModal({ const handleQuickFill = () => { if (transactions.length === 1 && parsedTotal > 0) { - setTransactions([{ ...transactions[0], amount: formatAmount(parsedTotal.toFixed(2)) }]) + setTransactions([{ ...transactions[0], amount: formatCalculatedAmount(parsedTotal, { maximumFractionDigits: 2 }) }]) } } const getPercentage = (amount: string) => { - const val = parseFloat(amount.replace(/\s/g, '')) || 0 + const val = parseAmount(amount) || 0 if (parsedTotal === 0) return 0 return (val / parsedTotal) * 100 } @@ -199,11 +195,9 @@ export function BulkTransactionModal({
- setTotalAmount(formatAmount(e.target.value))} + onValueChange={setTotalAmount} placeholder="Enter total amount to split" className="h-11 sm:h-10 text-lg font-semibold" /> @@ -241,14 +235,14 @@ export function BulkTransactionModal({
Allocated: - {allocatedAmount.toLocaleString()} / {parsedTotal.toLocaleString()} {accountCurrency} + {formatAmount(allocatedAmount)} / {formatAmount(parsedTotal)} {accountCurrency}
{Math.abs(remaining) >= 0.01 && (
- Remaining: {remaining.toFixed(2)} {accountCurrency} + Remaining: {formatCalculatedAmount(remaining, { maximumFractionDigits: 2 })} {accountCurrency}
)} @@ -299,7 +293,7 @@ export function BulkTransactionModal({
{transactions.map((tx, index) => { const percentage = getPercentage(tx.amount) - const txAmount = parseFloat(tx.amount.replace(/\s/g, '')) || 0 + const txAmount = parseAmount(tx.amount) || 0 return (
@@ -335,7 +329,7 @@ export function BulkTransactionModal({ value={txAmount} onChange={(e) => { const value = parseFloat(e.target.value) - updateTransaction(tx.id, 'amount', formatAmount(value.toFixed(2))) + updateTransaction(tx.id, 'amount', formatCalculatedAmount(value, { maximumFractionDigits: 2 })) }} className="w-full h-3 sm:h-2 bg-secondary rounded-lg appearance-none cursor-pointer accent-primary touch-manipulation" style={{ @@ -348,12 +342,10 @@ export function BulkTransactionModal({
- updateTransaction(tx.id, 'amount', formatAmount(e.target.value))} + onValueChange={amount => updateTransaction(tx.id, 'amount', amount)} placeholder="0" className="h-10 sm:h-9 text-sm" /> diff --git a/client/src/components/dashboard-module/RecurringTransactions.tsx b/client/src/components/dashboard-module/RecurringTransactions.tsx index fe425aa..9085de9 100644 --- a/client/src/components/dashboard-module/RecurringTransactions.tsx +++ b/client/src/components/dashboard-module/RecurringTransactions.tsx @@ -8,6 +8,8 @@ import { Select } from '../common/select' import { Plus, Trash2, Edit2, Clock, TrendingDown, TrendingUp, AlertTriangle, Calendar, ChevronLeft, ChevronRight, ChevronDown, Loader2 } from 'lucide-react' import { useAlert } from '../../context/AlertContext' import { usePrivacy } from '../../context/PrivacyContext' +import { AmountInput } from '../common/amount-input' +import { formatAmount, parseAmount } from '../../lib/amount' // Helper function to convert Date to local YYYY-MM-DD string (no timezone conversion) function toLocalDateString(date: Date): string { @@ -45,7 +47,7 @@ type RecurringSchedule = { to_account_id?: string category_id?: string amount: number - amount_to?: number + amount_to?: number | null description?: string is_active: boolean created_at: number @@ -153,8 +155,8 @@ export function RecurringTransactions({ e.preventDefault() if (isSubmitting) return - const amount = parseFloat(formData.amount.replace(/\s/g, '')) - if (isNaN(amount) || amount <= 0) { + const amount = parseAmount(formData.amount) + if (amount === null || amount <= 0) { showAlert({ type: 'error', message: 'Please enter a valid amount' }) return } @@ -195,10 +197,13 @@ export function RecurringTransactions({ } else if (formData.type === 'transfer') { payload.to_account_id = formData.to_account_id if (formData.amount_to) { - const amountTo = parseFloat(formData.amount_to.replace(/\s/g, '')) - if (!isNaN(amountTo) && amountTo > 0) { - payload.amount_to = amountTo + const amountTo = parseAmount(formData.amount_to) + if (amountTo === null || amountTo <= 0) { + showAlert({ type: 'error', message: 'Please enter a valid amount to receive' }) + setIsSubmitting(false) + return } + payload.amount_to = amountTo } } @@ -273,8 +278,10 @@ export function RecurringTransactions({ account_id: schedule.account_id, to_account_id: schedule.to_account_id || '', category_id: schedule.category_id || '', - amount: Math.abs(schedule.amount).toString(), - amount_to: schedule.amount_to?.toString() || '', + amount: formatAmount(Math.abs(schedule.amount), { maximumFractionDigits: 8 }), + amount_to: schedule.amount_to === undefined || schedule.amount_to === null + ? '' + : formatAmount(schedule.amount_to, { maximumFractionDigits: 8 }), description: schedule.description || '', transaction_type: schedule.amount < 0 ? 'expense' : 'income', limit_type: limit_type, @@ -1145,24 +1152,10 @@ export function RecurringTransactions({
- { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({ ...formData, amount: formatted }) - }} - onKeyDown={e => { - if (e.key.length === 1 && !/[0-9.]/.test(e.key) && !e.ctrlKey && !e.metaKey) { - e.preventDefault() - } - }} + onValueChange={amount => setFormData({ ...formData, amount })} required />
@@ -1211,48 +1204,20 @@ export function RecurringTransactions({
- { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({ ...formData, amount: formatted }) - }} - onKeyDown={e => { - if (e.key.length === 1 && !/[0-9.]/.test(e.key) && !e.ctrlKey && !e.metaKey) { - e.preventDefault() - } - }} + onValueChange={amount => setFormData({ ...formData, amount })} required />
- { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({ ...formData, amount_to: formatted }) - }} - onKeyDown={e => { - if (e.key.length === 1 && !/[0-9.]/.test(e.key) && !e.ctrlKey && !e.metaKey) { - e.preventDefault() - } - }} + onValueChange={amountTo => setFormData({ ...formData, amount_to: amountTo })} placeholder="Leave empty if same currency" />
diff --git a/client/src/components/dashboard-module/SplitTransactionModal.tsx b/client/src/components/dashboard-module/SplitTransactionModal.tsx index c66c938..0db9a34 100644 --- a/client/src/components/dashboard-module/SplitTransactionModal.tsx +++ b/client/src/components/dashboard-module/SplitTransactionModal.tsx @@ -5,6 +5,8 @@ import { Input } from '../common/input' import { Label } from '../common/label' import { Select } from '../common/select' import { Plus, Trash2, AlertCircle, Percent } from 'lucide-react' +import { AmountInput } from '../common/amount-input' +import { formatAmount, formatCalculatedAmount, parseAmount } from '../../lib/amount' type Category = { id: string @@ -21,6 +23,8 @@ export type SplitTransaction = { date: string } +type SplitDraft = Omit & { amount: string } + interface SplitTransactionModalProps { isOpen: boolean onClose: () => void @@ -40,11 +44,11 @@ export function SplitTransactionModal({ categories, defaultDate = new Date().toISOString().split('T')[0] }: SplitTransactionModalProps) { - const [splits, setSplits] = useState([ + const [splits, setSplits] = useState([ { id: crypto.randomUUID(), description: '', - amount: 0, + amount: '', category_id: '', date: defaultDate } @@ -57,7 +61,7 @@ export function SplitTransactionModal({ { id: crypto.randomUUID(), description: '', - amount: 0, + amount: '', category_id: '', date: defaultDate } @@ -71,7 +75,7 @@ export function SplitTransactionModal({ { id: crypto.randomUUID(), description: '', - amount: 0, + amount: '', category_id: '', date: defaultDate } @@ -84,7 +88,7 @@ export function SplitTransactionModal({ } } - const updateSplit = (id: string, field: keyof SplitTransaction, value: string | number) => { + const updateSplit = (id: string, field: keyof SplitDraft, value: string) => { setSplits(splits.map(s => s.id === id ? { ...s, [field]: value } : s)) } @@ -92,18 +96,30 @@ export function SplitTransactionModal({ // Ensure the amount doesn't exceed remaining budget const otherSplitsTotal = splits .filter(s => s.id !== id) - .reduce((sum, s) => sum + (parseFloat(s.amount.toString()) || 0), 0) + .reduce((sum, s) => sum + (parseAmount(s.amount) || 0), 0) - const maxAmount = Math.abs(totalAmount) - Math.abs(otherSplitsTotal) - const clampedValue = Math.min(Math.abs(value), Math.abs(maxAmount)) - const finalValue = totalAmount < 0 ? -clampedValue : clampedValue + const maxAmount = Math.max(Math.abs(totalAmount) - Math.abs(otherSplitsTotal), 0) + const clampedValue = Math.min(Math.abs(value), maxAmount) - setSplits(splits.map(s => s.id === id ? { ...s, amount: finalValue } : s)) + setSplits(splits.map(s => s.id === id + ? { ...s, amount: formatCalculatedAmount(clampedValue, { maximumFractionDigits: 8 }) } + : s + )) } const handleEqualSplit = () => { - const amountPerSplit = totalAmount / splits.length - setSplits(splits.map(s => ({ ...s, amount: amountPerSplit }))) + const absoluteTotal = Math.abs(totalAmount) + const amountPerSplit = absoluteTotal / splits.length + let allocated = 0 + + setSplits(splits.map((s, index) => { + const value = index === splits.length - 1 + ? absoluteTotal - allocated + : amountPerSplit + const amount = formatCalculatedAmount(value, { maximumFractionDigits: 8 }) + allocated += parseAmount(amount) || 0 + return { ...s, amount } + })) } const handleAutoBalance = () => { @@ -111,21 +127,22 @@ export function SplitTransactionModal({ if (splits.length > 0) { const otherSplitsTotal = splits .slice(0, -1) - .reduce((sum, s) => sum + (parseFloat(s.amount.toString()) || 0), 0) - const remainingForLast = totalAmount - otherSplitsTotal + .reduce((sum, s) => sum + (parseAmount(s.amount) || 0), 0) + const remainingForLast = Math.max(Math.abs(totalAmount) - otherSplitsTotal, 0) setSplits(splits.map((s, idx) => idx === splits.length - 1 - ? { ...s, amount: remainingForLast } + ? { ...s, amount: formatCalculatedAmount(remainingForLast, { maximumFractionDigits: 8 }) } : s )) } } - const totalSplitAmount = splits.reduce((sum, split) => sum + (parseFloat(split.amount.toString()) || 0), 0) + const direction = totalAmount < 0 ? -1 : 1 + const totalSplitAmount = direction * splits.reduce((sum, split) => sum + (parseAmount(split.amount) || 0), 0) const remaining = totalAmount - totalSplitAmount - const isValid = Math.abs(remaining) < 0.01 && splits.every(s => s.amount !== 0) + const isValid = Math.abs(remaining) < 0.01 && splits.every(s => (parseAmount(s.amount) || 0) > 0) const getSplitPercentage = (amount: number) => { if (totalAmount === 0) return 0 @@ -134,14 +151,20 @@ export function SplitTransactionModal({ const handleConfirm = () => { if (isValid) { - onConfirm(splits) + onConfirm(splits.map(split => ({ + ...split, + amount: direction * (parseAmount(split.amount) || 0) + }))) onClose() } } const handleQuickFill = () => { if (splits.length === 1 && totalAmount !== 0) { - setSplits([{ ...splits[0], amount: totalAmount }]) + setSplits([{ + ...splits[0], + amount: formatCalculatedAmount(Math.abs(totalAmount), { maximumFractionDigits: 8 }) + }]) } } @@ -157,14 +180,14 @@ export function SplitTransactionModal({
Total Amount: = 0 ? 'text-green-500' : 'text-red-500'}`}> - {totalAmount >= 0 ? '+' : ''}{totalAmount.toFixed(2)} {accountCurrency} + {totalAmount >= 0 ? '+' : '-'}{formatCalculatedAmount(Math.abs(totalAmount), { maximumFractionDigits: 8 })} {accountCurrency}
{Math.abs(remaining) > 0.01 && (
- Remaining: {remaining >= 0 ? '+' : ''}{remaining.toFixed(2)} {accountCurrency} + Remaining: {remaining >= 0 ? '+' : '-'}{formatCalculatedAmount(Math.abs(remaining), { maximumFractionDigits: 8 })} {accountCurrency}
)} @@ -198,8 +221,8 @@ export function SplitTransactionModal({ {/* Splits */}
{splits.map((split, index) => { - const percentage = getSplitPercentage(split.amount) - const absAmount = Math.abs(split.amount) + const absAmount = parseAmount(split.amount) || 0 + const percentage = getSplitPercentage(absAmount) const maxSliderValue = Math.abs(totalAmount) return ( @@ -226,7 +249,7 @@ export function SplitTransactionModal({
Adjust amount - {split.amount >= 0 ? '+' : ''}{absAmount.toFixed(2)} {accountCurrency} + {totalAmount >= 0 ? '+' : '-'}{formatCalculatedAmount(absAmount, { maximumFractionDigits: 8 })} {accountCurrency}
0 - {maxSliderValue.toFixed(0)} {accountCurrency} + {formatAmount(maxSliderValue)} {accountCurrency}
@@ -263,14 +286,22 @@ export function SplitTransactionModal({
- { - const value = parseFloat(e.target.value) || 0 - updateSplitAmount(split.id, totalAmount < 0 ? -value : value) + value={split.amount} + onValueChange={value => { + const numericValue = parseAmount(value) + const otherSplitsTotal = splits + .filter(item => item.id !== split.id) + .reduce((sum, item) => sum + (parseAmount(item.amount) || 0), 0) + const maxAmount = Math.max(Math.abs(totalAmount) - otherSplitsTotal, 0) + + if (numericValue !== null && numericValue > maxAmount) { + updateSplitAmount(split.id, maxAmount) + } else { + updateSplit(split.id, 'amount', value) + } }} placeholder="0.00" /> diff --git a/client/src/components/dashboard-module/TransactionList.tsx b/client/src/components/dashboard-module/TransactionList.tsx index 3b70f74..056d3e4 100644 --- a/client/src/components/dashboard-module/TransactionList.tsx +++ b/client/src/components/dashboard-module/TransactionList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react' +import { useState, useEffect, useRef } from 'react' import { Button } from '../common/button' import { Input } from '../common/input' import { Label } from '../common/label' @@ -12,6 +12,8 @@ import { usePrivacy } from '../../context/PrivacyContext' import { useAlert } from '../../context/AlertContext' import { DateRangePicker } from '../common/DateRangePicker' import { BulkTransactionModal, type BulkTransaction } from './BulkTransactionModal' +import { AmountInput } from '../common/amount-input' +import { formatAmount, formatCalculatedAmount, parseAmount } from '../../lib/amount' type Transaction = { id: string @@ -58,6 +60,7 @@ const RECENT_TRANSACTION_MS = 10 * 60 * 1000 const RECENT_TIMESTAMP_GRACE_MS = 60 * 1000 const UPDATED_BADGE_GRACE_MS = 1000 const getLocalDateString = () => format(new Date(), 'yyyy-MM-dd') +const getTransferPairKey = (fromAccountId: string, toAccountId: string) => `${fromAccountId}:${toAccountId}` const isRecentTimestamp = (timestamp: number, now: number) => { return timestamp > 0 @@ -127,9 +130,13 @@ export function TransactionList({ exclude_from_estimate: false }) const [exchangeRate, setExchangeRate] = useState(null) + const [exchangeRateDraft, setExchangeRateDraft] = useState('') const [suggestedRate, setSuggestedRate] = useState(null) const [isLoadingRate, setIsLoadingRate] = useState(false) const [skipAutoCalc, setSkipAutoCalc] = useState(false) + const rateRequestSequenceRef = useRef(0) + const manualRateOverrideRef = useRef(false) + const editedTransferPairRef = useRef(null) const [activeTxId, setActiveTxId] = useState(null) const [categoryFilter, setCategoryFilter] = useState('all') const [sortOrder, setSortOrder] = useState<'date' | 'amount-high' | 'amount-low'>('date') @@ -172,9 +179,15 @@ export function TransactionList({ // Fetch exchange rate when transfer accounts are selected useEffect(() => { + const requestId = ++rateRequestSequenceRef.current + let cancelled = false + const isCurrentRequest = () => !cancelled && rateRequestSequenceRef.current === requestId + if (formData.type !== 'transfer' || !formData.account_id || !formData.to_account_id) { setSuggestedRate(null) setExchangeRate(null) + setExchangeRateDraft('') + setIsLoadingRate(false) return } @@ -184,9 +197,12 @@ export function TransactionList({ if (!fromAccount || !toAccount || fromAccount.currency === toAccount.currency) { setSuggestedRate(null) setExchangeRate(null) + setExchangeRateDraft('') + setIsLoadingRate(false) return } + const pairKey = getTransferPairKey(fromAccount.id, toAccount.id) const fetchRate = async () => { setIsLoadingRate(true) try { @@ -266,24 +282,38 @@ export function TransactionList({ if (directRate) rate = directRate } + if (!isCurrentRequest()) return + + const preserveEditedRate = !!editingId && editedTransferPairRef.current === pairKey + const userChangedRate = manualRateOverrideRef.current + if (rate > 0) { setSuggestedRate(rate) - setExchangeRate(rate) + if (!preserveEditedRate && !userChangedRate) { + setExchangeRate(rate) + setExchangeRateDraft(formatCalculatedAmount(rate, { maximumFractionDigits: 12 })) + } } else { setSuggestedRate(null) - setExchangeRate(null) + if (!preserveEditedRate && !userChangedRate) { + setExchangeRate(null) + setExchangeRateDraft('') + } } } catch (error) { + if (!isCurrentRequest()) return console.error('Failed to fetch exchange rate:', error) setSuggestedRate(null) - setExchangeRate(null) } finally { - setIsLoadingRate(false) + if (isCurrentRequest()) setIsLoadingRate(false) } } fetchRate() - }, [formData.account_id, formData.to_account_id, formData.type, accounts]) + return () => { + cancelled = true + } + }, [formData.account_id, formData.to_account_id, formData.type, accounts, editingId]) // Auto-calculate amount_to when amount or rate changes useEffect(() => { @@ -295,13 +325,18 @@ export function TransactionList({ if (isDifferentCurrency && formData.amount && exchangeRate) { // Different currency: amount_to = amount_from × exchange_rate - const amountFrom = parseFloat(formData.amount.replace(/\s/g, '')) || 0 + const amountFrom = parseAmount(formData.amount) || 0 const calculated = amountFrom * exchangeRate - setFormData(prev => ({ ...prev, amount_to: calculated.toString() })) + const maximumFractionDigits = toAccount?.type === 'investment' ? 8 : 2 + setFormData(prev => ({ + ...prev, + amount_to: formatCalculatedAmount(calculated, { maximumFractionDigits }) + })) } else if (!isDifferentCurrency && formData.amount) { // Same currency: amount_to = amount_from (no fee) - const amountFrom = parseFloat(formData.amount.replace(/\s/g, '')) || 0 - setFormData(prev => ({ ...prev, amount_to: amountFrom.toString() })) + setFormData(prev => ({ ...prev, amount_to: prev.amount })) + } else if (!formData.amount) { + setFormData(prev => prev.amount_to ? ({ ...prev, amount_to: '' }) : prev) } }, [formData.amount, exchangeRate, formData.type, formData.account_id, formData.to_account_id, accounts, skipAutoCalc]) @@ -417,6 +452,15 @@ export function TransactionList({ } } + const resetTransferRateState = () => { + manualRateOverrideRef.current = false + editedTransferPairRef.current = null + setExchangeRate(null) + setExchangeRateDraft('') + setSuggestedRate(null) + setSkipAutoCalc(false) + } + const resetForm = () => { const defaults = loadSavedDefaults('expense') setFormData({ @@ -431,8 +475,7 @@ export function TransactionList({ manual_price: '', exclude_from_estimate: false }) - setExchangeRate(null) - setSuggestedRate(null) + resetTransferRateState() } // When opening the Add form, load defaults @@ -450,6 +493,7 @@ export function TransactionList({ manual_price: '', exclude_from_estimate: false }) + resetTransferRateState() setIsAccountOpen(false) setIsAdding(true) } @@ -465,30 +509,54 @@ export function TransactionList({ category_id: defaults.category_id, amount_to: defaults.amount_to || '' }) - setExchangeRate(null) - setSuggestedRate(null) + resetTransferRateState() + } + + const handleTransferAccountChange = ( + field: 'account_id' | 'to_account_id', + accountId: string, + ) => { + resetTransferRateState() + setFormData(prev => ({ ...prev, [field]: accountId, amount_to: '' })) } const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() if (isSubmitting) return + + const amount = parseAmount(formData.amount) + if (amount === null || amount <= 0) { + showAlert({ type: 'error', message: 'Please enter a valid amount greater than 0' }) + return + } setIsSubmitting(true) try { - const amount = parseFloat(formData.amount.replace(/\s/g, '')) const wasEditing = !!editingId const savedType = formData.type const savedAsUpcoming = isUpcomingForm if (formData.type === 'transfer') { // Handle transfer - const amountTo = parseFloat(formData.amount_to.replace(/\s/g, '')) || amount + const sourceAccount = accounts.find(a => a.id === formData.account_id) const toAccount = accounts.find(a => a.id === formData.to_account_id) + const isSameCurrency = sourceAccount && toAccount + && sourceAccount.currency === toAccount.currency + const receivedDraft = parseAmount(formData.amount_to) + const parsedAmountTo = receivedDraft ?? (isSameCurrency ? amount : null) + if (parsedAmountTo === null || parsedAmountTo <= 0) { + throw new Error('Please enter a valid amount to receive greater than 0') + } + const amountTo = parsedAmountTo let price = undefined // For transfers to investment accounts, include price if (toAccount?.type === 'investment' && formData.manual_price) { - price = parseFloat(formData.manual_price.replace(/\s/g, '')) + const parsedPrice = parseAmount(formData.manual_price) + if (parsedPrice === null || parsedPrice <= 0) { + throw new Error('Please enter a valid price greater than 0') + } + price = parsedPrice console.log(`Transfer to investment: ${amountTo} shares @ $${price}`) } @@ -547,7 +615,11 @@ export function TransactionList({ let price = undefined if (account?.type === 'investment' && formData.manual_price) { - price = parseFloat(formData.manual_price) + const parsedPrice = parseAmount(formData.manual_price) + if (parsedPrice === null || parsedPrice <= 0) { + throw new Error('Please enter a valid price greater than 0') + } + price = parsedPrice console.log(`Using price for ${account.symbol}: $${price}`) } @@ -606,34 +678,48 @@ export function TransactionList({ } } - // Format numbers with spaces - const formatNumber = (num: number) => { - const str = Math.abs(num).toString() - return str.includes('.') - ? (() => { const [integer, decimal] = str.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : str.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - } + const formatNumber = (num: number) => formatAmount(Math.abs(num), { maximumFractionDigits: 8 }) const relatedTx = (tx as Transaction & { relatedTx?: Transaction }).relatedTx || (tx.linked_transaction_id ? allKnownTransactions.find(t => t.id === tx.linked_transaction_id) : undefined) + resetTransferRateState() + if (tx.linked_transaction_id && relatedTx) { const outgoing = tx.amount < 0 ? tx : relatedTx const incoming = tx.amount < 0 ? relatedTx : tx const transferNote = (outgoing.description || '').split(' - ').slice(1).join(' - ') + const outgoingAccount = accounts.find(account => account.id === outgoing.account_id) + const incomingAccount = accounts.find(account => account.id === incoming.account_id) + const incomingValue = incomingAccount?.type === 'investment' && incoming.quantity !== undefined + ? Math.abs(incoming.quantity) + : Math.abs(incoming.amount) + const existingRate = Math.abs(outgoing.amount) > 0 + ? incomingValue / Math.abs(outgoing.amount) + : null setFormData({ account_id: outgoing.account_id, to_account_id: incoming.account_id, category_id: '', amount: formatNumber(outgoing.amount), - amount_to: formatNumber(incoming.amount), + amount_to: formatNumber(incomingValue), description: transferNote, date: outgoing.date, type: 'transfer', manual_price: '', exclude_from_estimate: false }) + const isDifferentCurrency = outgoingAccount && incomingAccount + && outgoingAccount.currency !== incomingAccount.currency + const historicalRate = isDifferentCurrency ? existingRate : null + editedTransferPairRef.current = getTransferPairKey(outgoing.account_id, incoming.account_id) + setSkipAutoCalc(true) + setExchangeRate(historicalRate) + setExchangeRateDraft(historicalRate === null + ? '' + : formatCalculatedAmount(historicalRate, { maximumFractionDigits: 12 }) + ) setEditingId(outgoing.id) setIsAccountOpen(false) setIsAdding(true) @@ -800,8 +886,9 @@ export function TransactionList({ const handleBulkTransactionConfirm = async (bulkTransactions: BulkTransaction[]) => { // Create all transactions one by one for (const tx of bulkTransactions) { - const amount = parseFloat(tx.amount.replace(/\s/g, '')) - const finalAmount = tx.type === 'expense' ? -Math.abs(amount) : Math.abs(amount) + const amount = parseAmount(tx.amount) + const numericAmount = amount ?? 0 + const finalAmount = tx.type === 'expense' ? -Math.abs(numericAmount) : Math.abs(numericAmount) await apiFetch(`${API_BASE_URL}/transactions`, { method: 'POST', @@ -860,8 +947,8 @@ export function TransactionList({ // For transfer preview const fromAccount = accounts.find(a => a.id === formData.account_id) const toAccount = accounts.find(a => a.id === formData.to_account_id) - const transferAmount = parseFloat(formData.amount.replace(/\s/g, '')) || 0 - const transferAmountTo = parseFloat(formData.amount_to.replace(/\s/g, '')) || transferAmount + const transferAmount = parseAmount(formData.amount) || 0 + const transferAmountTo = parseAmount(formData.amount_to) || transferAmount const isEditingTransfer = !!editingId && formData.type === 'transfer' const isEditingStandardTransaction = !!editingId && formData.type !== 'transfer' @@ -1304,7 +1391,7 @@ export function TransactionList({ setFormData({...formData, to_account_id: e.target.value})} + onChange={e => handleTransferAccountChange('to_account_id', e.target.value)} required > @@ -1333,19 +1420,13 @@ export function TransactionList({ - { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({...formData, amount: formatted}) - }} + onValueChange={amount => { + setSkipAutoCalc(false) + setFormData({...formData, amount}) + }} placeholder="0.00" required /> @@ -1354,37 +1435,33 @@ export function TransactionList({ - { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + onValueChange={amountTo => { + manualRateOverrideRef.current = true setSkipAutoCalc(true) - setFormData({...formData, amount_to: formatted}) + setFormData({...formData, amount_to: amountTo}) // Recalculate exchange rate if different currencies - if (fromAccount && toAccount && formData.amount && value) { - const amountFrom = parseFloat(formData.amount.replace(/\s/g, '')) || 0 - const amountTo = parseFloat(value) || 0 + if (fromAccount && toAccount && formData.amount && amountTo) { + const amountFrom = parseAmount(formData.amount) || 0 + const receivedAmount = parseAmount(amountTo) || 0 - if (fromAccount.currency !== toAccount.currency && amountFrom > 0 && amountTo > 0) { + if (fromAccount.currency !== toAccount.currency && amountFrom > 0 && receivedAmount > 0) { // Different currency: recalculate exchange rate // Formula: amount_to = amount_from × rate // So: rate = amount_to / amount_from - const newRate = amountTo / amountFrom + const newRate = receivedAmount / amountFrom setExchangeRate(newRate) + setExchangeRateDraft(formatCalculatedAmount(newRate, { maximumFractionDigits: 12 })) } } }} onBlur={() => setSkipAutoCalc(false)} placeholder="0.00" required + disabled={!!fromAccount && !!toAccount && fromAccount.currency === toAccount.currency} />
@@ -1404,29 +1481,22 @@ export function TransactionList({ Suggested: 1 {fromAccount.currency} = {suggestedRate} {toAccount.currency}

)} - { - let value = e.target.value.replace(/\s/g, '') - if (value === '') { - setExchangeRate(null) - return - } - if (!/^\d*\.?\d*$/.test(value)) return - const rate = parseFloat(value) - if (!isNaN(rate) && rate > 0) { - setExchangeRate(rate) - } - }} + value={exchangeRateDraft} + onValueChange={value => { + manualRateOverrideRef.current = true + setSkipAutoCalc(false) + setExchangeRateDraft(value) + const rate = parseAmount(value, { allowNegative: false }) + setExchangeRate(rate !== null && rate > 0 ? rate : null) + }} placeholder="Enter custom rate" className="bg-background" /> {exchangeRate && formData.amount && (

- {parseFloat(formData.amount.replace(/\s/g, ''))} {fromAccount.currency} = {parseFloat(formData.amount.replace(/\s/g, '')) * exchangeRate} {toAccount.currency} + {formData.amount} {fromAccount.currency} = {formatCalculatedAmount((parseAmount(formData.amount) || 0) * exchangeRate, { maximumFractionDigits: toAccount.type === 'investment' ? 8 : 2 })} {toAccount.currency}

)} @@ -1438,19 +1508,10 @@ export function TransactionList({ {toAccount?.type === 'investment' && (
- { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({...formData, manual_price: formatted}) - }} + onValueChange={manualPrice => setFormData({...formData, manual_price: manualPrice})} placeholder="Auto-fetch from market data" />

For old dates (before 2020), enter the price manually for accuracy

@@ -1485,18 +1546,18 @@ export function TransactionList({

Preview

{fromAccount.name} - -{transferAmount} {fromAccount.currency} + -{formatAmount(transferAmount)} {fromAccount.currency}
{toAccount.name} - +{transferAmountTo} {toAccount.type === 'investment' ? 'shares' : toAccount.currency} + +{formatAmount(transferAmountTo, { maximumFractionDigits: 8 })} {toAccount.type === 'investment' ? 'shares' : toAccount.currency}
{toAccount.type === 'investment' && formData.manual_price && (
@ ${formData.manual_price}/share - ${(transferAmountTo * parseFloat(formData.manual_price)).toFixed(2)} USD value + ${formatCalculatedAmount(transferAmountTo * (parseAmount(formData.manual_price) || 0), { maximumFractionDigits: 2 })} USD value
)}
@@ -1513,19 +1574,10 @@ export function TransactionList({
- { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({...formData, amount: formatted}) - }} + onValueChange={amount => setFormData({...formData, amount})} placeholder="0" required /> @@ -1582,19 +1634,10 @@ export function TransactionList({ {accounts.find(a => a.id === formData.account_id)?.type === 'investment' && (
- { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFormData({...formData, manual_price: formatted}) - }} + onValueChange={manualPrice => setFormData({...formData, manual_price: manualPrice})} placeholder="Auto-fetch from market data" />

For old dates (before 2020), enter the price manually for accuracy

diff --git a/client/src/components/dashboard-module/TransferForm.tsx b/client/src/components/dashboard-module/TransferForm.tsx deleted file mode 100644 index 64b9e2d..0000000 --- a/client/src/components/dashboard-module/TransferForm.tsx +++ /dev/null @@ -1,416 +0,0 @@ -import { useState, useEffect } from 'react' -import { Button } from '../common/button' -import { Input } from '../common/input' -import { Label } from '../common/label' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../common/card' -import { Select } from '../common/select' -import { API_BASE_URL, apiFetch } from '../../config' -import { useAlert } from '../../context/AlertContext' -interface Account { - id: string - name: string - type: string - balance: number - currency: string - symbol?: string - asset_type?: string - is_locked?: boolean -} - -interface TransferFormProps { - accounts: Account[] - onTransferComplete: () => void -} - -export default function TransferForm({ accounts, onTransferComplete }: TransferFormProps) { - const { showAlert } = useAlert() - const [fromAccountId, setFromAccountId] = useState('') - const [toAccountId, setToAccountId] = useState('') - const [amountFrom, setAmountFrom] = useState('') - const [amountTo, setAmountTo] = useState('') - const [fee, setFee] = useState('0') - const [description, setDescription] = useState('') - const [date, setDate] = useState(new Date().toISOString().split('T')[0]) - const [exchangeRate, setExchangeRate] = useState(null) - const [suggestedRate, setSuggestedRate] = useState(null) - const [isLoadingRate, setIsLoadingRate] = useState(false) - const [isSubmitting, setIsSubmitting] = useState(false) - - const fromAccount = accounts.find(a => a.id === fromAccountId) - const toAccount = accounts.find(a => a.id === toAccountId) - const isDifferentCurrency = fromAccount && toAccount && fromAccount.currency !== toAccount.currency - - // Fetch exchange rate when accounts are selected - useEffect(() => { - if (!fromAccount || !toAccount || !isDifferentCurrency) { - setSuggestedRate(null) - setExchangeRate(null) - return - } - - const fetchRate = async () => { - setIsLoadingRate(true) - try { - let rate = 0 - - // Helper to get quote price - const getQuotePrice = async (symbol: string) => { - try { - const res = await apiFetch(`${API_BASE_URL}/market/quote?symbol=${encodeURIComponent(symbol)}`) - if (!res.ok) return null - const data = await res.json() - return { price: data.regularMarketPrice, currency: data.currency } - } catch (e) { - console.error('Quote fetch failed', e) - return null - } - } - - // Helper to get FX rate - const getFxRate = async (from: string, to: string) => { - if (from === to) return 1 - try { - const res = await apiFetch(`${API_BASE_URL}/transfers/exchange-rate?from=${from}&to=${to}`) - if (!res.ok) return null - const data = await res.json() - return data.rate - } catch (e) { - console.error('FX fetch failed', e) - return null - } - } - - // Case 1: Investment -> Cash/Other - if (fromAccount.type === 'investment' && fromAccount.symbol) { - const quote = await getQuotePrice(fromAccount.symbol) - if (quote && quote.price) { - // Ensure currency is uppercase, trimmed, and default to USD - const quoteCurrency = (quote.currency || 'USD').toUpperCase().trim() - - let fxRate = await getFxRate(quoteCurrency, toAccount.currency) - - // If failed and currency wasn't USD, try USD as fallback (common for crypto quotes) - if (!fxRate && quoteCurrency !== 'USD') { - fxRate = await getFxRate('USD', toAccount.currency) - } - - if (fxRate) { - rate = quote.price * fxRate - } - } - } - // Case 2: Cash/Other -> Investment - else if (toAccount.type === 'investment' && toAccount.symbol) { - const quote = await getQuotePrice(toAccount.symbol) - if (quote && quote.price) { - const quoteCurrency = (quote.currency || 'USD').toUpperCase().trim() - - let fxRate = await getFxRate(fromAccount.currency, quoteCurrency) - - // If failed and currency wasn't USD, try converting From -> USD - if (!fxRate && quoteCurrency !== 'USD') { - fxRate = await getFxRate(fromAccount.currency, 'USD') - } - - if (fxRate) { - rate = fxRate / quote.price - } - } - } - - // Fallback / Case 3: Direct Currency Conversion - // Only try this if we haven't calculated a rate yet AND it's not an investment case that just failed - // (Prevent trying BTC -> HUF if we already failed to get a quote-based rate) - const isInvestmentCase = (fromAccount.type === 'investment' && fromAccount.symbol) || (toAccount.type === 'investment' && toAccount.symbol) - - if (!rate && !isInvestmentCase && fromAccount.currency !== toAccount.currency) { - const directRate = await getFxRate(fromAccount.currency, toAccount.currency) - if (directRate) rate = directRate - } - - if (rate > 0) { - setSuggestedRate(rate) - setExchangeRate(rate) - } else { - setSuggestedRate(null) - setExchangeRate(null) - } - } catch (error) { - console.error('Failed to fetch exchange rate:', error) - setSuggestedRate(null) - setExchangeRate(null) - } finally { - setIsLoadingRate(false) - } - } - - fetchRate() - }, [fromAccountId, toAccountId, fromAccount, toAccount, isDifferentCurrency]) - - // Auto-calculate amountTo when amountFrom or rate changes - useEffect(() => { - if (isDifferentCurrency && amountFrom && exchangeRate) { - const calculated = parseFloat(amountFrom.replace(/\s/g, '')) * exchangeRate - const formatted = calculated.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setAmountTo(formatted) - } else if (!isDifferentCurrency && amountFrom) { - setAmountTo(amountFrom) - } - }, [amountFrom, exchangeRate, isDifferentCurrency]) - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() - - if (!fromAccountId || !toAccountId || !amountFrom || !amountTo) { - showAlert({ - type: 'warning', - title: 'Missing Information', - message: 'Please fill in all required fields' - }) - return - } - - setIsSubmitting(true) - - try { - const response = await apiFetch(`${API_BASE_URL}/transfers`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - from_account_id: fromAccountId, - to_account_id: toAccountId, - amount_from: parseFloat(amountFrom.replace(/\s/g, '')), - amount_to: parseFloat(amountTo.replace(/\s/g, '')), - fee: parseFloat(fee.replace(/\s/g, '')) || 0, - exchange_rate: exchangeRate, - description: description || undefined, - date, - }), - }) - - if (!response.ok) { - const error = await response.json() - throw new Error(error.error || 'Transfer failed') - } - - // Reset form - setFromAccountId('') - setToAccountId('') - setAmountFrom('') - setAmountTo('') - setFee('0') - setDescription('') - setDate(new Date().toISOString().split('T')[0]) - setExchangeRate(null) - setSuggestedRate(null) - - showAlert({ - type: 'success', - title: 'Transfer Complete', - message: 'Your transfer has been processed successfully' - }) - - onTransferComplete() - } catch (error) { - console.error('Transfer error:', error) - showAlert({ - type: 'error', - title: 'Transfer Failed', - message: error instanceof Error ? error.message : 'Failed to create transfer' - }) - } finally { - setIsSubmitting(false) - } - } - - const handleExchangeRateChange = (value: string) => { - const rate = parseFloat(value) - if (!isNaN(rate) && rate > 0) { - setExchangeRate(rate) - } - } - - return ( - - - Transfer Between Accounts - Move money from one account to another - - -
-
-
- - -
- -
- - -
-
- -
-
- - { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setAmountFrom(formatted) - }} - placeholder="0.00" - required - /> -
- -
- - { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setAmountTo(formatted) - }} - placeholder="0.00" - required - disabled={!isDifferentCurrency} - /> -
-
- - {isDifferentCurrency && ( -
- - {isLoadingRate ? ( -

Loading exchange rate...

- ) : ( - <> - {suggestedRate && ( -

- Suggested: 1 {fromAccount?.currency} = {suggestedRate.toFixed(4)} {toAccount?.currency} -

- )} - { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - handleExchangeRateChange(value) - }} - placeholder="Enter custom rate" - /> - {exchangeRate && amountFrom && ( -

- {amountFrom} {fromAccount?.currency} = {(parseFloat(amountFrom.replace(/\s/g, '')) * exchangeRate).toFixed(2)} {toAccount?.currency} -

- )} - - )} -
- )} - -
-
- - { - let value = e.target.value.replace(/\s/g, '') - if (!/^\d*\.?\d*$/.test(value)) return - const formatted = value.includes('.') - ? (() => { const [integer, decimal] = value.split('.'); return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') + '.' + decimal })() - : value.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') - setFee(formatted) - }} - placeholder="0.00" - /> -
- -
- - setDate(e.target.value)} - required - /> -
-
- -
- - setDescription(e.target.value)} - placeholder="e.g., Monthly savings" - /> -
- - {fromAccount && fee && parseFloat(fee.replace(/\s/g, '')) > 0 && ( -

- Total deduction: {(parseFloat(amountFrom.replace(/\s/g, '') || '0') + parseFloat(fee.replace(/\s/g, ''))).toFixed(2)} {fromAccount.currency} -

- )} - - -
-
-
- ) -} diff --git a/client/src/lib/amount.test.ts b/client/src/lib/amount.test.ts new file mode 100644 index 0000000..864368b --- /dev/null +++ b/client/src/lib/amount.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { + expandScientificNotation, + formatAmount, + formatCalculatedAmount, + parseAmount, +} from './amount' + +describe('amount formatting', () => { + it('uses ASCII-space grouping and preserves decimal text', () => { + expect(formatAmount('1234567.000123')).toBe('1 234 567.000123') + expect(formatAmount('-120000.50')).toBe('-120 000.50') + expect(formatAmount(1234.567, { maximumFractionDigits: 2 })).toBe('1 234.57') + }) + + it('normalizes pasted locale separators', () => { + expect(formatAmount('12\u00a0345,67')).toBe('12 345.67') + expect(formatAmount('12\u202f345,67')).toBe('12 345.67') + expect(parseAmount('12\u00a0345,67')).toBe(12345.67) + }) + + it('strictly parses complete drafts', () => { + expect(parseAmount('120 300')).toBe(120300) + expect(parseAmount('-120 300')).toBe(-120300) + expect(parseAmount('')).toBeNull() + expect(parseAmount('-')).toBeNull() + expect(parseAmount('1.2.3')).toBeNull() + expect(parseAmount('-1', { allowNegative: false })).toBeNull() + }) + + it('expands scientific notation', () => { + expect(expandScientificNotation(1e-8)).toBe('0.00000001') + expect(formatAmount(1e21)).toBe('1 000 000 000 000 000 000 000') + }) + + it('bounds calculated precision and removes floating-point tails', () => { + expect(formatCalculatedAmount(0.1 * 0.2, { maximumFractionDigits: 2 })).toBe('0.02') + expect(formatCalculatedAmount(1000 * 365.1234, { maximumFractionDigits: 2 })).toBe('365 123.4') + expect(formatCalculatedAmount(1e-8, { maximumFractionDigits: 8 })).toBe('0.00000001') + expect(formatCalculatedAmount(12, { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + })).toBe('12.00') + }) +}) diff --git a/client/src/lib/amount.ts b/client/src/lib/amount.ts new file mode 100644 index 0000000..39b9379 --- /dev/null +++ b/client/src/lib/amount.ts @@ -0,0 +1,167 @@ +const EDITABLE_GROUP_SEPARATOR_PATTERN = /[\s\u00a0\u202f]/g +const SCIENTIFIC_NUMBER_PATTERN = /^([+-]?)(\d+)(?:\.(\d*))?[eE]([+-]?\d+)$/ + +export interface AmountFormatOptions { + allowNegative?: boolean + maximumFractionDigits?: number + minimumFractionDigits?: number +} + +export type CalculatedAmountFormatOptions = AmountFormatOptions + +const amountPattern = (allowNegative: boolean) => + allowNegative ? /^-?\d*(?:\.\d*)?$/ : /^\d*(?:\.\d*)?$/ + +/** + * Convert scientific notation to a plain decimal string without changing its + * significant digits. + */ +export function expandScientificNotation(value: string | number): string { + const source = typeof value === 'number' ? value.toString() : value.trim() + const match = SCIENTIFIC_NUMBER_PATTERN.exec(source) + + if (!match) return source + + const [, sign, integer, fraction = '', exponentText] = match + const exponent = Number(exponentText) + const digits = integer + fraction + const decimalPosition = integer.length + exponent + const normalizedSign = sign === '+' ? '' : sign + + if (decimalPosition <= 0) { + return `${normalizedSign}0.${'0'.repeat(-decimalPosition)}${digits}` + } + + if (decimalPosition >= digits.length) { + return `${normalizedSign}${digits}${'0'.repeat(decimalPosition - digits.length)}` + } + + return `${normalizedSign}${digits.slice(0, decimalPosition)}.${digits.slice(decimalPosition)}` +} + +/** + * Normalize characters accepted by editable amount fields while retaining the + * user's separator scaffold. Returns null when the draft is not a valid + * transient amount (for example, when it contains two decimal separators). + */ +export function normalizeAmountDraft( + value: string, + { allowNegative = true }: AmountFormatOptions = {}, +): string | null { + const normalized = value + .replace(EDITABLE_GROUP_SEPARATOR_PATTERN, ' ') + .replace(/,/g, '.') + const ungrouped = normalized.replace(/ /g, '') + + return amountPattern(allowNegative).test(ungrouped) ? normalized : null +} + +export function getAmountIntegerDigitCount(value: string): number { + const normalized = normalizeAmountDraft(value, { allowNegative: true }) + if (normalized === null) return 0 + + const unsigned = normalized.replace(/ /g, '').replace(/^-/, '') + return unsigned.split('.')[0].length +} + +function groupInteger(integer: string): string { + return integer.replace(/\B(?=(\d{3})+(?!\d))/g, ' ') +} + +/** Format a number or valid editable amount using ASCII-space grouping. */ +export function formatAmount( + value: string | number, + options: AmountFormatOptions = {}, +): string { + const { + allowNegative = true, + maximumFractionDigits, + minimumFractionDigits, + } = options + + if (typeof value === 'number' && !Number.isFinite(value)) return '' + if ( + typeof value === 'number' && + (maximumFractionDigits !== undefined || minimumFractionDigits !== undefined) + ) { + return formatCalculatedAmount(value, { + allowNegative, + maximumFractionDigits: maximumFractionDigits ?? Math.max(minimumFractionDigits ?? 0, 8), + minimumFractionDigits, + }) + } + + const expanded = expandScientificNotation(value) + const normalized = normalizeAmountDraft(expanded, { allowNegative }) + if (normalized === null) return '' + + const ungrouped = normalized.replace(/ /g, '') + if (ungrouped === '' || ungrouped === '-' || ungrouped === '.' || ungrouped === '-.') { + return ungrouped + } + + const isNegative = ungrouped.startsWith('-') + const unsigned = isNegative ? ungrouped.slice(1) : ungrouped + const decimalIndex = unsigned.indexOf('.') + let integer = decimalIndex === -1 ? unsigned : unsigned.slice(0, decimalIndex) + const decimal = decimalIndex === -1 ? null : unsigned.slice(decimalIndex + 1) + + if (integer === '' && decimal !== null && decimal !== '') integer = '0' + + const sign = isNegative ? '-' : '' + const groupedInteger = groupInteger(integer) + return decimal === null ? `${sign}${groupedInteger}` : `${sign}${groupedInteger}.${decimal}` +} + +/** Strictly parse an editable amount, returning null for incomplete/invalid drafts. */ +export function parseAmount( + value: string, + { allowNegative = true }: AmountFormatOptions = {}, +): number | null { + const normalized = normalizeAmountDraft(value, { allowNegative }) + if (normalized === null) return null + + const ungrouped = normalized.replace(/ /g, '') + if (ungrouped === '' || ungrouped === '-' || ungrouped === '.' || ungrouped === '-.') { + return null + } + + const parsed = Number(ungrouped) + return Number.isFinite(parsed) ? parsed : null +} + +/** + * Format a calculated numeric value while rounding away floating-point tails. + * The default of eight fraction digits is suitable for quantities; callers can + * request two (or another currency-specific precision) for cash amounts. + */ +export function formatCalculatedAmount( + value: number, + { + allowNegative = true, + maximumFractionDigits = 8, + minimumFractionDigits = 0, + }: CalculatedAmountFormatOptions = {}, +): string { + if (!Number.isFinite(value)) return '' + + const maximum = Math.min(100, Math.max(0, Math.trunc(maximumFractionDigits))) + const minimum = Math.min(maximum, Math.max(0, Math.trunc(minimumFractionDigits))) + let rounded = expandScientificNotation(value.toFixed(maximum)) + + const decimalIndex = rounded.indexOf('.') + if (decimalIndex !== -1) { + const integer = rounded.slice(0, decimalIndex) + let fraction = rounded.slice(decimalIndex + 1) + + while (fraction.length > minimum && fraction.endsWith('0')) { + fraction = fraction.slice(0, -1) + } + + rounded = fraction === '' ? integer : `${integer}.${fraction}` + } + + if (/^-0(?:\.0*)?$/.test(rounded)) rounded = rounded.slice(1) + + return formatAmount(rounded, { allowNegative }) +} diff --git a/package.json b/package.json index 825a2c5..d80dbc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "finance-manager", - "version": "2.5", + "version": "2.6", "private": true, "workspaces": [ "client", From 20cdbcff94b404b23a2f1dc5c7c7898e859c44e4 Mon Sep 17 00:00:00 2001 From: 31b4 Date: Sun, 12 Jul 2026 17:23:51 +0200 Subject: [PATCH 2/3] feat: add single transaction handling and modal in AccountList --- .../dashboard-module/AccountList.tsx | 30 +++++- .../SplitTransactionModal.tsx | 92 +++++++++++-------- package.json | 2 +- 3 files changed, 81 insertions(+), 43 deletions(-) diff --git a/client/src/components/dashboard-module/AccountList.tsx b/client/src/components/dashboard-module/AccountList.tsx index e8d1be1..d7d6991 100644 --- a/client/src/components/dashboard-module/AccountList.tsx +++ b/client/src/components/dashboard-module/AccountList.tsx @@ -79,6 +79,7 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A const [quotes, setQuotes] = useState>({}) const [categories, setCategories] = useState([]) const [showChoiceModal, setShowChoiceModal] = useState(false) + const [showSingleModal, setShowSingleModal] = useState(false) const [showSplitModal, setShowSplitModal] = useState(false) const [pendingAdjustment, setPendingAdjustment] = useState<{ payload: any, @@ -484,14 +485,15 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A return currency === 'HUF' ? `${sign}${formatted} ${symbol}` : `${sign}${symbol}${formatted}` } - const handleSingleTransaction = async () => { + const handleSingleTransactionConfirm = async (transaction: SplitTransaction) => { if (!pendingAdjustment) return try { const { payload, accountId } = pendingAdjustment - // Send the payload with single transaction + // Send the payload with the transaction details chosen by the user. payload.adjustWithTransaction = true + payload.splitTransactions = [transaction] await apiFetch(`${API_BASE_URL}/accounts/${accountId}`, { method: 'PUT', @@ -503,7 +505,7 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A setIsAdding(false) resetForm() setPendingAdjustment(null) - setShowChoiceModal(false) + setShowSingleModal(false) onAccountAdded() showAlert({ @@ -1136,7 +1138,10 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A setShowChoiceModal(false) setPendingAdjustment(null) }} - onSingleTransaction={handleSingleTransaction} + onSingleTransaction={() => { + setShowChoiceModal(false) + setShowSingleModal(true) + }} onSplitTransaction={() => { setShowChoiceModal(false) setShowSplitModal(true) @@ -1146,6 +1151,23 @@ export function AccountList({ accounts, onAccountAdded, loading }: { accounts: A /> )} + {/* Single Transaction Modal */} + {pendingAdjustment && ( + { + setShowSingleModal(false) + setPendingAdjustment(null) + }} + onConfirm={splits => handleSingleTransactionConfirm(splits[0])} + totalAmount={pendingAdjustment.newBalance - pendingAdjustment.oldBalance} + accountCurrency={pendingAdjustment.payload.currency} + categories={categories} + defaultDate={new Date().toISOString().split('T')[0]} + mode="single" + /> + )} + {/* Split Transaction Modal */} {pendingAdjustment && ( ([ { id: crypto.randomUUID(), @@ -61,13 +64,15 @@ export function SplitTransactionModal({ { id: crypto.randomUUID(), description: '', - amount: '', + amount: isSingleTransaction + ? formatCalculatedAmount(Math.abs(totalAmount), { maximumFractionDigits: 8 }) + : '', category_id: '', date: defaultDate } ]) } - }, [isOpen, defaultDate]) + }, [isOpen, defaultDate, isSingleTransaction, totalAmount]) const addSplit = () => { setSplits([ @@ -142,7 +147,9 @@ export function SplitTransactionModal({ const direction = totalAmount < 0 ? -1 : 1 const totalSplitAmount = direction * splits.reduce((sum, split) => sum + (parseAmount(split.amount) || 0), 0) const remaining = totalAmount - totalSplitAmount - const isValid = Math.abs(remaining) < 0.01 && splits.every(s => (parseAmount(s.amount) || 0) > 0) + const isValid = Math.abs(remaining) < 0.01 && splits.every(s => + (parseAmount(s.amount) || 0) > 0 && Boolean(s.category_id) && Boolean(s.date) + ) const getSplitPercentage = (amount: number) => { if (totalAmount === 0) return 0 @@ -173,7 +180,7 @@ export function SplitTransactionModal({ const filteredCategories = categories.filter(c => c.type === transactionType) return ( - +
{/* Info */}
@@ -194,29 +201,31 @@ export function SplitTransactionModal({
{/* Quick Actions */} -
- - {Math.abs(remaining) > 0.01 && ( + {!isSingleTransaction && ( +
- )} -
+ {Math.abs(remaining) > 0.01 && ( + + )} +
+ )} {/* Splits */}
@@ -229,12 +238,16 @@ export function SplitTransactionModal({
- Split {index + 1} - - {percentage.toFixed(1)}% + + {isSingleTransaction ? 'Transaction details' : `Split ${index + 1}`} + {!isSingleTransaction && ( + + {percentage.toFixed(1)}% + + )}
- {splits.length > 1 && ( + {!isSingleTransaction && splits.length > 1 && (
{/* Visual Slider */} -
+ {!isSingleTransaction &&
Adjust amount @@ -271,7 +284,7 @@ export function SplitTransactionModal({ 0 {formatAmount(maxSliderValue)} {accountCurrency}
-
+
}
@@ -290,6 +303,7 @@ export function SplitTransactionModal({ id={`amount-${split.id}`} step="0.01" value={split.amount} + disabled={isSingleTransaction} onValueChange={value => { const numericValue = parseAmount(value) const otherSplitsTotal = splits @@ -339,7 +353,7 @@ export function SplitTransactionModal({
{/* Quick Fill Button */} - {splits.length === 1 && totalAmount !== 0 && ( + {!isSingleTransaction && splits.length === 1 && totalAmount !== 0 && ( + {!isSingleTransaction && ( + + )} {/* Actions */}
@@ -377,7 +393,7 @@ export function SplitTransactionModal({ disabled={!isValid} className="flex-1" > - Confirm Split + {isSingleTransaction ? 'Confirm Transaction' : 'Confirm Split'}
diff --git a/package.json b/package.json index d80dbc6..54a5e97 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "finance-manager", - "version": "2.6", + "version": "2.7", "private": true, "workspaces": [ "client", From f9346ad867149b12e6464508e3e3fa017823c055 Mon Sep 17 00:00:00 2001 From: 31b4 Date: Sun, 12 Jul 2026 18:02:58 +0200 Subject: [PATCH 3/3] refactor: improve amount formatting and validation in transaction handling --- .../dashboard-module/BulkTransactionModal.tsx | 4 +-- .../dashboard-module/TransactionList.tsx | 29 ++++++++++++++----- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/client/src/components/dashboard-module/BulkTransactionModal.tsx b/client/src/components/dashboard-module/BulkTransactionModal.tsx index a17cdc9..a7e2b52 100644 --- a/client/src/components/dashboard-module/BulkTransactionModal.tsx +++ b/client/src/components/dashboard-module/BulkTransactionModal.tsx @@ -7,7 +7,7 @@ import { Select } from '../common/select' import { Plus, Trash2, AlertCircle, Percent } from 'lucide-react' import { useAlert } from '../../context/AlertContext' import { AmountInput } from '../common/amount-input' -import { formatAmount, formatCalculatedAmount, parseAmount } from '../../lib/amount' +import { formatCalculatedAmount, parseAmount } from '../../lib/amount' type Category = { id: string @@ -235,7 +235,7 @@ export function BulkTransactionModal({
Allocated: - {formatAmount(allocatedAmount)} / {formatAmount(parsedTotal)} {accountCurrency} + {formatCalculatedAmount(allocatedAmount, { maximumFractionDigits: 2 })} / {formatCalculatedAmount(parsedTotal, { maximumFractionDigits: 2 })} {accountCurrency}
{Math.abs(remaining) >= 0.01 && ( diff --git a/client/src/components/dashboard-module/TransactionList.tsx b/client/src/components/dashboard-module/TransactionList.tsx index 056d3e4..43b0565 100644 --- a/client/src/components/dashboard-module/TransactionList.tsx +++ b/client/src/components/dashboard-module/TransactionList.tsx @@ -203,6 +203,10 @@ export function TransactionList({ } const pairKey = getTransferPairKey(fromAccount.id, toAccount.id) + const shouldKeepCurrentRate = () => + (!!editingId && editedTransferPairRef.current === pairKey) + || manualRateOverrideRef.current + const fetchRate = async () => { setIsLoadingRate(true) try { @@ -284,18 +288,17 @@ export function TransactionList({ if (!isCurrentRequest()) return - const preserveEditedRate = !!editingId && editedTransferPairRef.current === pairKey - const userChangedRate = manualRateOverrideRef.current + const preserveEditedRate = shouldKeepCurrentRate() if (rate > 0) { setSuggestedRate(rate) - if (!preserveEditedRate && !userChangedRate) { + if (!preserveEditedRate) { setExchangeRate(rate) setExchangeRateDraft(formatCalculatedAmount(rate, { maximumFractionDigits: 12 })) } } else { setSuggestedRate(null) - if (!preserveEditedRate && !userChangedRate) { + if (!preserveEditedRate) { setExchangeRate(null) setExchangeRateDraft('') } @@ -304,6 +307,10 @@ export function TransactionList({ if (!isCurrentRequest()) return console.error('Failed to fetch exchange rate:', error) setSuggestedRate(null) + if (!shouldKeepCurrentRate()) { + setExchangeRate(null) + setExchangeRateDraft('') + } } finally { if (isCurrentRequest()) setIsLoadingRate(false) } @@ -884,11 +891,17 @@ export function TransactionList({ } const handleBulkTransactionConfirm = async (bulkTransactions: BulkTransaction[]) => { - // Create all transactions one by one - for (const tx of bulkTransactions) { + const parsedTransactions = bulkTransactions.map(tx => { const amount = parseAmount(tx.amount) - const numericAmount = amount ?? 0 - const finalAmount = tx.type === 'expense' ? -Math.abs(numericAmount) : Math.abs(numericAmount) + if (amount === null || amount <= 0) { + throw new Error('Each bulk transaction needs a valid amount greater than 0') + } + return { tx, amount } + }) + + // Validate every row before creating any transactions, then submit them one by one. + for (const { tx, amount } of parsedTransactions) { + const finalAmount = tx.type === 'expense' ? -amount : amount await apiFetch(`${API_BASE_URL}/transactions`, { method: 'POST',