whisper-money/resources/js/components/ui/amount-input.tsx

258 lines
7.9 KiB
TypeScript

import * as React from 'react';
import { Input } from '@/components/ui/input';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
interface AmountInputProps {
value: number;
onChange: (valueInCents: number) => void;
currencyCode: string;
disabled?: boolean;
required?: boolean;
placeholder?: string;
id?: string;
className?: string;
}
const getCurrencyInfo = (
currencyCode: string,
locale: string,
): { symbol: string; position: 'prefix' | 'suffix' } => {
const parts = new Intl.NumberFormat(locale, {
style: 'currency',
currency: currencyCode,
}).formatToParts(1);
const symbolPart = parts.find((p) => p.type === 'currency');
const symbol = symbolPart?.value ?? currencyCode;
const symbolIndex = parts.findIndex((p) => p.type === 'currency');
const literalIndex = parts.findIndex((p) => p.type === 'integer');
const position = symbolIndex < literalIndex ? 'prefix' : 'suffix';
return { symbol, position };
};
const formatCurrency = (value: number, locale: string): string => {
const amount = value / 100;
return new Intl.NumberFormat(locale, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(amount);
};
const parseInputValue = (input: string): number => {
const isNegative = input.trim().startsWith('-');
const cleaned = input.replace(/[^\d.,]/g, '');
if (!cleaned) {
return 0;
}
const lastComma = cleaned.lastIndexOf(',');
const lastDot = cleaned.lastIndexOf('.');
let normalized: string;
if (lastComma > lastDot) {
normalized = cleaned.replace(/\./g, '').replace(',', '.');
} else if (lastDot > lastComma) {
normalized = cleaned.replace(/,/g, '');
} else {
normalized = cleaned.replace(',', '.');
}
const parsed = parseFloat(normalized);
if (isNaN(parsed)) {
return 0;
}
const cents = Math.round(parsed * 100);
return isNegative ? -cents : cents;
};
const evaluateMathExpression = (input: string): number | null => {
// Check for leading minus (negative result)
const trimmed = input.trim();
const isNegativeResult = trimmed.startsWith('-');
const withoutLeadingMinus = isNegativeResult ? trimmed.substring(1) : trimmed;
// Check if input contains math operators (excluding leading minus)
if (!/[+\-*/]/.test(withoutLeadingMinus)) {
return null; // No math operation found
}
try {
// Remove spaces
const cleaned = withoutLeadingMinus.replace(/\s/g, '');
// Helper to convert parsed cents to dollars for calculation
const parseToDollars = (str: string): number => {
return parseInputValue(str) / 100;
};
// Split into tokens (numbers and operators)
const tokens: (number | string)[] = [];
let currentNumber = '';
for (let i = 0; i < cleaned.length; i++) {
const char = cleaned[i];
if (['+', '-', '*', '/'].includes(char) && currentNumber) {
tokens.push(parseToDollars(currentNumber));
tokens.push(char);
currentNumber = '';
} else {
currentNumber += char;
}
}
if (currentNumber) {
tokens.push(parseToDollars(currentNumber));
}
if (tokens.length < 3) {
return null; // Need at least: number operator number
}
// Handle multiplication and division first (operator precedence)
let i = 1;
while (i < tokens.length) {
if (tokens[i] === '*' || tokens[i] === '/') {
const left = tokens[i - 1] as number;
const op = tokens[i] as string;
const right = tokens[i + 1] as number;
const result = op === '*' ? left * right : left / right;
tokens.splice(i - 1, 3, result);
} else {
i += 2;
}
}
// Handle addition and subtraction
let result = tokens[0] as number;
for (let i = 1; i < tokens.length; i += 2) {
const op = tokens[i] as string;
const right = tokens[i + 1] as number;
if (op === '+') {
result += right;
} else if (op === '-') {
result -= right;
}
}
// Apply negative sign if the input started with minus
if (isNegativeResult) {
result = -result;
}
return Math.round(result * 100);
} catch {
return null;
}
};
export const AmountInput = React.forwardRef<HTMLInputElement, AmountInputProps>(
(
{
value,
onChange,
currencyCode,
disabled = false,
required = false,
placeholder = '0.00',
id,
className = '',
},
ref,
) => {
const locale = useLocale();
const [displayValue, setDisplayValue] = React.useState<string>('');
const [isFocused, setIsFocused] = React.useState<boolean>(false);
React.useEffect(() => {
if (!isFocused) {
if (value === 0) {
setDisplayValue('');
} else {
setDisplayValue(formatCurrency(value, locale));
}
}
}, [value, isFocused, locale]);
const handleFocus = () => {
setIsFocused(true);
if (value !== 0) {
const amount = (value / 100).toFixed(2);
setDisplayValue(amount);
} else {
setDisplayValue('');
}
};
const handleBlur = () => {
setIsFocused(false);
// Try to evaluate as math expression first
const mathResult = evaluateMathExpression(displayValue);
const valueInCents = mathResult !== null ? mathResult : parseInputValue(displayValue);
onChange(valueInCents);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setDisplayValue(e.target.value);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
// Try to evaluate as math expression first
const mathResult = evaluateMathExpression(displayValue);
const valueInCents = mathResult !== null ? mathResult : parseInputValue(displayValue);
onChange(valueInCents);
}
};
const { symbol: currencySymbol, position: symbolPosition } = getCurrencyInfo(currencyCode, locale);
return (
<div className="relative">
{symbolPosition === 'prefix' && (
<span className="-translate-y-1/2 absolute top-1/2 left-3 text-muted-foreground text-sm">
{currencySymbol}
</span>
)}
<Input
ref={ref}
id={id}
type="text"
inputMode="decimal"
value={displayValue}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
required={required}
className={cn([
'bg-background',
symbolPosition === 'prefix' ? 'pl-9' : 'pr-9',
className,
])}
/>
{symbolPosition === 'suffix' && (
<span className="-translate-y-1/2 absolute top-1/2 right-3 text-muted-foreground text-sm">
{currencySymbol}
</span>
)}
</div>
);
},
);
AmountInput.displayName = 'AmountInput';