mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
351 lines
10 KiB
TypeScript
351 lines
10 KiB
TypeScript
'use client';
|
|
|
|
import {
|
|
ChangeEventHandler,
|
|
FocusEventHandler,
|
|
useEffect,
|
|
useState,
|
|
} from 'react';
|
|
import { cn, formatDate } from '@/lib/helper';
|
|
import { DateRange, DayPicker, Matcher } from 'react-day-picker';
|
|
import 'react-day-picker/dist/style.css';
|
|
import { Icon } from '@iconify/react';
|
|
import Modal, { useModal } from '@/components/Modal';
|
|
import Button from '@/components/Button';
|
|
|
|
export interface DateInputProps {
|
|
label?: string;
|
|
bottomLabel?: string;
|
|
name: string;
|
|
value?: string | { from?: string; to?: string };
|
|
placeholder?: string;
|
|
min?: string;
|
|
max?: string;
|
|
className?: {
|
|
wrapper?: string;
|
|
label?: string;
|
|
inputWrapper?: string;
|
|
input?: string;
|
|
};
|
|
isError?: boolean;
|
|
isValid?: boolean;
|
|
disabled?: boolean;
|
|
readOnly?: boolean;
|
|
required?: boolean;
|
|
isLoading?: boolean;
|
|
isRange?: boolean;
|
|
isNestedModal?: boolean; // New prop to indicate if used inside another modal
|
|
errorMessage?: string;
|
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
onBlur?: FocusEventHandler<HTMLInputElement>;
|
|
}
|
|
|
|
const DateInput = ({
|
|
label,
|
|
bottomLabel,
|
|
name,
|
|
value,
|
|
placeholder = 'dd/mm/yyyy',
|
|
min,
|
|
max,
|
|
className,
|
|
isError: externalError,
|
|
isValid: externalValid,
|
|
errorMessage: externalErrorMessage,
|
|
disabled = false,
|
|
required = false,
|
|
onChange,
|
|
onBlur,
|
|
readOnly = false,
|
|
isLoading = false,
|
|
isRange = false,
|
|
isNestedModal = 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 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) {
|
|
setDisplayValue('');
|
|
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) {
|
|
setSelected(undefined);
|
|
setDisplayValue('');
|
|
const syntheticEvent = {
|
|
target: { name, value: '' },
|
|
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
|
onChange?.(syntheticEvent);
|
|
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) {
|
|
setSelectedRange({});
|
|
setDisplayValue('');
|
|
const syntheticEvent = {
|
|
target: { name, value: { from: '', to: '' } },
|
|
} as unknown as React.ChangeEvent<HTMLInputElement>;
|
|
onChange?.(syntheticEvent);
|
|
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 (
|
|
<div className={cn('w-full flex flex-col text-start', className?.wrapper)}>
|
|
{label && (
|
|
<label
|
|
htmlFor={name}
|
|
className={cn(
|
|
'w-full py-2 text-xs font-semibold leading-5',
|
|
{ 'text-error': finalIsError },
|
|
className?.label
|
|
)}
|
|
>
|
|
{label}
|
|
{required && (
|
|
<span className='text-error' title='required'>
|
|
{' '}
|
|
*
|
|
</span>
|
|
)}
|
|
</label>
|
|
)}
|
|
|
|
<div
|
|
className={cn(
|
|
'input h-fit bg-inherit px-3 py-2.5 text-base font-normal leading-6 w-full rounded-lg transition-all duration-200 flex items-center border border-base-content/10',
|
|
{
|
|
'border-error': finalIsError,
|
|
'border-success': externalValid && !finalIsError,
|
|
},
|
|
className?.inputWrapper
|
|
)}
|
|
>
|
|
<input
|
|
type='text'
|
|
id={name}
|
|
name={name}
|
|
placeholder={isRange ? 'dd/mm/yyyy - dd/mm/yyyy' : placeholder}
|
|
value={displayValue}
|
|
onBlur={handleBlur}
|
|
onClick={handleClick}
|
|
disabled={disabled}
|
|
readOnly // ✅ tidak bisa diketik manual
|
|
className={cn(
|
|
'grow bg-transparent cursor-pointer focus:outline-none text-sm leading-tight',
|
|
{
|
|
'cursor-not-allowed': readOnly,
|
|
},
|
|
className?.input
|
|
)}
|
|
/>
|
|
|
|
{isLoading && (
|
|
<div className='flex flex-row gap-2'>
|
|
<span className='loading loading-spinner' />
|
|
</div>
|
|
)}
|
|
<Icon
|
|
icon='heroicons:calendar-date-range'
|
|
width={15}
|
|
height={15}
|
|
className='cursor-pointer text-base-content/20'
|
|
onClick={(e) =>
|
|
handleClick(e as unknown as React.MouseEvent<HTMLInputElement>)
|
|
}
|
|
/>
|
|
</div>
|
|
|
|
{!finalIsError && bottomLabel && (
|
|
<p className='w-full mt-1.5 text-xs opacity-60'>{bottomLabel}</p>
|
|
)}
|
|
{finalIsError && finalErrorMessage && (
|
|
<p className='w-full mt-1.5 text-xs 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>
|
|
);
|
|
};
|
|
|
|
export default DateInput;
|