Merge branch 'dev/randy' into 'feat/FE/US-34/stock-adjustment'

[FEAT/FE][US#33][TASK#51-54] Form Validation and UI/UX Adjustment

See merge request mbugroup/lti-web-client!10
This commit is contained in:
Rivaldi A N S
2025-10-13 06:53:00 +00:00
7 changed files with 175 additions and 132 deletions
+96 -82
View File
@@ -1,28 +1,38 @@
'use client';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import Select, { OptionProps, GroupBase, InputActionMeta } from 'react-select';
import {
ComponentType,
ReactNode,
useEffect,
useMemo,
useState,
} from 'react';
import Select, {
OptionProps,
GroupBase,
InputActionMeta,
MultiValue,
SingleValue,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn } from '@/lib/helper';
export interface OptionType {
value: string | number;
label: string;
className?: string; // for multi select
labelClassName?: string; // for multi select
className?: string;
labelClassName?: string;
}
export type OptionComponent<T = OptionType> = ComponentType<
OptionProps<T, boolean, GroupBase<T>>
>;
interface SelectInputProps<T = OptionType> {
interface SelectInputBaseProps<T = OptionType> {
label?: ReactNode;
bottomLabel?: ReactNode;
value?: T | T[];
onChange?: (val: T | T[] | null) => void;
options: T[];
optionComponent?: OptionComponent<T>;
isDisabled?: boolean;
@@ -46,52 +56,78 @@ interface SelectInputProps<T = OptionType> {
onInputChange?: (search: string) => void;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
createables?: boolean;
value?: T | T[] | null;
onChange?: (val: T | T[] | null) => void;
}
const animatedComponents = makeAnimated();
const SelectInput = <T extends OptionType>({
label,
bottomLabel,
value,
onChange,
options,
optionComponent,
isDisabled,
isLoading,
isClearable,
isRtl,
isSearchable = true,
isMulti,
placeholder,
required,
className,
isError,
errorMessage,
isAnimated = true,
openMenu,
delay = 300,
onInputChange,
}: SelectInputProps) => {
const [internalInputValue, setInternalInputValue] = useState('');
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const {
label,
bottomLabel,
value,
onChange,
options,
optionComponent,
isDisabled,
isLoading,
isClearable,
isRtl,
isSearchable = true,
isMulti,
placeholder,
required,
className,
isError,
errorMessage,
isAnimated = true,
openMenu,
delay = 300,
createables = false,
onInputChange,
} = props;
const [debouncedInputValue] = useDebounce(internalInputValue, delay ?? 300);
const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
return {
...base,
IndicatorSeparator: () => null,
};
return { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
const internalInputChangeHandler = (value: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(value);
const internalInputChangeHandler = (
val: string,
meta: InputActionMeta
) => {
if (meta.action === 'input-change') setInternalInputValue(val);
if (meta.action === 'menu-close') setInternalInputValue('');
};
useEffect(() => {
onInputChange?.(debouncedInputValue);
}, [debouncedInputValue]);
}, [onInputChange, debouncedInputValue]);
const SelectComponent = createables ? CreatableSelect : Select;
/** 🎯 handleChange tanpa any */
const handleChange = (
val: MultiValue<T> | SingleValue<T>
): void => {
if (!val) {
onChange?.(null);
return;
}
if (isMulti) {
onChange?.(val as T[]);
} else {
onChange?.(val as T);
}
};
return (
<div
className={cn(
@@ -103,28 +139,23 @@ const SelectInput = <T extends OptionType>({
<span
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
{ 'text-error': isError },
className?.label
)}
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'> *</span>
</span>
</>
<span className="tooltip tooltip-error" data-tip="required">
<span className="text-error"> *</span>
</span>
)}
</span>
)}
<Select
instanceId='select'
value={value}
onChange={(val) => onChange?.(val as T)}
<SelectComponent<T, boolean, GroupBase<T>>
instanceId="select"
value={value ?? (isMulti ? [] : null)}
onChange={handleChange}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
@@ -136,14 +167,13 @@ const SelectInput = <T extends OptionType>({
isRtl={isRtl}
isSearchable={isSearchable}
placeholder={placeholder}
className={cn('w-full', className)}
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded-lg! border bg-white transition-shadow cursor-pointer!',
{
'border-red-500! focus-within:border-red-500 focus-within:ring-2 focus-within:ring-red-200':
isError,
'border-red-500! ring-2 ring-red-200': isError,
'border-indigo-500 ring-2 ring-indigo-200': isFocused,
'border-gray-300': !isError && !isFocused,
'bg-gray-100 text-gray-400 cursor-not-allowed': isDisabled,
@@ -156,8 +186,6 @@ const SelectInput = <T extends OptionType>({
cn({ 'text-gray-900': !isError, 'text-error!': isError }),
input: () => cn('text-gray-900'),
indicatorsContainer: () => cn('flex items-center gap-1 pr-2'),
indicatorSeparator: () => cn('mx-1 h-4 w-px bg-gray-200'),
clearIndicator: () => cn('p-1 rounded-md hover:bg-gray-100'),
dropdownIndicator: ({ isFocused }) =>
cn('p-1 rounded-md hover:bg-gray-100', {
'text-gray-900': isFocused,
@@ -165,55 +193,41 @@ const SelectInput = <T extends OptionType>({
'text-error!': isError,
}),
menu: () =>
cn(
'border border-gray-200 rounded-lg bg-white shadow-lg rounded-lg!'
),
cn('border border-gray-200 rounded-lg bg-white shadow-lg!'),
menuList: () => cn('p-2! max-h-60 overflow-auto'),
groupHeading: () =>
cn('ml-2 mt-2 mb-1 text-xs font-medium text-gray-500'),
option: ({ isFocused, isSelected, isDisabled }) =>
cn('mt-1 px-3 py-2 rounded-md cursor-pointer! select-none', {
'text-gray-300': isDisabled,
option: ({ isFocused, isSelected }) =>
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused,
'text-gray-700': !isDisabled && !isFocused,
'active:bg-indigo-50': !isDisabled,
'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected,
}),
noOptionsMessage: () => cn('px-3 py-2 text-gray-500'),
loadingMessage: () => cn('px-3 py-2 text-gray-500'),
multiValue: ({ getValue, index }) => {
const selectedValues = getValue();
const selectedValues = getValue() as T[];
return cn(
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1 rounded-md!',
'bg-indigo-50 rounded-md py-0.5 pl-2 pr-1 flex items-center gap-1!',
selectedValues[index]?.className
);
},
multiValueLabel: ({ getValue, index }) => {
const selectedValues = getValue();
const selectedValues = getValue() as T[];
return cn('text-indigo-700', selectedValues[index]?.labelClassName);
},
multiValueRemove: () =>
cn('p-1 rounded-sm! hover:bg-indigo-100 hover:text-indigo-800'),
}}
components={{
...components,
...(optionComponent ? { Option: optionComponent } : {}),
}}
// make the menu float above modals/etc.
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
}
styles={{
// Tailwind can't set inline z-index on a portal; use styles here:
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
}}
/>
{isError && <p className='w-full text-sm text-error'>{errorMessage}</p>}
{isError && <p className="w-full text-sm text-error">{errorMessage}</p>}
{!isError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
<p className="w-full text-sm opacity-60">{bottomLabel}</p>
)}
</div>
);
+4 -4
View File
@@ -31,7 +31,7 @@ export interface TextAreaProps {
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLTextAreaElement>;
onBlur?: FocusEventHandler<HTMLTextAreaElement>;
cols?: number;
rows?: number;
}
const TextArea = ({
@@ -52,7 +52,7 @@ const TextArea = ({
onBlur,
readOnly = false,
isLoading = false,
cols = 3
rows = 3
}: TextAreaProps) => {
return (
<div
@@ -87,7 +87,7 @@ const TextArea = ({
<textarea
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all',
'input h-auto px-4 py-2 text-base font-normal leading-6 w-full rounded-lg! outline-none! transition-all',
{
'border-error': isError,
'border-success!': isValid,
@@ -98,7 +98,7 @@ const TextArea = ({
name={name}
placeholder={placeholder}
value={value}
cols={cols}
rows={rows}
onChange={onChange}
onBlur={onBlur}
disabled={disabled}
@@ -27,6 +27,7 @@ export const InventoryAdjustmentFormSchema = Yup.object({
transaction_type: Yup.string()
.oneOf(['increase', 'decrease'], 'Tipe transaksi tidak valid')
.nullable()
.required('Tipe transaksi wajib diisi'),
quantity: Yup.number()
@@ -77,7 +77,7 @@ const InventoryAdjustmentForm = ({
product: undefined,
warehouse: undefined,
quantity: initialValues?.quantity ?? 0,
transaction_type: initialValues?.transaction_type ?? 'increase',
transaction_type: undefined,
note: initialValues?.note ?? '',
};
}, [initialValues]);
@@ -153,13 +153,14 @@ const InventoryAdjustmentForm = ({
formik.setFieldValue('product_category_id', (val as OptionType)?.value);
formik.setFieldValue('product_category', val);
setSelectedProductCategories((val as OptionType)?.value as string);
const disabled = (val as OptionType)?.value == null;
setDisabledProduct(disabled);
if (disabled) {
formik.setFieldValue('product_id', 0);
formik.setFieldValue('product', null);
}
formik.setFieldValue('product_id', 0);
formik.setFieldValue('product', null);
formik.setFieldTouched('product', false);
formik.setFieldTouched('product_id', false);
};
const productChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -340,30 +341,6 @@ const InventoryAdjustmentForm = ({
isClearable
/>
{/* Number Input Stock */}
<TextInput
required
label={quantityLabel}
name='quantity'
type='text'
value={formatNumber(String(formik.values.quantity))}
onChange={(e) => {
const rawValue = e.target.value.replace(/,/g, '');
const numericValue = parseFloat(rawValue);
if (!isNaN(numericValue)) {
formik.setFieldValue('quantity', numericValue);
} else {
formik.setFieldValue('quantity', 0);
}
}}
onBlur={formik.handleBlur}
isError={
formik.touched.quantity && Boolean(formik.errors.quantity)
}
errorMessage={formik.errors.quantity as string}
readOnly={type === 'detail'}
/>
{/* Radio Button Flag Stock */}
<RadioInput
name='transaction_type'
@@ -387,10 +364,39 @@ const InventoryAdjustmentForm = ({
errorMessage={formik.errors.transaction_type as string}
variant='radio-primary'
required
bottomLabel='Pilih salah satu tipe transaksi'
bottomLabel={formik.values.transaction_type == undefined ? 'Pilih salah satu tipe transaksi' : undefined}
disabled={type === 'detail'}
/>
{/* Number Input Stock */}
<TextInput
className={{
wrapper: `${formik.values.transaction_type != undefined ? '' : 'hidden'}`,
}}
required
label={quantityLabel}
name='quantity'
type='text'
value={formatNumber(String(formik.values.quantity))}
onChange={(e) => {
const rawValue = e.target.value.replace(/,/g, '');
const numericValue = parseFloat(rawValue);
if (!isNaN(numericValue)) {
formik.setFieldValue('quantity', numericValue);
} else {
formik.setFieldValue('quantity', 0);
}
}}
onBlur={formik.handleBlur}
isError={
formik.touched.quantity && Boolean(formik.errors.quantity)
}
errorMessage={formik.errors.quantity as string}
readOnly={type === 'detail'}
/>
{/* Text Area Input Reason */}
<TextArea
required
@@ -307,7 +307,6 @@ const CustomerForm = ({
isError={formik.touched.address && Boolean(formik.errors.address)}
errorMessage={formik.errors.address}
readOnly={formType === 'detail'}
cols={8}
/>
</div>
@@ -2,7 +2,10 @@ import * as Yup from 'yup';
export const SupplierFormSchema = Yup.object({
name: Yup.string().required('Nama wajib diisi!'),
alias: Yup.string().required('Alias wajib diisi!'),
alias: Yup.string()
.matches(/^[A-Za-z0-9]+$/, 'Alias hanya boleh berisi huruf dan angka tanpa spasi atau simbol!')
.max(5, 'Alias maksimal 5 karakter!')
.required('Alias wajib diisi!'),
pic: Yup.string().required('PIC wajib diisi!'),
type: Yup.object({
value: Yup.string().required(),
@@ -21,7 +21,6 @@ import SelectInput, { OptionType } from '@/components/input/SelectInput';
import { Icon } from '@iconify/react';
import Button from '@/components/Button';
import TextInput from '@/components/input/TextInput';
import TagInput from '@/components/input/TagInput';
import TextArea from '@/components/input/TextArea';
import { cn } from '@/lib/helper';
import ConfirmationModal from '@/components/modal/ConfirmationModal';
@@ -42,7 +41,7 @@ const SupplierForm = ({
// Setup State
const [supplierFormErrorMessage, setSupplierFormErrorMessage] = useState('');
const [isDeleteLoading, setIsDeleteLoading] = useState(false);
const [hatcheryTagInputValue, setHatcheryTagInputValue] = useState('');
const [hatcheryOptionsValues, setHatcheryOptionValues] = useState<OptionType[]>([]);
// -- Options data mapping
const typeOptions = TYPE_OPTIONS;
@@ -168,8 +167,22 @@ const SupplierForm = ({
// Initialize Formik
useEffect(() => {
formikSetValues(formikInitialValues);
setHatcheryTagInputValue(formikInitialValues.hatchery);
}, [formikSetValues, formikInitialValues, hatcheryTagInputValue]);
if(formType != 'add'){
const hatcheryArrays = formikInitialValues.hatchery.split(',');
const hatcheryCreatedOptions = hatcheryArrays.map((item) => ({
value: item,
label: item,
}));
setHatcheryOptionValues(hatcheryCreatedOptions);
}
}, [formikSetValues, formikInitialValues, setHatcheryOptionValues]);
useEffect(() => {
const commaSeparatedValues = hatcheryOptionsValues.map((item) => item.value).join(',');
formikSetValues({
...formik.values,
hatchery: commaSeparatedValues,
})
}, [hatcheryOptionsValues, formikSetValues]);
// Option Handler
const typeChangeHandler = (val: OptionType | OptionType[] | null) => {
@@ -281,17 +294,25 @@ const SupplierForm = ({
isClearable
isSearchable={true}
/>
<TagInput
name='hatchery'
<SelectInput
isMulti
createables
required
placeholder='Pilih Hatchery'
label='Hatchery'
value={hatcheryTagInputValue}
onChange={(value) => formik.setFieldValue('hatchery', value)}
isError={
formik.touched.hatchery && Boolean(formik.errors.hatchery)
}
errorMessage={formik.errors.hatchery}
readOnly={formType === 'detail'}
value={hatcheryOptionsValues}
onChange={(val) => {
console.log(val); // pastikan val = array of { value, label }
setHatcheryOptionValues(val as OptionType[]);
}}
isError={formik.touched.hatchery && Boolean(formik.errors.hatchery)}
errorMessage={formik.errors.hatchery as string}
isDisabled={formType === 'detail'}
isClearable
isSearchable={true}
options={[]}
/>
<TextInput
required
label='Nomor Telepon'
@@ -327,7 +348,6 @@ const SupplierForm = ({
isError={formik.touched.address && Boolean(formik.errors.address)}
errorMessage={formik.errors.address}
readOnly={formType === 'detail'}
cols={8}
/>
<TextInput
required