money-input.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  1. import { useUserSettings } from '@/hooks/use-user-settings.js';
  2. import { useMemo, useState, useEffect } from 'react';
  3. import { useLocalFormat } from '@/hooks/use-local-format.js';
  4. import { AffixedInput } from './affixed-input.js';
  5. // Original component
  6. function MoneyInputInternal({
  7. value,
  8. currency,
  9. onChange,
  10. }: {
  11. value: number;
  12. currency: string;
  13. onChange: (value: number) => void;
  14. }) {
  15. const {
  16. settings: { displayLanguage, displayLocale },
  17. } = useUserSettings();
  18. const { toMajorUnits, toMinorUnits } = useLocalFormat();
  19. const [displayValue, setDisplayValue] = useState(toMajorUnits(value).toFixed(2));
  20. // Update display value when prop value changes
  21. useEffect(() => {
  22. setDisplayValue(toMajorUnits(value).toFixed(2));
  23. }, [value, toMajorUnits]);
  24. // Determine if the currency symbol should be a prefix based on locale
  25. const shouldPrefix = useMemo(() => {
  26. if (!currency) return false;
  27. const locale = displayLocale || displayLanguage.replace(/_/g, '-');
  28. const parts = new Intl.NumberFormat(locale, {
  29. style: 'currency',
  30. currency,
  31. currencyDisplay: 'symbol',
  32. }).formatToParts();
  33. const NaNString = parts.find(p => p.type === 'nan')?.value ?? 'NaN';
  34. const localised = new Intl.NumberFormat(locale, {
  35. style: 'currency',
  36. currency,
  37. currencyDisplay: 'symbol',
  38. }).format(undefined as any);
  39. return localised.indexOf(NaNString) > 0;
  40. }, [currency, displayLocale, displayLanguage]);
  41. // Get the currency symbol
  42. const currencySymbol = useMemo(() => {
  43. if (!currency) return '';
  44. const locale = displayLocale || displayLanguage.replace(/_/g, '-');
  45. const parts = new Intl.NumberFormat(locale, {
  46. style: 'currency',
  47. currency,
  48. currencyDisplay: 'symbol',
  49. }).formatToParts();
  50. return parts.find(p => p.type === 'currency')?.value ?? currency;
  51. }, [currency, displayLocale, displayLanguage]);
  52. return (
  53. <AffixedInput
  54. type="text"
  55. value={displayValue}
  56. onChange={e => {
  57. const inputValue = e.target.value;
  58. // Allow empty input
  59. if (inputValue === '') {
  60. setDisplayValue('');
  61. return;
  62. }
  63. // Only allow numbers and one decimal point
  64. if (!/^[0-9.]*$/.test(inputValue)) {
  65. return;
  66. }
  67. setDisplayValue(inputValue);
  68. }}
  69. onKeyDown={e => {
  70. if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
  71. e.preventDefault();
  72. const currentValue = parseFloat(displayValue) || 0;
  73. const step = e.key === 'ArrowUp' ? 0.01 : -0.01;
  74. const newValue = currentValue + step;
  75. if (newValue >= 0) {
  76. onChange(toMinorUnits(newValue));
  77. setDisplayValue(newValue.toString());
  78. }
  79. }
  80. }}
  81. onBlur={e => {
  82. const inputValue = e.target.value;
  83. if (inputValue === '') {
  84. onChange(0);
  85. setDisplayValue('0');
  86. return;
  87. }
  88. const newValue = parseFloat(inputValue);
  89. if (!isNaN(newValue)) {
  90. onChange(toMinorUnits(newValue));
  91. setDisplayValue(newValue.toFixed(2));
  92. }
  93. }}
  94. step="0.01"
  95. min="0"
  96. prefix={shouldPrefix ? currencySymbol : undefined}
  97. suffix={!shouldPrefix ? currencySymbol : undefined}
  98. />
  99. );
  100. }
  101. // Wrapper that makes it compatible with DataInputComponent
  102. export function MoneyInput(props: { value: any; onChange: (value: any) => void; [key: string]: any }) {
  103. const { value, onChange, ...rest } = props;
  104. const currency = rest.currency || 'USD'; // Default currency if none provided
  105. return <MoneyInputInternal value={value} currency={currency} onChange={onChange} />;
  106. }