feat(FE-114): add NumberInput component and integrate into RecordingForm for enhanced numeric input handling

This commit is contained in:
rstubryan
2025-10-18 11:39:18 +07:00
parent 881e2bfc4a
commit c25b49c179
2 changed files with 476 additions and 10 deletions
+444
View File
@@ -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<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
// 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<string>('');
// 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<HTMLInputElement>) => {
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<HTMLInputElement>;
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<HTMLInputElement>;
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<HTMLInputElement>;
onChange(syntheticEvent);
}
};
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
>
{label && (
<label
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
)}
</label>
)}
<div
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all duration-200',
{
'border-error': isError,
'border-success!': isValid,
},
className?.inputWrapper
)}
>
{/* Decrement Button */}
{showSteppers && (
<button
type='button'
onClick={handleDecrement}
disabled={disabled || readOnly}
className={cn(
'btn btn-ghost btn-sm h-8 w-8 min-h-0 p-0 rounded-md',
{
'opacity-50 cursor-not-allowed': disabled || readOnly,
}
)}
tabIndex={-1}
>
<Icon icon='ic:round-minus' width={20} height={20} />
</button>
)}
{startAdornment && startAdornment}
<input
type='text'
id={name}
name={name}
placeholder={placeholder}
value={displayValue}
onChange={handleInputChange}
onFocus={onFocus}
onBlur={onBlur}
disabled={disabled}
className={cn('grow', className?.input)}
readOnly={readOnly}
inputMode='decimal'
/>
{(isLoading || endAdornment) && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
</div>
)}
{/* Increment Button */}
{showSteppers && (
<button
type='button'
onClick={handleIncrement}
disabled={disabled || readOnly}
className={cn(
'btn btn-ghost btn-sm h-8 w-8 min-h-0 p-0 rounded-md',
{
'opacity-50 cursor-not-allowed': disabled || readOnly,
}
)}
tabIndex={-1}
>
<Icon icon='ic:round-plus' width={20} height={20} />
</button>
)}
</div>
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
)}
</div>
);
};
export default NumberInput;
@@ -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) => {
/>
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`feed_data.${idx}.feed_stock`}
value={feed.feed_stock}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
maskType='number'
decimals={0}
min={0}
isError={
isRepeaterInputError(
'feed_data',
@@ -728,13 +731,16 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
</td>
)}
<td>
<TextInput
<NumberInput
required
type='number'
name={`body_weight.${idx}.chicken_weight`}
value={weight.chicken_weight}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
maskType='weight'
weightUnit='gram'
decimals={0}
min={0}
isError={
isRepeaterInputError(
'body_weight',
@@ -750,16 +756,21 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
).errorMessage
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`body_weight.${idx}.chicken_count`}
value={weight.chicken_count}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
maskType='number'
decimals={0}
min={0}
isError={
isRepeaterInputError(
'body_weight',
@@ -775,16 +786,22 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
).errorMessage
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`body_weight.${idx}.average_chicken_weight`}
value={weight.average_chicken_weight}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
maskType='weight'
weightUnit='gram'
decimals={0}
min={0}
isError={
isRepeaterInputError(
'body_weight',
@@ -800,6 +817,9 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
).errorMessage
}
readOnly={type === 'detail'}
className={{
wrapper: 'w-full min-w-24',
}}
/>
</td>
{type !== 'detail' && (
@@ -995,13 +1015,15 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
/>
</td>
<td>
<TextInput
<NumberInput
required
type='number'
name={`vaccination.${idx}.used_stock`}
value={vaccine.used_stock}
onChange={formik.handleChange}
onBlur={formik.handleBlur}
maskType='number'
decimals={0}
min={0}
isError={
isRepeaterInputError(
'vaccination',