refactor(FE-92-87): mengganti select input dengan reuseable component

This commit is contained in:
randy-ar
2025-10-30 21:23:37 +07:00
parent d0d201bf3a
commit 99194eaf80
5 changed files with 320 additions and 205 deletions
+58 -37
View File
@@ -1,5 +1,6 @@
'use client';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import SelectInput, { OptionType } from '@/components/input/SelectInput';
import Modal, { useModal } from '@/components/Modal';
@@ -39,10 +40,12 @@ const AddChickin = () => {
const [isLoadingProjectFlockKandang, setIsLoadingProjectFlockKandang] =
useState(false);
const [searchProjectFlock, setSearchProjectFlock] = useState('');
const [selectedProjectFlock, setSelectedProjectFlock] =
useState<OptionType | null>(null);
// Fetch Data
const { data: projectFlock, isLoading: isLoadingProjectFlock } = useSWR(
projectFlockId,
projectFlockId ?? selectedProjectFlock?.value.toString(),
(id: number) => ProjectFlockApi.getSingle(id)
);
const { data: listProjectFlock, isLoading: isLoadingListProjectFlock } =
@@ -59,7 +62,7 @@ const AddChickin = () => {
? listProjectFlock?.data.map((projectFlock) => {
return {
value: projectFlock.id,
label: `${projectFlock?.flock?.name} - ${projectFlock?.category} - Periode ${projectFlock.period}`,
label: `${projectFlock?.flock?.name} - Periode ${projectFlock?.period}`,
};
})
: [];
@@ -67,24 +70,6 @@ const AddChickin = () => {
const chickinModal = useModal();
const alertModal = useModal();
if (!projectFlockId) {
router.back();
return (
<div className='w-full flex flex-row justify-center items-center p-4'>
<span className='loading loading-spinner loading-xl' />
</div>
);
}
if (
!isLoadingProjectFlock &&
(!projectFlock || isResponseError(projectFlock))
) {
router.replace('/404');
return;
}
// Handle Function
const handleChickinClick = async (kandang: Kandang) => {
setIsLoadingProjectFlockKandang(true);
@@ -95,7 +80,7 @@ const AddChickin = () => {
>(getProjectFlockKandangUrl, {
method: 'GET',
params: {
project_flock_id: projectFlockId ?? 0,
project_flock_id: projectFlockId ?? selectedProjectFlock?.value ?? 0,
kandang_id: kandang.id,
},
});
@@ -116,10 +101,15 @@ const AddChickin = () => {
chickinModal.closeModal();
router.push('/production/chickin');
};
const handleChangeProjectFlock = (val: OptionType | null) => {
setSelectedProjectFlock(val);
if (projectFlockId) {
router.push('/production/chickin/add');
}
};
return (
<>
{isResponseSuccess(projectFlock) && (
<>
<section className='w-full p-4'>
<header className='flex flex-col gap-4'>
@@ -136,30 +126,61 @@ const AddChickin = () => {
<div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'>
<SelectInput
required
isSearchable
label='Project Flock'
placeholder='Pilih project flock'
options={options}
isLoading={isLoadingListProjectFlock}
value={{
label: `${projectFlock.data?.flock?.name} - ${projectFlock.data?.category} - Periode ${projectFlock.data?.period}`,
value: projectFlock.data?.id,
}}
onChange={(val) =>
router.push(
`/production/chickin/add?projectFlockId=${
(val as OptionType | null)?.value
}`
)
}
onInputChange={(val) => {
setSearchProjectFlock(val);
}}
isLoading={isLoadingListProjectFlock}
value={
isResponseSuccess(projectFlock)
? {
label: `${projectFlock.data?.flock?.name}`,
value: projectFlock.data?.id,
}
: undefined
}
onChange={(val) => {
handleChangeProjectFlock(val as OptionType);
}}
isSearchable
isClearable
startAdornment={
isResponseSuccess(projectFlock) && (
<Badge
variant='soft'
color='success'
size='sm'
className={{
badge: 'whitespace-nowrap font-semibold',
}}
>
Periode {projectFlock.data?.period}
</Badge>
)
}
/>
</div>
</div>
</header>
<Table<Kandang>
data={projectFlock.data?.kandangs}
emptyContent={
<div className='w-full p-5 text-center'>
{projectFlockId && isResponseError(projectFlock) ? (
<span className='text-lg opacity-50'>
{projectFlock.message}
</span>
) : (
<span className='text-lg opacity-50'>
Pilih project flock terlebih dahulu...
</span>
)}
</div>
}
data={
isResponseSuccess(projectFlock) ? projectFlock.data?.kandangs : []
}
columns={[
{
header: '#',
@@ -235,6 +256,7 @@ const AddChickin = () => {
</Button>
</div>
{isResponseSuccess(projectFlockKandang) &&
isResponseSuccess(projectFlock) &&
!isLoadingProjectFlockKandang && (
<ChickinForm
initialValues={{
@@ -262,7 +284,6 @@ const AddChickin = () => {
}}
/>
</>
)}
</>
);
};
+63 -20
View File
@@ -4,10 +4,24 @@ import {
ChangeEventHandler,
FocusEventHandler,
ReactNode,
useState,
} from 'react';
import { cn } from '@/lib/helper';
const formatToISO = (dateStr: string): string | null => {
const parts = dateStr.split('/');
if (parts.length !== 3) return null;
const [day, month, year] = parts;
if (!day || !month || !year) return null;
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
};
const formatToLocal = (isoDate: string): string => {
if (!isoDate) return '';
const [year, month, day] = isoDate.split('-');
return `${day}/${month}/${year}`;
};
export interface DateInputProps {
label?: string;
bottomLabel?: string;
@@ -44,9 +58,9 @@ const DateInput = ({
min,
max,
className,
isError,
isValid,
errorMessage,
isError: externalError,
isValid: externalValid,
errorMessage: externalErrorMessage,
startAdornment,
endAdornment,
disabled = false,
@@ -56,6 +70,40 @@ const DateInput = ({
readOnly = false,
isLoading = false,
}: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null);
const minISO = min ? formatToISO(min) ?? undefined : undefined;
const maxISO = max ? formatToISO(max) ?? undefined : undefined;
const valueISO =
value && value.includes('/') ? formatToISO(value) ?? '' : value ?? '';
const handleChange: ChangeEventHandler<HTMLInputElement> = (e) => {
const selectedDate = e.target.value;
const isoMin = minISO;
const isoMax = maxISO;
if (isoMin && selectedDate < isoMin) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
} else if (isoMax && selectedDate > isoMax) {
setInternalError(`Tanggal tidak boleh setelah ${max}`);
} else {
setInternalError(null);
}
const event = {
...e,
target: {
...e.target,
value: formatToLocal(selectedDate),
},
};
onChange?.(event as React.ChangeEvent<HTMLInputElement>);
};
const finalIsError = externalError || !!internalError;
const finalErrorMessage = internalError || externalErrorMessage;
return (
<div
className={cn(
@@ -68,9 +116,7 @@ const DateInput = ({
htmlFor={name}
className={cn(
'w-full text-sm font-normal leading-5',
{
'text-error': isError,
},
{ 'text-error': finalIsError },
className?.label
)}
>
@@ -90,8 +136,8 @@ const DateInput = ({
className={cn(
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded outline-none! transition-all duration-200 flex items-center',
{
'border-error': isError,
'border-success!': isValid,
'border-error': finalIsError,
'border-success!': externalValid && !finalIsError,
},
className?.inputWrapper
)}
@@ -103,16 +149,13 @@ const DateInput = ({
id={name}
name={name}
placeholder={placeholder}
value={value}
onChange={onChange}
value={valueISO}
onChange={handleChange}
onBlur={onBlur}
min={min}
max={max}
min={minISO}
max={maxISO}
disabled={disabled}
className={cn(
'grow bg-transparent cursor-pointer',
className?.input
)}
className={cn('grow bg-transparent cursor-pointer', className?.input)}
readOnly={readOnly}
/>
@@ -124,11 +167,11 @@ const DateInput = ({
)}
</div>
{!isError && bottomLabel && (
{!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)}
{isError && errorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p>
{finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{finalErrorMessage}</p>
)}
</div>
);
+53 -8
View File
@@ -1,22 +1,23 @@
'use client';
import { ComponentType, ReactNode, useEffect, useMemo, useState } from 'react';
import useSWR from 'swr';
import Select, {
OptionProps,
GroupBase,
InputActionMeta,
MultiValue,
SingleValue,
components as ReactSelectComponents,
ControlProps,
} from 'react-select';
import CreatableSelect from 'react-select/creatable';
import makeAnimated from 'react-select/animated';
import { useDebounce } from 'use-debounce';
import { cn, getByPath } from '@/lib/helper';
import useSWR from 'swr';
import { httpClientFetcher } from '@/services/http/client';
import { isResponseSuccess } from '@/lib/api-helper';
import { BaseApiResponse } from '@/types/api/api-general';
import { isResponseSuccess } from '@/lib/api-helper';
export interface OptionType {
value: string | number;
@@ -53,6 +54,7 @@ interface SelectInputBaseProps<T = OptionType> {
openMenu?: boolean;
delay?: number;
onInputChange?: (search: string) => void;
startAdornment?: ReactNode;
}
interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
@@ -63,6 +65,33 @@ interface SelectInputProps<T = OptionType> extends SelectInputBaseProps<T> {
const animatedComponents = makeAnimated();
const CustomControl = <
Option,
IsMulti extends boolean,
Group extends GroupBase<Option>
>(
props: ControlProps<Option, IsMulti, Group>
) => {
const { children } = props;
const customProps = props.selectProps as unknown as {
shouldShowAdornment?: boolean;
startAdornment?: ReactNode;
};
const shouldShowAdornment = customProps.shouldShowAdornment ?? false;
const startAdornment = customProps.startAdornment;
return (
<ReactSelectComponents.Control {...props}>
<div className='flex-1 px-4! py-1.5 gap-1 flex items-center'>
{shouldShowAdornment && startAdornment}
{children}
</div>
</ReactSelectComponents.Control>
);
};
const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
const {
label,
@@ -87,15 +116,24 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
delay = 300,
createables = false,
onInputChange,
startAdornment,
} = props;
const [internalInputValue, setInternalInputValue] = useState('');
const [debouncedInputValue] = useDebounce(internalInputValue, delay);
const shouldShowAdornment = startAdornment && !internalInputValue;
const components = useMemo(() => {
const base = isAnimated ? animatedComponents : {};
return { ...base, IndicatorSeparator: () => null };
}, [isAnimated]);
const customComponents = { ...base, IndicatorSeparator: () => null };
if (startAdornment) {
customComponents.Control = CustomControl;
}
return customComponents;
}, [isAnimated, startAdornment]);
const internalInputChangeHandler = (val: string, meta: InputActionMeta) => {
if (meta.action === 'input-change') setInternalInputValue(val);
@@ -148,12 +186,13 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
<SelectComponent<T, boolean, GroupBase<T>>
instanceId='select'
value={value ?? (isMulti ? [] : null)}
onChange={handleChange}
value={value ?? (isMulti ? [] : undefined)}
onChange={onChange ? handleChange : undefined}
options={options}
menuIsOpen={openMenu}
inputValue={internalInputValue}
onInputChange={internalInputChangeHandler}
onMenuClose={() => setInternalInputValue('')}
isMulti={isMulti}
isDisabled={isDisabled}
isLoading={isLoading}
@@ -163,6 +202,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
placeholder={placeholder}
className={cn('w-full', className?.select)}
classNames={{
...(!startAdornment && {
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
@@ -174,6 +214,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
}
),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
}),
placeholder: () =>
cn({ 'text-gray-400': !isError, 'text-red-300!': isError }),
singleValue: () =>
@@ -190,7 +231,7 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
cn('border border-gray-200 rounded! bg-base-100 shadow-lg!'),
menuList: () => cn('p-2! max-h-60 overflow-auto'),
option: ({ isFocused, isSelected }) =>
cn('mt-1 px-3 py-2 rounded cursor-pointer!', {
cn('mt-1 px-3 py-2 rounded-md cursor-pointer!', {
'bg-indigo-600 text-white': isFocused,
'bg-blue-500!': isSelected,
'text-gray-700': !isFocused && !isSelected,
@@ -211,6 +252,10 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
...components,
...(optionComponent ? { Option: optionComponent } : {}),
}}
{...(startAdornment && {
shouldShowAdornment,
startAdornment,
})}
menuPortalTarget={
typeof document !== 'undefined' ? document.body : undefined
}
@@ -372,9 +372,9 @@ const ProjectFlockTable = () => {
(row) => row.original?.approval?.step_number == 1
);
const allSelected = selectableRows.every((row) =>
row.getIsSelected()
) && selectableRows.length != 0;
const allSelected =
selectableRows.every((row) => row.getIsSelected()) &&
selectableRows.length != 0;
const someSelected =
selectableRows.some((row) => row.getIsSelected()) &&
@@ -495,7 +495,7 @@ const ProjectFlockTable = () => {
return (
<>
{currentPageSize > 2 && (
{currentPageSize > 1 && (
<RowDropdownOptions isLast2Rows={isLast2Rows}>
<RowOptionsMenu
type='dropdown'
@@ -505,10 +505,10 @@ const ProjectFlockTable = () => {
</RowDropdownOptions>
)}
{currentPageSize <= 2 && (
{currentPageSize <= 1 && (
<RowCollapseOptions>
<RowOptionsMenu
type='dropdown'
type='collapse'
props={props}
deleteClickHandler={deleteClickHandler}
/>
+6
View File
@@ -40,6 +40,12 @@ export const MAIN_DRAWER_LINKS: MAIN_DRAWER_MENU[] = [
],
},
{
title: 'Penjualan',
link: '/sale',
icon: 'mdi:attach-money',
},
{
title: 'Persediaan',
link: '/inventory',