mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 21:41:57 +00:00
fix(merge): resolve conflict on merge
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -8,4 +8,8 @@
|
||||
--step-bg: var(--color-error);
|
||||
--step-fg: var(--color-error-content);
|
||||
}
|
||||
|
||||
.table :where(th, td) {
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user