fix(merge): resolve conflict on merge

This commit is contained in:
rstubryan
2025-10-23 18:24:02 +07:00
13 changed files with 362 additions and 645 deletions
+2
View File
@@ -0,0 +1,2 @@
npm run lint
npm run build
+43 -1
View File
@@ -13,6 +13,7 @@
"axios": "^1.12.2",
"clsx": "^2.1.1",
"formik": "^2.4.6",
"inputmask": "^5.0.9",
"moment": "^2.30.1",
"next": "15.5.3",
"react": "19.1.0",
@@ -29,12 +30,14 @@
"@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4",
"@types/inputmask": "^5.0.7",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"daisyui": "^5.1.12",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"husky": "^9.1.7",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -1639,6 +1642,13 @@
"@types/react": "*"
}
},
"node_modules/@types/inputmask": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/@types/inputmask/-/inputmask-5.0.7.tgz",
"integrity": "sha512-uojbVPWzBQ/n/0jc/d16fLqmGasFIptbrLD2WrCPWArlk+5PgblOqH4EDkI3AoobXLAlOK5yF01V8jMmvMG5qg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1674,6 +1684,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.13.tgz",
"integrity": "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -1743,6 +1754,7 @@
"integrity": "sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.43.0",
"@typescript-eslint/types": "8.43.0",
@@ -2260,6 +2272,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -2828,7 +2841,8 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/daisyui": {
"version": "5.1.12",
@@ -3262,6 +3276,7 @@
"integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -3436,6 +3451,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4176,6 +4192,22 @@
"react-is": "^16.7.0"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
"integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==",
"dev": true,
"license": "MIT",
"bin": {
"husky": "bin.js"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/typicode"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4212,6 +4244,12 @@
"node": ">=0.8.19"
}
},
"node_modules/inputmask": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/inputmask/-/inputmask-5.0.9.tgz",
"integrity": "sha512-s0lUfqcEbel+EQXtehXqwCJGShutgieOaIImFKC/r4reYNvX3foyrChl6LOEvaEgxEbesePIrw1Zi2jhZaDZbQ==",
"license": "MIT"
},
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -5749,6 +5787,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -5758,6 +5797,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.26.0"
},
@@ -6574,6 +6614,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -6741,6 +6782,7 @@
"integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+5 -1
View File
@@ -6,7 +6,8 @@
"dev": "eslint && next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"prepare": "husky"
},
"dependencies": {
"@tanstack/match-sorter-utils": "^8.19.4",
@@ -14,6 +15,7 @@
"axios": "^1.12.2",
"clsx": "^2.1.1",
"formik": "^2.4.6",
"inputmask": "^5.0.9",
"moment": "^2.30.1",
"next": "15.5.3",
"react": "19.1.0",
@@ -30,12 +32,14 @@
"@eslint/eslintrc": "^3",
"@iconify/react": "^6.0.2",
"@tailwindcss/postcss": "^4",
"@types/inputmask": "^5.0.7",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"daisyui": "^5.1.12",
"eslint": "^9",
"eslint-config-next": "15.5.3",
"husky": "^9.1.7",
"tailwindcss": "^4",
"typescript": "^5"
}
+51 -242
View File
@@ -1,278 +1,87 @@
'use client';
import { ChangeEventHandler, FocusEventHandler, ReactNode, useId } from 'react';
import { HTMLProps, useEffect, useRef } from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface CheckboxInputProps {
// Basic Props
interface CheckboxInputProps extends HTMLProps<HTMLInputElement> {
name: string;
label?: string;
bottomLabel?: string;
checked?: boolean;
value?: string | number;
indeterminate?: boolean;
naked?: boolean; // New prop for checkbox-only mode
// Styling Props
className?: {
classNames?: {
wrapper?: string;
label?: string;
inputWrapper?: string;
checkbox?: string;
input?: string;
label?: string;
};
// State Props
isError?: boolean;
isValid?: boolean;
errorMessage?: string;
disabled?: boolean;
readOnly?: boolean;
required?: boolean;
isLoading?: boolean;
// Adornment Props
startAdornment?: ReactNode;
endAdornment?: ReactNode;
// Event Handlers
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
// Additional Props
tooltip?: string;
description?: string;
size?: 'sm' | 'md' | 'lg';
variant?:
| 'default'
| 'primary'
| 'secondary'
| 'success'
| 'warning'
| 'info'
| 'error';
}
const CheckboxInput = ({
indeterminate,
name,
label,
bottomLabel,
checked = false,
value,
indeterminate = false,
naked = false,
className,
classNames,
isValid,
isError,
errorMessage,
disabled = false,
readOnly = false,
required = false,
isLoading = false,
startAdornment,
endAdornment,
onChange,
onBlur,
onFocus,
tooltip,
description,
size = 'md',
variant = 'default',
...rest
}: CheckboxInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
const ref = useRef<HTMLInputElement>(null!);
// Size classes
const sizeClasses = {
sm: 'checkbox-sm',
md: 'checkbox-md',
lg: 'checkbox-lg',
};
// Variant classes
const variantClasses = {
default: '',
primary: 'checkbox-primary',
secondary: 'checkbox-secondary',
success: 'checkbox-success',
warning: 'checkbox-warning',
info: 'checkbox-info',
error: 'checkbox-error',
};
// Generate unique ID for accessibility using React's useId hook for SSR compatibility
const generatedId = useId();
const checkboxId = `checkbox-${name}-${generatedId}`;
// Naked mode - only checkbox, no wrapper structure
if (naked) {
return (
<div className='relative'>
<input
type='checkbox'
id={checkboxId}
name={name}
value={value}
checked={checked}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
disabled={disabled}
readOnly={readOnly}
className={cn(
'checkbox',
sizeClasses[size],
variantClasses[variant],
{
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled && !readOnly,
},
className?.checkbox
)}
ref={(input) => {
if (input) {
input.indeterminate = indeterminate;
}
}}
/>
{/* Loading State */}
{isLoading && (
<div className='absolute inset-0 flex items-center justify-center'>
<span className='loading loading-spinner loading-xs opacity-50' />
</div>
)}
</div>
);
}
useEffect(() => {
if (typeof indeterminate === 'boolean') {
ref.current.indeterminate = !rest.checked && indeterminate;
}
}, [ref, indeterminate]);
return (
<div
className={cn(
'w-full flex flex-col gap-2 text-start',
className?.wrapper
)}
className={cn('flex flex-col items-center gap-1', classNames?.wrapper)}
>
{/* Label with Tooltip Support */}
{label && (
<label
htmlFor={checkboxId}
<div
className={cn(
'flex flex-row justify-center items-center gap-2',
classNames?.inputWrapper
)}
>
<input
type='checkbox'
ref={ref}
id={name}
name={name}
className={cn(
'w-full text-sm font-normal leading-5 flex items-start gap-2',
'checkbox cursor-pointer',
{
'text-error': isError,
'text-gray-500': disabled,
'border-error': isError,
'border-success': isValid,
},
className?.label
className,
classNames?.checkbox
)}
>
{/* Checkbox */}
<div className='flex items-center gap-2'>
{/* Start Adornment */}
{startAdornment && (
<span className={cn({ 'opacity-50': disabled })}>
{startAdornment}
</span>
{...rest}
/>
{label && (
<label
htmlFor={name}
className={cn(
'text-inherit',
{
'text-error': isError,
'text-success': isValid,
},
classNames?.label
)}
>
{label}
</label>
)}
</div>
{/* Checkbox Input */}
<div className='relative'>
<input
type='checkbox'
id={checkboxId}
name={name}
value={value}
checked={checked}
onChange={onChange}
onBlur={onBlur}
onFocus={onFocus}
disabled={disabled}
readOnly={readOnly}
className={cn(
'checkbox',
sizeClasses[size],
variantClasses[variant],
{
'opacity-50 cursor-not-allowed': disabled,
'cursor-pointer': !disabled && !readOnly,
},
className?.checkbox
)}
ref={(input) => {
if (input) {
input.indeterminate = indeterminate;
}
}}
/>
{/* Loading State */}
{isLoading && (
<div className='absolute inset-0 flex items-center justify-center'>
<span className='loading loading-spinner loading-xs opacity-50' />
</div>
)}
</div>
{/* Label Text */}
<span
className={cn('flex-1', {
'line-through opacity-50': disabled,
})}
>
{label}
{required && (
<>
{' '}
<span
className={cn('tooltip', {
'tooltip-error': isError,
'tooltip-base': !isError,
})}
data-tip={tooltip || 'required'}
>
<span
className={cn('text-xs font-bold', {
'text-error': isError,
'text-base-content/70': !isError,
})}
>
*
</span>
</span>
</>
)}
</span>
{/* End Adornment */}
{endAdornment && (
<span className={cn({ 'opacity-50': disabled })}>
{endAdornment}
</span>
)}
</div>
</label>
)}
{/* Description */}
{description && (
<p
className={cn('text-xs leading-4', {
'text-gray-500': !isError,
'text-error': isError,
'opacity-50': disabled,
})}
>
{description}
</p>
)}
{/* Field Message */}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{errorMessage && <span className='text-error'>{errorMessage}</span>}
</div>
);
};
+5 -9
View File
@@ -2,7 +2,6 @@ import { Ref } from 'react';
import { cn } from '@/lib/helper';
import { TextInputProps } from '@/components/input/TextInput';
import FieldMessage from '@/components/helper/FieldMessage';
interface FileInputProps
extends Omit<
@@ -38,9 +37,6 @@ const FileInput = ({
onBlur,
readOnly = false,
}: FileInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -80,11 +76,11 @@ const FileInput = ({
readOnly={readOnly}
/>
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
+194 -325
View File
@@ -6,24 +6,55 @@ import {
FocusEventHandler,
ReactNode,
useEffect,
useRef,
useState,
} from 'react';
import { Icon } from '@iconify/react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
import Inputmask from 'inputmask';
export type MaskType = 'currency' | 'weight' | 'decimal' | 'number';
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 {
// Basic Props
label?: string;
bottomLabel?: string;
name: string;
value?: number | string;
placeholder?: string;
// Styling Props
className?: {
wrapper?: string;
label?: string;
@@ -31,7 +62,6 @@ export interface NumberInputProps {
input?: string;
};
// State Props
isError?: boolean;
isValid?: boolean;
errorMessage?: string;
@@ -40,16 +70,13 @@ export interface NumberInputProps {
required?: boolean;
isLoading?: boolean;
// Adornment Props
startAdornment?: ReactNode;
endAdornment?: ReactNode;
// Event Handlers
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
onFocus?: FocusEventHandler<HTMLInputElement>;
// Masking Options
maskType?: MaskType;
decimals?: number;
thousandSeparator?: string;
@@ -57,131 +84,50 @@ export interface NumberInputProps {
currencyPrefix?: string;
weightUnit?: string;
// Validation Props
min?: number;
max?: number;
allowNegative?: boolean;
// Stepper Options
showSteppers?: boolean;
step?: number;
oncomplete?: () => void;
onincomplete?: () => void;
oncleared?: () => void;
}
// UTILITY FUNCTIONS
/**
* Core number formatting function
* Formats number with thousand separator and decimal separator
*/
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;
};
/**
* Parse formatted string to number
* Converts formatted input back to raw number for processing
*/
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;
};
/**
* Clean and validate numeric input while typing
* Ensures only valid characters are allowed
*/
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;
};
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>('');
// CONFIG & HELPERS
const allowDecimal =
maskType === 'decimal' || maskType === 'weight' || decimals > 0;
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);
const getInputPrefix = (): string => {
switch (maskType) {
@@ -201,165 +147,93 @@ const NumberInput = ({
}
};
const getFormattedValue = (rawValue: number | string): string => {
if (rawValue === '' || rawValue === null || rawValue === undefined)
return '';
switch (maskType) {
case 'currency':
case 'weight':
case 'decimal':
case 'number':
return formatNumber(
rawValue,
decimals,
thousandSeparator,
decimalSeparator
);
default:
return String(rawValue);
}
};
// EFFECTS
useEffect(() => {
setDisplayValue(getFormattedValue(value || ''));
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]);
// EVENT HANDLERS
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
const currentValue = (e.currentTarget as HTMLInputElement).value;
console.log('✅ After format:', currentValue);
// 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) {
const syntheticEvent = {
...e,
target: {
...e.target,
name,
value: numericValue.toString(),
},
} as ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
};
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(),
value: currentValue,
},
} as ChangeEvent<HTMLInputElement>;
onChange(syntheticEvent);
}
};
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);
}
};
// RENDER CALCULATIONS
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
const inputPrefix = getInputPrefix();
const inputSuffix = getInputSuffix();
@@ -393,9 +267,7 @@ const NumberInput = ({
</label>
)}
{/* Input Container */}
<div className='relative flex'>
{/* Prefix Block */}
{inputPrefix && (
<div
className={cn(
@@ -420,7 +292,6 @@ const NumberInput = ({
</div>
)}
{/* Input Wrapper */}
<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',
@@ -436,35 +307,15 @@ const NumberInput = ({
className?.inputWrapper
)}
>
{/* Stepper Buttons */}
{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>
)}
{/* Start Adornment */}
{startAdornment && startAdornment}
{/* Main Input */}
<input
type='text'
id={name}
name={name}
placeholder={placeholder}
value={displayValue}
onChange={handleInputChange}
ref={inputRef}
placeholder={placeholder || '0'}
onKeyUp={handleKeyUp}
onFocus={onFocus}
onBlur={onBlur}
disabled={disabled}
@@ -477,37 +328,19 @@ const NumberInput = ({
className?.input
)}
readOnly={readOnly}
inputMode='decimal'
inputMode='text'
autoComplete='off'
spellCheck={false}
/>
{/* End Adornment & Loading */}
{(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>
{/* Suffix Block */}
{inputSuffix && (
<div
className={cn(
@@ -533,14 +366,50 @@ const NumberInput = ({
)}
</div>
{/* Field Message */}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{(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>
);
};
export default NumberInput;
+9 -8
View File
@@ -2,7 +2,6 @@
import { ChangeEventHandler, ReactNode } from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface RadioOption {
label: string;
@@ -48,8 +47,6 @@ const RadioInput = ({
onChange,
onBlur,
}: RadioInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div className={cn('w-full flex flex-col gap-2', className?.wrapper)}>
{/* Label atas */}
@@ -100,11 +97,15 @@ const RadioInput = ({
))}
</div>
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{/* Label bawah */}
{!isError && bottomLabel && (
<p className='text-sm opacity-60'>{bottomLabel}</p>
)}
{/* Pesan error */}
{isError && errorMessage && (
<p className='text-sm text-error'>{errorMessage}</p>
)}
</div>
);
};
+4 -9
View File
@@ -12,7 +12,6 @@ import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface OptionType {
value: string | number;
@@ -118,9 +117,6 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}
};
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -218,11 +214,10 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}}
/>
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
</div>
);
};
+5 -9
View File
@@ -2,7 +2,6 @@
import React, { useState, KeyboardEvent, ChangeEvent, useEffect } from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TagInputProps {
label?: string;
@@ -74,9 +73,6 @@ const TagInput: React.FC<TagInputProps> = ({
setInputValue(e.target.value);
};
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -161,11 +157,11 @@ const TagInput: React.FC<TagInputProps> = ({
)}
</div>
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{/* Bottom label or error message */}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
+4 -9
View File
@@ -3,7 +3,6 @@
import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TextAreaProps {
label?: string;
@@ -51,9 +50,6 @@ const TextArea = ({
isLoading = false,
rows = 3,
}: TextAreaProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -113,11 +109,10 @@ const TextArea = ({
</div>
)}
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
</div>
);
};
+6 -9
View File
@@ -8,7 +8,6 @@ import {
} from 'react';
import { cn } from '@/lib/helper';
import FieldMessage from '@/components/helper/FieldMessage';
export interface TextInputProps {
type?: HTMLInputTypeAttribute;
@@ -56,9 +55,6 @@ const TextInput = ({
readOnly = false,
isLoading = false,
}: TextInputProps) => {
const showErrorMessage = Boolean(isError && errorMessage);
const feedbackMessage = showErrorMessage ? errorMessage : bottomLabel;
return (
<div
className={cn(
@@ -123,11 +119,12 @@ const TextInput = ({
)}
</div>
<FieldMessage
message={feedbackMessage}
tone={showErrorMessage ? 'error' : 'info'}
isVisible={showErrorMessage || Boolean(bottomLabel)}
/>
{!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>
);
};
@@ -29,7 +29,6 @@ import { SupplierApi, WarehouseApi } from '@/services/api/master-data';
import { ProductWarehouseApi } from '@/services/api/inventory';
import { toast } from 'react-hot-toast';
import FileInput from '@/components/input/FileInput';
import FieldMessage from '@/components/helper/FieldMessage';
import CheckboxInput from '@/components/input/CheckboxInput';
interface MovementFormProps {
@@ -848,7 +847,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedProducts.length &&
formik.values.products?.length > 0
}
onChange={(e) => {
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts(
formik.values.products?.map(
@@ -859,8 +860,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setSelectedProducts([]);
}
}}
naked={true}
size='sm'
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</th>
@@ -891,11 +894,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<tr key={`product-row-${idx}-${product.product_id}`}>
{type !== 'detail' && (
<td>
<div className='flex flex-col items-center gap-2'>
<div className='flex justify-center'>
<CheckboxInput
name={`product-${idx}`}
checked={selectedProducts.includes(idx)}
onChange={(e) => {
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedProducts([
...selectedProducts,
@@ -907,10 +912,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
}
}}
naked={true}
size='sm'
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
<FieldMessage message={null} isVisible={false} />
</div>
</td>
)}
@@ -1006,7 +1012,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
height={24}
/>
</Button>
<FieldMessage message={null} isVisible={false} />
</div>
</td>
)}
@@ -1064,7 +1069,9 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
selectedDeliveries.length &&
formik.values.deliveries?.length > 0
}
onChange={(e) => {
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries(
formik.values.deliveries?.map(
@@ -1075,8 +1082,10 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
setSelectedDeliveries([]);
}
}}
naked={true}
size='sm'
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
</div>
</th>
@@ -1153,11 +1162,13 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
<tr key={`delivery-row-${idx}`}>
{type !== 'detail' && (
<td>
<div className='flex flex-col items-start gap-2'>
<div className='flex justify-center'>
<CheckboxInput
name={`delivery-${idx}`}
checked={selectedDeliveries.includes(idx)}
onChange={(e) => {
onChange={(
e: React.ChangeEvent<HTMLInputElement>
) => {
if (e.target.checked) {
setSelectedDeliveries([
...selectedDeliveries,
@@ -1171,10 +1182,11 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
);
}
}}
naked={true}
size='sm'
classNames={{
wrapper: 'flex justify-center',
checkbox: 'checkbox checkbox-sm',
}}
/>
<FieldMessage message={null} isVisible={false} />
</div>
</td>
)}
@@ -1323,10 +1335,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
'-'
)}
</Button>
<FieldMessage
message={null}
isVisible={false}
/>
</div>
</>
) : (
@@ -1444,7 +1452,6 @@ const MovementForm = ({ type = 'add', initialValues }: MovementFormProps) => {
height={24}
/>
</Button>
<FieldMessage message={null} isVisible={false} />
</div>
</td>
)}
+4
View File
@@ -8,4 +8,8 @@
--step-bg: var(--color-error);
--step-fg: var(--color-error-content);
}
.table :where(th, td) {
vertical-align: top;
}
}