mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
refactor(FE): change number input to reuseablecomponent from ui-component
This commit is contained in:
@@ -1,413 +1,58 @@
|
||||
'use client';
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ChangeEvent,
|
||||
ChangeEventHandler,
|
||||
FocusEventHandler,
|
||||
ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { ChangeEvent, ReactNode } from "react";
|
||||
import { NumericFormat, OnValueChange } from "react-number-format";
|
||||
import TextInput, { TextInputProps } from "@/components/input/TextInput";
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
import Inputmask from 'inputmask';
|
||||
|
||||
const createInputMask = (
|
||||
maskType: MaskType,
|
||||
decimals: number,
|
||||
thousandSeparator: string,
|
||||
decimalSeparator: string,
|
||||
allowNegative: boolean,
|
||||
oncomplete?: () => void,
|
||||
onincomplete?: () => void,
|
||||
oncleared?: () => void
|
||||
): Inputmask.Instance => {
|
||||
const options: Inputmask.Options = {
|
||||
alias: 'numeric',
|
||||
groupSeparator: thousandSeparator,
|
||||
radixPoint: decimalSeparator,
|
||||
digits: decimals,
|
||||
allowMinus: allowNegative,
|
||||
rightAlign: false,
|
||||
insertMode: true,
|
||||
autoUnmask: false,
|
||||
clearMaskOnLostFocus: false,
|
||||
digitsOptional: decimals > 0,
|
||||
placeholder: '0',
|
||||
numericInput: false,
|
||||
positionCaretOnClick: 'radixFocus',
|
||||
greedy: true,
|
||||
oncomplete,
|
||||
onincomplete,
|
||||
oncleared
|
||||
};
|
||||
|
||||
return new Inputmask(options);
|
||||
};
|
||||
|
||||
export type MaskType = 'currency' | 'weight' | 'decimal' | 'number' | 'text';
|
||||
|
||||
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;
|
||||
errorMessage?: string;
|
||||
disabled?: boolean;
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
isLoading?: boolean;
|
||||
|
||||
startAdornment?: ReactNode;
|
||||
endAdornment?: ReactNode;
|
||||
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onFocus?: FocusEventHandler<HTMLInputElement>;
|
||||
|
||||
maskType?: MaskType;
|
||||
decimals?: number;
|
||||
interface NumberInputProps extends Omit<TextInputProps, "type"> {
|
||||
thousandSeparator?: string;
|
||||
decimalSeparator?: string;
|
||||
currencyPrefix?: string;
|
||||
weightUnit?: string;
|
||||
|
||||
min?: number;
|
||||
max?: number;
|
||||
decimalScale?: number;
|
||||
allowNegative?: boolean;
|
||||
|
||||
oncomplete?: () => void;
|
||||
onincomplete?: () => void;
|
||||
oncleared?: () => void;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
fixedDecimalScale?: boolean;
|
||||
inputPrefix?: ReactNode;
|
||||
inputSuffix?: ReactNode;
|
||||
}
|
||||
|
||||
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',
|
||||
allowNegative = false,
|
||||
oncomplete,
|
||||
onincomplete,
|
||||
oncleared,
|
||||
}: NumberInputProps) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const inputmaskRef = useRef<Inputmask.Instance | null>(null);
|
||||
const [maskComplete, setMaskComplete] = useState<boolean>(false);
|
||||
const [maskIncomplete, setMaskIncomplete] = useState<boolean>(false);
|
||||
const [maskCleared, setMaskCleared] = useState<boolean>(false);
|
||||
thousandSeparator = ",",
|
||||
decimalSeparator = ".",
|
||||
decimalScale = 5,
|
||||
allowNegative = true,
|
||||
onChange,
|
||||
inputPrefix,
|
||||
inputSuffix,
|
||||
...restProps
|
||||
}: NumberInputProps) => {
|
||||
const valueChangeHandler: OnValueChange = (
|
||||
numberFormatValues,
|
||||
sourceInfo,
|
||||
) => {
|
||||
const newChangeEvent = sourceInfo.event as
|
||||
| ChangeEvent<HTMLInputElement>
|
||||
| undefined;
|
||||
|
||||
const getInputPrefix = (): string => {
|
||||
switch (maskType) {
|
||||
case 'currency':
|
||||
return currencyPrefix;
|
||||
default:
|
||||
return '';
|
||||
if (newChangeEvent) {
|
||||
newChangeEvent.target.value = numberFormatValues.value;
|
||||
|
||||
onChange?.(newChangeEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const getInputSuffix = (): string => {
|
||||
switch (maskType) {
|
||||
case 'weight':
|
||||
return weightUnit;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current && !readOnly && !disabled) {
|
||||
if (inputmaskRef.current) {
|
||||
try {
|
||||
inputmaskRef.current.remove();
|
||||
} catch (error) {
|
||||
console.warn('Error removing Inputmask:', error);
|
||||
}
|
||||
}
|
||||
|
||||
const handleComplete = () => {
|
||||
setMaskComplete(true);
|
||||
setMaskIncomplete(false);
|
||||
setMaskCleared(false);
|
||||
if (oncomplete) oncomplete();
|
||||
};
|
||||
|
||||
const handleIncomplete = () => {
|
||||
setMaskIncomplete(true);
|
||||
setMaskComplete(false);
|
||||
setMaskCleared(false);
|
||||
if (onincomplete) onincomplete();
|
||||
};
|
||||
|
||||
const handleCleared = () => {
|
||||
setMaskCleared(true);
|
||||
setMaskComplete(false);
|
||||
setMaskIncomplete(false);
|
||||
if (oncleared) oncleared();
|
||||
};
|
||||
|
||||
const im = createInputMask(
|
||||
maskType,
|
||||
decimals,
|
||||
',',
|
||||
'.',
|
||||
allowNegative,
|
||||
handleComplete,
|
||||
handleIncomplete,
|
||||
handleCleared
|
||||
);
|
||||
|
||||
try {
|
||||
im.mask(inputRef.current);
|
||||
inputmaskRef.current = im;
|
||||
} catch (error) {
|
||||
console.warn('Error applying Inputmask:', error);
|
||||
inputmaskRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (inputmaskRef.current) {
|
||||
try {
|
||||
inputmaskRef.current.remove();
|
||||
} catch (error) {
|
||||
console.warn('Error removing Inputmask on cleanup:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [maskType, decimals, thousandSeparator, decimalSeparator, allowNegative, readOnly, disabled, oncomplete, onincomplete, oncleared]);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputRef.current && value !== undefined) {
|
||||
if (value === null || value === '') {
|
||||
inputRef.current.value = '';
|
||||
} else {
|
||||
inputRef.current.value = String(value);
|
||||
}
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const currentValue = (e.currentTarget as HTMLInputElement).value;
|
||||
console.log('✅ After format:', currentValue);
|
||||
|
||||
if (onChange) {
|
||||
const syntheticEvent = {
|
||||
target: {
|
||||
name,
|
||||
value: currentValue,
|
||||
},
|
||||
} as ChangeEvent<HTMLInputElement>;
|
||||
onChange(syntheticEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const inputPrefix = getInputPrefix();
|
||||
const inputSuffix = getInputSuffix();
|
||||
|
||||
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='relative flex'>
|
||||
{inputPrefix && (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center px-4 py-2 border border-r-0 rounded-l-md transition-all duration-200',
|
||||
{
|
||||
'bg-gray-100 border-gray-300': !disabled,
|
||||
'bg-gray-50 border-gray-200': disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium select-none whitespace-nowrap',
|
||||
{
|
||||
'text-gray-600': !disabled,
|
||||
'text-gray-400': disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{inputPrefix}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'input h-12 text-base font-normal leading-6 flex-1 rounded-lg! outline-none! transition-all duration-200 flex items-center bg-white',
|
||||
{
|
||||
'border-error': isError,
|
||||
'border-success!': isValid,
|
||||
'rounded-l-none!': inputPrefix,
|
||||
'rounded-r-none!': inputSuffix,
|
||||
'input-disabled': disabled,
|
||||
'cursor-not-allowed': disabled,
|
||||
'bg-gray-50': disabled,
|
||||
},
|
||||
className?.inputWrapper
|
||||
)}
|
||||
>
|
||||
{startAdornment && startAdornment}
|
||||
|
||||
<input
|
||||
type='text'
|
||||
id={name}
|
||||
name={name}
|
||||
ref={inputRef}
|
||||
placeholder={placeholder || '0'}
|
||||
onKeyUp={handleKeyUp}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'grow bg-transparent outline-none',
|
||||
{
|
||||
'cursor-not-allowed': disabled,
|
||||
'text-gray-500': disabled,
|
||||
},
|
||||
className?.input
|
||||
)}
|
||||
readOnly={readOnly}
|
||||
inputMode='text'
|
||||
autoComplete='off'
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
||||
{(isLoading || endAdornment) && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
{isLoading && <span className='loading loading-spinner' />}
|
||||
{endAdornment && endAdornment}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{inputSuffix && (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center px-4 py-2 border border-l-0 rounded-r-md transition-all duration-200',
|
||||
{
|
||||
'bg-gray-100 border-gray-300': !disabled,
|
||||
'bg-gray-50 border-gray-200': disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium select-none whitespace-nowrap',
|
||||
{
|
||||
'text-gray-600': !disabled,
|
||||
'text-gray-400': disabled,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{inputSuffix}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(maskType === 'text' || (oncomplete || onincomplete || oncleared)) && (
|
||||
<div className='flex gap-2 text-xs'>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded transition-all duration-200',
|
||||
maskComplete
|
||||
? 'bg-green-100 text-green-700 border border-green-200'
|
||||
: 'bg-gray-50 text-gray-400 border border-gray-200'
|
||||
)}
|
||||
>
|
||||
Complete
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded transition-all duration-200',
|
||||
maskIncomplete
|
||||
? 'bg-yellow-100 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-gray-50 text-gray-400 border border-gray-200'
|
||||
)}
|
||||
>
|
||||
Incomplete
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-1 rounded transition-all duration-200',
|
||||
maskCleared
|
||||
? 'bg-blue-100 text-blue-700 border border-blue-200'
|
||||
: 'bg-gray-50 text-gray-400 border border-gray-200'
|
||||
)}
|
||||
>
|
||||
Cleared
|
||||
</span>
|
||||
</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>
|
||||
<NumericFormat
|
||||
thousandSeparator={thousandSeparator}
|
||||
decimalSeparator={decimalSeparator}
|
||||
customInput={TextInput}
|
||||
onValueChange={valueChangeHandler}
|
||||
decimalScale={decimalScale}
|
||||
allowNegative={allowNegative}
|
||||
startAdornment={inputPrefix}
|
||||
endAdornment={inputSuffix}
|
||||
{...restProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1377,9 +1377,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
handleDeliveryCostChange(idx, e.target.value)
|
||||
}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='currency'
|
||||
decimals={0}
|
||||
min={0}
|
||||
{...isRepeaterInputError(
|
||||
'deliveries',
|
||||
'delivery_cost',
|
||||
@@ -1404,9 +1401,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
|
||||
)
|
||||
}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='currency'
|
||||
decimals={0}
|
||||
min={0}
|
||||
{...isRepeaterInputError(
|
||||
'deliveries',
|
||||
'delivery_cost_per_item',
|
||||
|
||||
@@ -942,9 +942,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
value={feed.feed_stock}
|
||||
onChange={handleFeedStockChangeWrapper(idx)}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='number'
|
||||
decimals={0}
|
||||
min={0}
|
||||
thousandSeparator=','
|
||||
decimalSeparator=''
|
||||
isError={
|
||||
@@ -1120,10 +1117,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
value={weight.chicken_weight}
|
||||
onChange={handleChickenWeightChangeWrapper(idx)}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='weight'
|
||||
weightUnit='gram'
|
||||
decimals={2}
|
||||
min={0}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
isError={
|
||||
@@ -1153,9 +1146,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
value={weight.chicken_count}
|
||||
onChange={handleChickenCountChangeWrapper(idx)}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='number'
|
||||
decimals={0}
|
||||
min={0}
|
||||
isError={
|
||||
isRepeaterInputError(
|
||||
'body_weight',
|
||||
@@ -1184,10 +1174,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
value={weight.average_chicken_weight || ''}
|
||||
onChange={handleAverageWeightChangeWrapper(idx)}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='weight'
|
||||
weightUnit='gram'
|
||||
decimals={2}
|
||||
min={0}
|
||||
thousandSeparator=','
|
||||
decimalSeparator='.'
|
||||
isError={
|
||||
@@ -1450,9 +1436,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
value={vaccine.used_stock}
|
||||
onChange={handleVaccinationStockChangeWrapper(idx)}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='number'
|
||||
decimals={0}
|
||||
min={0}
|
||||
thousandSeparator=','
|
||||
decimalSeparator=''
|
||||
isError={
|
||||
@@ -1660,9 +1643,6 @@ const RecordingForm = ({ type = 'add', initialValues }: RecordingFormProps) => {
|
||||
value={mortality.count}
|
||||
onChange={handleMortalityCountChangeWrapper(idx)}
|
||||
onBlur={formik.handleBlur}
|
||||
maskType='number'
|
||||
decimals={0}
|
||||
min={0}
|
||||
thousandSeparator=','
|
||||
decimalSeparator=''
|
||||
isError={
|
||||
|
||||
Reference in New Issue
Block a user