From 99194eaf8024ca7a08fb9f586922d515cee2f585 Mon Sep 17 00:00:00 2001 From: randy-ar Date: Thu, 30 Oct 2025 21:23:37 +0700 Subject: [PATCH 01/18] refactor(FE-92-87): mengganti select input dengan reuseable component --- src/app/production/chickin/add/page.tsx | 339 ++++++++++-------- src/components/input/DateInput.tsx | 83 +++-- src/components/input/SelectInput.tsx | 83 ++++- .../project-flock/ProjectFlockTable.tsx | 14 +- src/config/constant.ts | 6 + 5 files changed, 320 insertions(+), 205 deletions(-) diff --git a/src/app/production/chickin/add/page.tsx b/src/app/production/chickin/add/page.tsx index 3ef73396..a52dbe10 100644 --- a/src/app/production/chickin/add/page.tsx +++ b/src/app/production/chickin/add/page.tsx @@ -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(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 ( -
- -
- ); - } - - 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) && ( - <> -
-
- + <> +
+
+ -
-
- - router.push( - `/production/chickin/add?projectFlockId=${ - (val as OptionType | null)?.value - }` - ) - } - onInputChange={(val) => { - setSearchProjectFlock(val); - }} - /> -
-
-
- - 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 ( - <> - - - ); - }, - }, - ]} - 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', - }} - /> -
- -
-

- Chickin Kandang - {selectedKandang?.name} -

- -
- {isResponseSuccess(projectFlockKandang) && - !isLoadingProjectFlockKandang && ( - +
+ { + 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) && ( + + Periode {projectFlock.data?.period} + + ) + } /> - )} - - { - alertModal.closeModal(); +
+ +
+ + emptyContent={ +
+ {projectFlockId && isResponseError(projectFlock) ? ( + + {projectFlock.message} + + ) : ( + + Pilih project flock terlebih dahulu... + + )} +
+ } + 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 ( + <> + + + ); + }, + }, + ]} + 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', }} /> - - )} +
+ +
+

+ Chickin Kandang - {selectedKandang?.name} +

+ +
+ {isResponseSuccess(projectFlockKandang) && + isResponseSuccess(projectFlock) && + !isLoadingProjectFlockKandang && ( + + )} +
+ { + alertModal.closeModal(); + }, + }} + /> + ); }; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index be485b75..2b91b7ae 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -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(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 = (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); + }; + + const finalIsError = externalError || !!internalError; + const finalErrorMessage = internalError || externalErrorMessage; + return (
@@ -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 = ({ )}
- {!isError && bottomLabel && ( + {!finalIsError && bottomLabel && (

{bottomLabel}

)} - {isError && errorMessage && ( -

{errorMessage}

+ {finalIsError && finalErrorMessage && ( +

{finalErrorMessage}

)} ); diff --git a/src/components/input/SelectInput.tsx b/src/components/input/SelectInput.tsx index 6a8d0ac8..8db939bd 100644 --- a/src/components/input/SelectInput.tsx +++ b/src/components/input/SelectInput.tsx @@ -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 { openMenu?: boolean; delay?: number; onInputChange?: (search: string) => void; + startAdornment?: ReactNode; } interface SelectInputProps extends SelectInputBaseProps { @@ -63,6 +65,33 @@ interface SelectInputProps extends SelectInputBaseProps { const animatedComponents = makeAnimated(); +const CustomControl = < + Option, + IsMulti extends boolean, + Group extends GroupBase