refactor(FE-238-106): change dateinput and create chickin page

This commit is contained in:
randy-ar
2025-11-03 10:09:12 +07:00
parent 495e11c6fe
commit 3eb2930640
8 changed files with 580 additions and 206 deletions
+38 -23
View File
@@ -1,6 +1,13 @@
'use client';
import { ReactNode, RefObject, useCallback, useRef, useState } from 'react';
import {
ReactNode,
RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import { cn } from '@/lib/helper';
export const useModal = () => {
@@ -8,31 +15,35 @@ export const useModal = () => {
const [open, setOpen] = useState(false);
const openModal = useCallback(() => {
if (!ref.current) return;
ref.current.showModal();
setOpen(true);
ref.current?.showModal();
}, []);
const closeModal = useCallback(() => {
if (!ref.current) return;
ref.current.close();
setOpen(false);
ref.current?.close();
}, []);
const toggle = useCallback(() => {
if (open) {
closeModal();
} else {
openModal();
}
open ? closeModal() : openModal();
}, [open, closeModal, openModal]);
if (ref.current) {
ref.current.addEventListener('close', () => {
closeModal();
});
}
// Gunakan useEffect agar event listener tidak didaftarkan berulang kali
useEffect(() => {
const dialog = ref.current;
if (!dialog) return;
return { ref, open, setOpen, openModal, closeModal, toggle } as const;
const handleClose = () => setOpen(false);
dialog.addEventListener('close', handleClose);
return () => {
dialog.removeEventListener('close', handleClose);
};
}, []);
return { ref, open, openModal, closeModal, toggle } as const;
};
interface ModalProps {
@@ -46,15 +57,19 @@ interface ModalProps {
}
const Modal = ({ ref, children, closeOnBackdrop, className }: ModalProps) => {
return (
<dialog ref={ref} className={cn('modal', className?.modal)}>
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
const handleBackdropClick = (e: React.MouseEvent<HTMLDialogElement>) => {
if (closeOnBackdrop && e.target === ref.current) {
ref.current?.close();
}
};
{closeOnBackdrop && (
<form method='dialog' className='modal-backdrop'>
<button>close</button>
</form>
)}
return (
<dialog
ref={ref}
className={cn('modal', className?.modal)}
onClick={handleBackdropClick}
>
<div className={cn('modal-box', className?.modalBox)}>{children}</div>
</dialog>
);
};
+217 -66
View File
@@ -4,29 +4,21 @@ import {
ChangeEventHandler,
FocusEventHandler,
ReactNode,
useEffect,
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}`;
};
import { cn, formatDate } from '@/lib/helper';
import Modal, { useModal } from '../Modal';
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
import 'react-day-picker/dist/style.css';
import Button from '../Button';
import { Icon } from '@iconify/react';
export interface DateInputProps {
label?: string;
bottomLabel?: string;
name: string;
value?: string;
value?: string | { from?: string; to?: string };
placeholder?: string;
min?: string;
max?: string;
@@ -42,9 +34,8 @@ export interface DateInputProps {
readOnly?: boolean;
required?: boolean;
isLoading?: boolean;
isRange?: boolean;
errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>;
}
@@ -54,51 +45,139 @@ const DateInput = ({
bottomLabel,
name,
value,
placeholder,
placeholder = 'dd/mm/yyyy',
min,
max,
className,
isError: externalError,
isValid: externalValid,
errorMessage: externalErrorMessage,
startAdornment,
endAdornment,
disabled = false,
required = false,
onChange,
onBlur,
readOnly = false,
isLoading = false,
isRange = false,
}: DateInputProps) => {
const [internalError, setInternalError] = useState<string | null>(null);
const [selected, setSelected] = useState<Date | undefined>();
const [selectedRange, setSelectedRange] = useState<{
from?: Date;
to?: Date;
}>({});
const [displayValue, setDisplayValue] = useState<string>('');
const minISO = min ? formatToISO(min) ?? undefined : undefined;
const maxISO = max ? formatToISO(max) ?? undefined : undefined;
const minDate = min
? new Date(min.split('/').reverse().join('-'))
: undefined;
const maxDate = max
? new Date(max.split('/').reverse().join('-'))
: undefined;
const valueISO =
value && value.includes('/') ? formatToISO(value) ?? '' : value ?? '';
const calendarModal = useModal();
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);
// --- Sync value props ---
useEffect(() => {
if (!value) return;
if (isRange && typeof value === 'object') {
const from = value.from ? new Date(value.from) : undefined;
const to = value.to ? new Date(value.to) : undefined;
setSelectedRange({ from, to });
setDisplayValue(
`${from ? formatDate(from, 'DD/MM/YYYY') : ''} ${
to ? '- ' + formatDate(to, 'DD/MM/YYYY') : ''
}`
);
} else if (typeof value === 'string') {
const iso = value.includes('/')
? value.split('/').reverse().join('-')
: value;
const date = new Date(iso);
setSelected(date);
setDisplayValue(formatDate(iso, 'DD/MM/YYYY'));
}
}, [value, isRange]);
const event = {
...e,
target: {
...e.target,
value: formatToLocal(selectedDate),
},
};
onChange?.(event as React.ChangeEvent<HTMLInputElement>);
const handleClick = (e: React.MouseEvent<HTMLInputElement>) => {
e.preventDefault();
if (!disabled && !readOnly) calendarModal.openModal();
};
const handleBlur: FocusEventHandler<HTMLInputElement> = (e) => {
onBlur?.(e);
};
const handleSelectSingle = (selectedDate?: Date) => {
if (!selectedDate) return;
if (minDate && selectedDate < minDate) {
setInternalError(`Tanggal tidak boleh sebelum ${min}`);
return;
}
if (maxDate && selectedDate > maxDate) {
setInternalError(`Tanggal tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
setSelected(selectedDate);
const formattedDisplay = formatDate(selectedDate, 'DD/MM/YYYY');
const formattedISO = formatDate(selectedDate, 'YYYY-MM-DD');
setDisplayValue(formattedDisplay);
const syntheticEvent = {
target: { name, value: formattedISO },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSelectRange = (range?: { from?: Date; to?: Date }) => {
if (!range) return;
setSelectedRange(range);
const fromStr = range.from ? formatDate(range.from, 'DD/MM/YYYY') : '';
const toStr = range.to ? formatDate(range.to, 'DD/MM/YYYY') : '';
setDisplayValue(`${fromStr}${toStr ? ' - ' + toStr : ''}`);
// Jika kedua tanggal sudah terpilih
if (range.from && range.to) {
if (minDate && range.from < minDate) {
setInternalError(`Tanggal mulai tidak boleh sebelum ${min}`);
return;
}
if (maxDate && range.to > maxDate) {
setInternalError(`Tanggal akhir tidak boleh setelah ${max}`);
return;
}
setInternalError(null);
const syntheticEvent = {
target: {
name,
value: {
from: formatDate(range.from, 'YYYY-MM-DD'),
to: formatDate(range.to, 'YYYY-MM-DD'),
},
},
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
}
};
const handleResetDate = () => {
setSelected(undefined);
setSelectedRange({});
setDisplayValue('');
const syntheticEvent = {
target: { name, value: isRange ? { from: '', to: '' } : '' },
} as unknown as React.ChangeEvent<HTMLInputElement>;
onChange?.(syntheticEvent);
calendarModal.closeModal();
};
const handleSaveDate = () => {
if (internalError) return;
calendarModal.closeModal();
};
const finalIsError = externalError || !!internalError;
@@ -122,49 +201,53 @@ const DateInput = ({
>
{label}
{required && (
<>
{' '}
<span className='tooltip tooltip-error' data-tip='required'>
<span className='text-error'>*</span>
</span>
</>
<span className='text-error' title='required'>
*
</span>
)}
</label>
)}
<div
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',
'input h-12 px-4 py-2 text-base font-normal leading-6 w-full rounded transition-all duration-200 flex items-center border',
{
'border-error': finalIsError,
'border-success!': externalValid && !finalIsError,
'border-success': externalValid && !finalIsError,
},
className?.inputWrapper
)}
>
{startAdornment && startAdornment}
<input
type='date'
type='text'
id={name}
name={name}
placeholder={placeholder}
value={valueISO}
onChange={handleChange}
onBlur={onBlur}
min={minISO}
max={maxISO}
placeholder={isRange ? 'dd/mm/yyyy - dd/mm/yyyy' : placeholder}
value={displayValue}
onBlur={handleBlur}
onClick={handleClick}
disabled={disabled}
className={cn('grow bg-transparent cursor-pointer', className?.input)}
readOnly={readOnly}
readOnly // ✅ tidak bisa diketik manual
className={cn(
'grow bg-transparent cursor-pointer focus:outline-none',
className?.input
)}
/>
{(isLoading || endAdornment) && (
{isLoading && (
<div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />}
{endAdornment && endAdornment}
<span className='loading loading-spinner' />
</div>
)}
<Icon
icon='uil:calendar'
width={24}
height={24}
className='cursor-pointer text-dark'
onClick={(e) =>
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
}
/>
</div>
{!finalIsError && bottomLabel && (
@@ -173,6 +256,74 @@ const DateInput = ({
{finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{finalErrorMessage}</p>
)}
<Modal
ref={calendarModal.ref}
className={{
modal: 'rounded',
modalBox: `w-fit min-h-${isRange ? '124' : '110'} flex flex-col`,
}}
closeOnBackdrop
>
{isRange ? (
<DayPicker
required={required}
mode='range'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selectedRange.from ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selectedRange as DateRange}
onSelect={handleSelectRange}
footer={<div className='text-center mt-3'>{displayValue}</div>}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
) : (
<DayPicker
required={required}
mode='single'
captionLayout='dropdown-years'
navLayout='around'
reverseYears
defaultMonth={selected ?? new Date()}
startMonth={minDate ?? new Date(1999, 1)}
endMonth={maxDate ?? new Date(new Date().getFullYear() + 5, 11)}
selected={selected}
onSelect={handleSelectSingle}
disabled={
[
minDate ? { before: minDate } : undefined,
maxDate ? { after: maxDate } : undefined,
].filter(Boolean) as Matcher[]
}
/>
)}
<div className='mt-auto flex flex-col gap-2'>
{isRange && (
<small className='text-secondary'>
Tekan dua kali untuk memilih tanggal awal
</small>
)}
<div className='flex h-full justify-end items-end gap-2'>
<Button type='button' color='warning' onClick={handleResetDate}>
Reset
</Button>
{isRange && (
<Button type='button' onClick={handleSaveDate}>
Simpan
</Button>
)}
</div>
</div>
</Modal>
</div>
);
};
@@ -44,54 +44,56 @@ const RowOptionsMenu = ({
'dropdown-content': type === 'dropdown',
'mt-2': type === 'collapse',
},
'p-2.5 mr-2 flex flex-col gap-1 bg-base-100 rounded-box z-10 border border-black/10 shadow'
'p-2.5 mr-2 bg-base-100 rounded-box z-10 border border-black/10 shadow'
)}
>
<Button
href={`/production/project-flock/detail?projectFlockId=${props.row.original.id}`}
variant='ghost'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
{props.row.original.approval.step_name === 'Aktif' && (
<div className='flex flex-col gap-1'>
<Button
href={`/production/chickin/add?projectFlockId=${props.row.original.id}`}
href={`/production/project-flock/detail?projectFlockId=${props.row.original.id}`}
variant='ghost'
color='success'
color='primary'
className='justify-start text-sm'
>
<Icon icon='mdi:home-import-outline' width={16} height={16} />
Chickin
<Icon icon='mdi:eye-outline' width={16} height={16} />
Detail
</Button>
)}
{props.row.original.approval.step_name === 'Pengajuan' && (
{props.row.original.approval.step_name === 'Aktif' && (
<Button
href={`/production/chickin/add?projectFlockId=${props.row.original.id}`}
variant='ghost'
color='success'
className='justify-start text-sm'
>
<Icon icon='mdi:home-import-outline' width={16} height={16} />
Chickin
</Button>
)}
{props.row.original.approval.step_name === 'Pengajuan' && (
<Button
href={`/production/project-flock/detail/edit?projectFlockId=${props.row.original.id}`}
variant='ghost'
color='warning'
className='justify-start text-sm'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
</Button>
)}
<Button
href={`/production/project-flock/detail/edit?projectFlockId=${props.row.original.id}`}
onClick={deleteClickHandler}
variant='ghost'
color='warning'
className='justify-start text-sm'
color='error'
className='text-error hover:text-inherit'
>
<Icon icon='mdi:pencil-outline' width={16} height={16} />
Edit
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
)}
<Button
onClick={deleteClickHandler}
variant='ghost'
color='error'
className='text-error hover:text-inherit'
>
<Icon
icon='material-symbols:delete-outline-rounded'
width={16}
height={16}
className='justify-start text-sm'
/>
Delete
</Button>
</div>
</div>
);
};