mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat(FE-114): add NumberInput component and integrate into RecordingForm for enhanced numeric input handling
This commit is contained in:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user