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
+180 -159
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,153 +101,189 @@ 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'>
<Button
href='/production/project-flock'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<>
<section className='w-full p-4'>
<header className='flex flex-col gap-4'>
<Button
href='/production/project-flock'
variant='link'
className='w-fit p-0 text-primary'
>
<Icon icon='uil:arrow-left' width={24} height={24} />
Kembali
</Button>
<div className='flex flex-col gap-4 w-full my-4'>
<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'
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);
}}
/>
</div>
</div>
</header>
<Table<Kandang>
data={projectFlock.data?.kandangs}
columns={[
{
header: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama Kandang',
},
{
header: 'Aksi',
cell: (props) => {
return (
<>
<Button
color='success'
variant='outline'
onClick={() => {
handleChickinClick(props.row.original);
}}
disabled={isLoadingProjectFlockKandang}
>
<Icon
icon='mdi:home-import-outline'
width={24}
height={24}
/>
Chickin
</Button>
</>
);
},
},
]}
page={undefined}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(projectFlock) &&
projectFlock.data?.kandangs?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
</section>
<Modal ref={chickinModal.ref}>
<div className='flex flex-row justify-between items-center'>
<h1 className='text-xl font-semibold text-center mb-6'>
Chickin Kandang - {selectedKandang?.name}
</h1>
<Button
color='error'
variant='link'
onClick={chickinModal.closeModal}
>
<Icon
className='text-black'
icon='uil:times'
width={24}
height={24}
/>
</Button>
</div>
{isResponseSuccess(projectFlockKandang) &&
!isLoadingProjectFlockKandang && (
<ChickinForm
initialValues={{
project_flock_kandang: projectFlockKandang.data,
created_user: projectFlock.data?.created_user,
created_at: projectFlock.data?.created_at,
updated_at: projectFlock.data?.updated_at,
approval: projectFlock.data?.approval,
<div className='flex flex-col gap-4 w-full my-4'>
<div className='max-w-full sm:max-w-1/2 md:max-w-3/5 lg:max-w-2/5'>
<SelectInput
required
label='Project Flock'
placeholder='Pilih project flock'
options={options}
onInputChange={(val) => {
setSearchProjectFlock(val);
}}
afterSubmit={handleAfterSubmit}
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>
)
}
/>
)}
</Modal>
<ConfirmationModal
ref={alertModal.ref}
type='info'
text={`Persediaan Day Old Chick pada kandang (${selectedKandang?.name}) belum ada, mohon isi terlebih dahulu di bagian Persediaan!`}
secondaryButton={undefined}
primaryButton={{
text: 'Ya',
color: 'info',
onClick: () => {
alertModal.closeModal();
</div>
</div>
</header>
<Table<Kandang>
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: '#',
cell: (props) =>
tableFilterState.pageSize * (tableFilterState.page - 1) +
props.row.index +
1,
},
{
accessorKey: 'name',
header: 'Nama Kandang',
},
{
header: 'Aksi',
cell: (props) => {
return (
<>
<Button
color='success'
variant='outline'
onClick={() => {
handleChickinClick(props.row.original);
}}
disabled={isLoadingProjectFlockKandang}
>
<Icon
icon='mdi:home-import-outline'
width={24}
height={24}
/>
Chickin
</Button>
</>
);
},
},
]}
page={undefined}
className={{
containerClassName: cn({
'mb-20':
isResponseSuccess(projectFlock) &&
projectFlock.data?.kandangs?.length === 0,
}),
tableWrapperClassName: 'overflow-x-auto min-h-full!',
tableClassName: 'font-inter w-full table-auto min-h-full!',
headerRowClassName: 'border-b border-b-gray-200',
headerColumnClassName:
'px-6 py-3 text-xs font-semibold text-gray-500 last:flex last:flex-row last:justify-end',
bodyRowClassName: 'border-b border-b-gray-200',
bodyColumnClassName:
'px-6 py-3 last:flex last:flex-row last:justify-end',
paginationClassName: 'hidden',
}}
/>
</>
)}
</section>
<Modal ref={chickinModal.ref}>
<div className='flex flex-row justify-between items-center'>
<h1 className='text-xl font-semibold text-center mb-6'>
Chickin Kandang - {selectedKandang?.name}
</h1>
<Button
color='error'
variant='link'
onClick={chickinModal.closeModal}
>
<Icon
className='text-black'
icon='uil:times'
width={24}
height={24}
/>
</Button>
</div>
{isResponseSuccess(projectFlockKandang) &&
isResponseSuccess(projectFlock) &&
!isLoadingProjectFlockKandang && (
<ChickinForm
initialValues={{
project_flock_kandang: projectFlockKandang.data,
created_user: projectFlock.data?.created_user,
created_at: projectFlock.data?.created_at,
updated_at: projectFlock.data?.updated_at,
approval: projectFlock.data?.approval,
}}
afterSubmit={handleAfterSubmit}
/>
)}
</Modal>
<ConfirmationModal
ref={alertModal.ref}
type='info'
text={`Persediaan Day Old Chick pada kandang (${selectedKandang?.name}) belum ada, mohon isi terlebih dahulu di bagian Persediaan!`}
secondaryButton={undefined}
primaryButton={{
text: 'Ya',
color: 'info',
onClick: () => {
alertModal.closeModal();
},
}}
/>
</>
</>
);
};
+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>
);
+64 -19
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,17 +202,19 @@ const SelectInput = <T extends OptionType>(props: SelectInputProps<T>) => {
placeholder={placeholder}
className={cn('w-full', className?.select)}
classNames={{
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
{
'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,
}
),
valueContainer: () => cn('flex-1 px-4! py-2! gap-1'),
...(!startAdornment && {
control: ({ isFocused, isDisabled }) =>
cn(
'w-full min-h-12! rounded border bg-white transition-shadow cursor-pointer!',
{
'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,
}
),
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
}
@@ -371,10 +371,10 @@ const ProjectFlockTable = () => {
const selectableRows = allRows.filter(
(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',