From c25b49c179dbff5f6394f263f45a8ccf4f64581e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Sat, 18 Oct 2025 11:39:18 +0700 Subject: [PATCH] feat(FE-114): add NumberInput component and integrate into RecordingForm for enhanced numeric input handling --- src/components/input/NumberInput.tsx | 444 ++++++++++++++++++ .../flock/recording/form/RecordingForm.tsx | 42 +- 2 files changed, 476 insertions(+), 10 deletions(-) create mode 100644 src/components/input/NumberInput.tsx diff --git a/src/components/input/NumberInput.tsx b/src/components/input/NumberInput.tsx new file mode 100644 index 00000000..a9b8d9a0 --- /dev/null +++ b/src/components/input/NumberInput.tsx @@ -0,0 +1,444 @@ +'use client'; + +import { + ChangeEvent, + ChangeEventHandler, + FocusEventHandler, + ReactNode, + useEffect, + useState, +} from 'react'; + +import { Icon } from '@iconify/react'; +import { cn } from '@/lib/helper'; + +// Utility Functions +const formatNumber = ( + value: number | string, + decimals: number = 0, + thousandSeparator: string = '.', + decimalSeparator: string = ',' +): string => { + if (value === '' || value === null || value === undefined) return ''; + + const numValue = typeof value === 'string' ? parseFloat(value) : value; + if (isNaN(numValue)) return ''; + + const parts = numValue.toFixed(decimals).split('.'); + const integerPart = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator); + const decimalPart = parts[1]; + + return decimals > 0 && decimalPart + ? `${integerPart}${decimalSeparator}${decimalPart}` + : integerPart; +}; + +const parseNumber = ( + value: string, + thousandSeparator: string = '.', + decimalSeparator: string = ',' +): number => { + if (!value) return 0; + + // Remove thousand separators and replace decimal separator with dot + const cleaned = value + .replace(new RegExp(`\\${thousandSeparator}`, 'g'), '') + .replace(decimalSeparator, '.'); + + const parsed = parseFloat(cleaned); + return isNaN(parsed) ? 0 : parsed; +}; + +const formatCurrency = ( + value: number | string, + prefix: string = 'Rp ', + decimals: number = 0 +): string => { + if (value === '' || value === null || value === undefined) return ''; + const formatted = formatNumber(value, decimals); + return formatted ? `${prefix}${formatted}` : ''; +}; + +const formatWeight = ( + value: number | string, + unit: string = 'kg', + decimals: number = 2 +): string => { + if (value === '' || value === null || value === undefined) return ''; + const formatted = formatNumber(value, decimals); + return formatted ? `${formatted} ${unit}` : ''; +}; + +const cleanNumericInput = ( + value: string, + allowDecimal: boolean = false, + decimalSeparator: string = ',' +): string => { + // Only allow numbers, decimal separator (if allowed), and minus sign at the start + let cleaned = value.replace(/[^\d,.-]/g, ''); + + // Handle decimal separator + if (allowDecimal) { + const parts = cleaned.split(decimalSeparator); + if (parts.length > 2) { + // Keep only first decimal separator + cleaned = parts[0] + decimalSeparator + parts.slice(1).join(''); + } + } else { + cleaned = cleaned.replace(new RegExp(decimalSeparator, 'g'), ''); + } + + // Handle minus sign (only at start) + const hasMinusAtStart = cleaned.startsWith('-'); + cleaned = cleaned.replace(/-/g, ''); + if (hasMinusAtStart) cleaned = '-' + cleaned; + + return cleaned; +}; + +// Types +export type MaskType = 'currency' | 'weight' | 'decimal' | 'number'; + +export interface NumberInputProps { + label?: string; + bottomLabel?: string; + name: string; + value?: number | string; + placeholder?: string; + className?: { + wrapper?: string; + label?: string; + inputWrapper?: string; + input?: string; + }; + isError?: boolean; + isValid?: boolean; + disabled?: boolean; + readOnly?: boolean; + required?: boolean; + isLoading?: boolean; + errorMessage?: string; + startAdornment?: ReactNode; + endAdornment?: ReactNode; + onChange?: ChangeEventHandler; + onBlur?: FocusEventHandler; + onFocus?: FocusEventHandler; + + // Masking Options + maskType?: MaskType; + decimals?: number; + thousandSeparator?: string; + decimalSeparator?: string; + + // Currency specific + currencyPrefix?: string; + + // Weight specific + weightUnit?: string; + + // Validation + min?: number; + max?: number; + allowNegative?: boolean; + + // Stepper (Increment/Decrement buttons) + showSteppers?: boolean; + step?: number; +} + +const NumberInput = ({ + label, + bottomLabel, + name, + value, + placeholder, + className, + isError, + isValid, + errorMessage, + startAdornment, + endAdornment, + disabled = false, + required = false, + onChange, + onBlur, + onFocus, + readOnly = false, + isLoading = false, + maskType = 'number', + decimals = 0, + thousandSeparator = '.', + decimalSeparator = ',', + currencyPrefix = 'Rp ', + weightUnit = 'kg', + min, + max, + allowNegative = false, + showSteppers = false, + step = 1, +}: NumberInputProps) => { + const [displayValue, setDisplayValue] = useState(''); + + // Determine if decimals are allowed based on maskType + const allowDecimal = maskType === 'decimal' || maskType === 'weight' || decimals > 0; + + // Format value for display based on maskType + const getFormattedValue = (rawValue: number | string): string => { + if (rawValue === '' || rawValue === null || rawValue === undefined) return ''; + + switch (maskType) { + case 'currency': + return formatCurrency(rawValue, currencyPrefix, decimals); + case 'weight': + return formatWeight(rawValue, weightUnit, decimals); + case 'decimal': + case 'number': + return formatNumber(rawValue, decimals, thousandSeparator, decimalSeparator); + default: + return String(rawValue); + } + }; + + // Initialize display value when value prop changes + useEffect(() => { + setDisplayValue(getFormattedValue(value || '')); + }, [value]); + + const handleInputChange = (e: ChangeEvent) => { + let inputValue = e.target.value; + + // Remove prefix/suffix for editing + if (maskType === 'currency' && inputValue.startsWith(currencyPrefix)) { + inputValue = inputValue.slice(currencyPrefix.length); + } + if (maskType === 'weight' && inputValue.endsWith(` ${weightUnit}`)) { + inputValue = inputValue.slice(0, -weightUnit.length - 1); + } + + // Clean input + const cleaned = cleanNumericInput(inputValue, allowDecimal, decimalSeparator); + + // Parse to number + let numericValue = parseNumber(cleaned, thousandSeparator, decimalSeparator); + + // Apply validation + if (!allowNegative && numericValue < 0) { + numericValue = 0; + } + if (min !== undefined && numericValue < min) { + numericValue = min; + } + if (max !== undefined && numericValue > max) { + numericValue = max; + } + + // Update display value + const formattedForDisplay = formatNumber( + numericValue, + decimals, + thousandSeparator, + decimalSeparator + ); + + setDisplayValue(formattedForDisplay); + + // Call onChange with modified event + if (onChange) { + // Create a synthetic event with the numeric value + const syntheticEvent = { + ...e, + target: { + ...e.target, + name, + value: numericValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + // Handle Increment + const handleIncrement = () => { + if (disabled || readOnly) return; + + const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + let newValue = currentValue + step; + + // Apply max validation + if (max !== undefined && newValue > max) { + newValue = max; + } + + // Update display + const formattedForDisplay = formatNumber( + newValue, + decimals, + thousandSeparator, + decimalSeparator + ); + setDisplayValue(formattedForDisplay); + + // Call onChange with synthetic event + if (onChange) { + const syntheticEvent = { + target: { + name, + value: newValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + // Handle Decrement + const handleDecrement = () => { + if (disabled || readOnly) return; + + const currentValue = parseNumber(displayValue, thousandSeparator, decimalSeparator); + let newValue = currentValue - step; + + // Apply min validation (prevent negative if not allowed) + if (!allowNegative && newValue < 0) { + newValue = 0; + } + if (min !== undefined && newValue < min) { + newValue = min; + } + + // Update display + const formattedForDisplay = formatNumber( + newValue, + decimals, + thousandSeparator, + decimalSeparator + ); + setDisplayValue(formattedForDisplay); + + // Call onChange with synthetic event + if (onChange) { + const syntheticEvent = { + target: { + name, + value: newValue.toString(), + }, + } as ChangeEvent; + + onChange(syntheticEvent); + } + }; + + return ( +
+ {label && ( + + )} + +
+ {/* Decrement Button */} + {showSteppers && ( + + )} + + {startAdornment && startAdornment} + + + + {(isLoading || endAdornment) && ( +
+ {isLoading && } + + {endAdornment && endAdornment} +
+ )} + + {/* Increment Button */} + {showSteppers && ( + + )} +
+ + {!isError && bottomLabel && ( +

{bottomLabel}

+ )} + {isError && errorMessage && ( +

{errorMessage}

+ )} +
+ ); +}; + +export default NumberInput; diff --git a/src/components/pages/flock/recording/form/RecordingForm.tsx b/src/components/pages/flock/recording/form/RecordingForm.tsx index 65c8073b..773baab6 100644 --- a/src/components/pages/flock/recording/form/RecordingForm.tsx +++ b/src/components/pages/flock/recording/form/RecordingForm.tsx @@ -6,6 +6,7 @@ import { Icon } from '@iconify/react'; import { toast } from 'react-hot-toast'; import Button from '@/components/Button'; import TextInput from '@/components/input/TextInput'; +import NumberInput from '@/components/input/NumberInput'; import SelectInput, { OptionType } from '@/components/input/SelectInput'; import ConfirmationModal from '@/components/modal/ConfirmationModal'; import { FormHeader } from '@/components/helper/form/FormHeader'; @@ -589,13 +590,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> - { )} - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> - { ).errorMessage } readOnly={type === 'detail'} + className={{ + wrapper: 'w-full min-w-24', + }} /> {type !== 'detail' && ( @@ -995,13 +1015,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => { /> -