feat(FE-208): enhance DateInput component with range selection and modal support

This commit is contained in:
rstubryan
2025-11-12 16:54:17 +07:00
parent f264474293
commit 4215c6c6ce
5 changed files with 298 additions and 46 deletions
+44
View File
@@ -16,6 +16,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
@@ -194,6 +195,12 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@date-fns/tz": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
"integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==",
"license": "MIT"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
@@ -2868,6 +2875,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/date-fns": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/kossnocorp"
}
},
"node_modules/date-fns-jalali": {
"version": "4.1.0-0",
"resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz",
"integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==",
"license": "MIT"
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -5741,6 +5764,27 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/react-day-picker": {
"version": "9.11.1",
"resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.11.1.tgz",
"integrity": "sha512-l3ub6o8NlchqIjPKrRFUCkTUEq6KwemQlfv3XZzzwpUeGwmDJ+0u0Upmt38hJyd7D/vn2dQoOoLV/qAp0o3uUw==",
"license": "MIT",
"dependencies": {
"@date-fns/tz": "^1.4.1",
"date-fns": "^4.1.0",
"date-fns-jalali": "^4.1.0-0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/gpbl"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"moment": "^2.30.1", "moment": "^2.30.1",
"next": "15.5.3", "next": "15.5.3",
"react": "19.1.0", "react": "19.1.0",
"react-day-picker": "^9.11.1",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-number-format": "^5.4.4", "react-number-format": "^5.4.4",
+7 -3
View File
@@ -10,15 +10,19 @@ import {
} from 'react'; } from 'react';
import { cn } from '@/lib/helper'; import { cn } from '@/lib/helper';
export const useModal = () => { export const useModal = (isNestingModal = false) => {
const ref = useRef<HTMLDialogElement>(null); const ref = useRef<HTMLDialogElement>(null);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const openModal = useCallback(() => { const openModal = useCallback(() => {
if (!ref.current) return; if (!ref.current) return;
ref.current.show(); if (isNestingModal) {
ref.current.showModal();
} else {
ref.current.show();
}
setOpen(true); setOpen(true);
}, []); }, [isNestingModal]);
const closeModal = useCallback(() => { const closeModal = useCallback(() => {
if (!ref.current) return; if (!ref.current) return;
+243 -41
View File
@@ -1,14 +1,23 @@
'use client'; 'use client';
import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react'; import {
ChangeEventHandler,
import { cn } from '@/lib/helper'; FocusEventHandler,
useEffect,
useState,
} from 'react';
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 { export interface DateInputProps {
label?: string; label?: string;
bottomLabel?: string; bottomLabel?: string;
name: string; name: string;
value?: string; value?: string | { from?: string; to?: string };
placeholder?: string; placeholder?: string;
min?: string; min?: string;
max?: string; max?: string;
@@ -24,9 +33,9 @@ export interface DateInputProps {
readOnly?: boolean; readOnly?: boolean;
required?: boolean; required?: boolean;
isLoading?: boolean; isLoading?: boolean;
isRange?: boolean;
isNestedModal?: boolean; // New prop to indicate if used inside another modal
errorMessage?: string; errorMessage?: string;
startAdornment?: ReactNode;
endAdornment?: ReactNode;
onChange?: ChangeEventHandler<HTMLInputElement>; onChange?: ChangeEventHandler<HTMLInputElement>;
onBlur?: FocusEventHandler<HTMLInputElement>; onBlur?: FocusEventHandler<HTMLInputElement>;
} }
@@ -36,22 +45,145 @@ const DateInput = ({
bottomLabel, bottomLabel,
name, name,
value, value,
placeholder, placeholder = 'dd/mm/yyyy',
min, min,
max, max,
className, className,
isError, isError: externalError,
isValid, isValid: externalValid,
errorMessage, errorMessage: externalErrorMessage,
startAdornment,
endAdornment,
disabled = false, disabled = false,
required = false, required = false,
onChange, onChange,
onBlur, onBlur,
readOnly = false, readOnly = false,
isLoading = false, isLoading = false,
isRange = false,
isNestedModal = false,
}: DateInputProps) => { }: 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 minDate = min
? new Date(min.split('/').reverse().join('-'))
: undefined;
const maxDate = max
? new Date(max.split('/').reverse().join('-'))
: undefined;
const calendarModal = useModal(isNestedModal);
// --- 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 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;
const finalErrorMessage = internalError || externalErrorMessage;
return ( return (
<div <div
className={cn( className={cn(
@@ -64,65 +196,135 @@ const DateInput = ({
htmlFor={name} htmlFor={name}
className={cn( className={cn(
'w-full text-sm font-normal leading-5', 'w-full text-sm font-normal leading-5',
{ { 'text-error': finalIsError },
'text-error': isError,
},
className?.label className?.label
)} )}
> >
{label} {label}
{required && ( {required && (
<> <span className='text-error' title='required'>
{' '} *
<span className='tooltip tooltip-error' data-tip='required'> </span>
<span className='text-error'>*</span>
</span>
</>
)} )}
</label> </label>
)} )}
<div <div
className={cn( 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': isError, 'border-error': finalIsError,
'border-success!': isValid, 'border-success': externalValid && !finalIsError,
}, },
className?.inputWrapper className?.inputWrapper
)} )}
> >
{startAdornment && startAdornment}
<input <input
type='date' type='text'
id={name} id={name}
name={name} name={name}
placeholder={placeholder} placeholder={isRange ? 'dd/mm/yyyy - dd/mm/yyyy' : placeholder}
value={value} value={displayValue}
onChange={onChange} onBlur={handleBlur}
onBlur={onBlur} onClick={handleClick}
min={min}
max={max}
disabled={disabled} disabled={disabled}
className={cn('grow bg-transparent cursor-pointer', className?.input)} readOnly // ✅ tidak bisa diketik manual
readOnly={readOnly} className={cn(
'grow bg-transparent cursor-pointer focus:outline-none',
className?.input
)}
/> />
{(isLoading || endAdornment) && ( {isLoading && (
<div className='flex flex-row gap-2'> <div className='flex flex-row gap-2'>
{isLoading && <span className='loading loading-spinner' />} <span className='loading loading-spinner' />
{endAdornment && endAdornment}
</div> </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> </div>
{!isError && bottomLabel && ( {!finalIsError && bottomLabel && (
<p className='w-full text-sm opacity-60'>{bottomLabel}</p> <p className='w-full text-sm opacity-60'>{bottomLabel}</p>
)} )}
{isError && errorMessage && ( {finalIsError && finalErrorMessage && (
<p className='w-full text-sm text-error'>{errorMessage}</p> <p className='w-full text-sm text-error'>{finalErrorMessage}</p>
)} )}
<Modal
ref={calendarModal.ref}
className={{
modal: 'rounded',
modalBox: `!max-w-max 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> </div>
); );
}; };
@@ -21,6 +21,7 @@ import {
CreateAcceptApprovalRequisitionsPayload, CreateAcceptApprovalRequisitionsPayload,
Purchase, Purchase,
} from '@/types/api/purchase/purchase'; } from '@/types/api/purchase/purchase';
import DateInput from '@/components/input/DateInput';
interface PurchaseOrderAcceptApprovalFormProps { interface PurchaseOrderAcceptApprovalFormProps {
type?: 'add' | 'edit'; type?: 'add' | 'edit';
@@ -472,10 +473,10 @@ const PurchaseOrderAcceptApprovalForm = ({
/> />
</td> </td>
<td> <td>
<TextInput <DateInput
required required
isNestedModal={true}
name={`items.${idx}.received_date`} name={`items.${idx}.received_date`}
type='date'
value={item.received_date || ''} value={item.received_date || ''}
onChange={(e) => onChange={(e) =>
formik.setFieldValue( formik.setFieldValue(