mirror of
https://gitlab.com/mbugroup/lti-web-client.git
synced 2026-05-20 13:32:00 +00:00
feat(FE-208): enhance DateInput component with range selection and modal support
This commit is contained in:
Generated
+44
@@ -16,6 +16,7 @@
|
||||
"moment": "^2.30.1",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
@@ -194,6 +195,12 @@
|
||||
"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": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.6.0.tgz",
|
||||
@@ -2868,6 +2875,22 @@
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -5741,6 +5764,27 @@
|
||||
"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": {
|
||||
"version": "19.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"moment": "^2.30.1",
|
||||
"next": "15.5.3",
|
||||
"react": "19.1.0",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "19.1.0",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-number-format": "^5.4.4",
|
||||
|
||||
@@ -10,15 +10,19 @@ import {
|
||||
} from 'react';
|
||||
import { cn } from '@/lib/helper';
|
||||
|
||||
export const useModal = () => {
|
||||
export const useModal = (isNestingModal = false) => {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
ref.current.show();
|
||||
if (isNestingModal) {
|
||||
ref.current.showModal();
|
||||
} else {
|
||||
ref.current.show();
|
||||
}
|
||||
setOpen(true);
|
||||
}, []);
|
||||
}, [isNestingModal]);
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
@@ -1,14 +1,23 @@
|
||||
'use client';
|
||||
|
||||
import { ChangeEventHandler, FocusEventHandler, ReactNode } from 'react';
|
||||
|
||||
import { cn } from '@/lib/helper';
|
||||
import {
|
||||
ChangeEventHandler,
|
||||
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 {
|
||||
label?: string;
|
||||
bottomLabel?: string;
|
||||
name: string;
|
||||
value?: string;
|
||||
value?: string | { from?: string; to?: string };
|
||||
placeholder?: string;
|
||||
min?: string;
|
||||
max?: string;
|
||||
@@ -24,9 +33,9 @@ export interface DateInputProps {
|
||||
readOnly?: boolean;
|
||||
required?: boolean;
|
||||
isLoading?: boolean;
|
||||
isRange?: boolean;
|
||||
isNestedModal?: boolean; // New prop to indicate if used inside another modal
|
||||
errorMessage?: string;
|
||||
startAdornment?: ReactNode;
|
||||
endAdornment?: ReactNode;
|
||||
onChange?: ChangeEventHandler<HTMLInputElement>;
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
}
|
||||
@@ -36,22 +45,145 @@ const DateInput = ({
|
||||
bottomLabel,
|
||||
name,
|
||||
value,
|
||||
placeholder,
|
||||
placeholder = 'dd/mm/yyyy',
|
||||
min,
|
||||
max,
|
||||
className,
|
||||
isError,
|
||||
isValid,
|
||||
errorMessage,
|
||||
startAdornment,
|
||||
endAdornment,
|
||||
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) 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 (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -64,65 +196,135 @@ const DateInput = ({
|
||||
htmlFor={name}
|
||||
className={cn(
|
||||
'w-full text-sm font-normal leading-5',
|
||||
{
|
||||
'text-error': isError,
|
||||
},
|
||||
{ 'text-error': finalIsError },
|
||||
className?.label
|
||||
)}
|
||||
>
|
||||
{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': isError,
|
||||
'border-success!': isValid,
|
||||
'border-error': finalIsError,
|
||||
'border-success': externalValid && !finalIsError,
|
||||
},
|
||||
className?.inputWrapper
|
||||
)}
|
||||
>
|
||||
{startAdornment && startAdornment}
|
||||
|
||||
<input
|
||||
type='date'
|
||||
type='text'
|
||||
id={name}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
min={min}
|
||||
max={max}
|
||||
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>
|
||||
|
||||
{!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>
|
||||
)}
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
CreateAcceptApprovalRequisitionsPayload,
|
||||
Purchase,
|
||||
} from '@/types/api/purchase/purchase';
|
||||
import DateInput from '@/components/input/DateInput';
|
||||
|
||||
interface PurchaseOrderAcceptApprovalFormProps {
|
||||
type?: 'add' | 'edit';
|
||||
@@ -472,10 +473,10 @@ const PurchaseOrderAcceptApprovalForm = ({
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<TextInput
|
||||
<DateInput
|
||||
required
|
||||
isNestedModal={true}
|
||||
name={`items.${idx}.received_date`}
|
||||
type='date'
|
||||
value={item.received_date || ''}
|
||||
onChange={(e) =>
|
||||
formik.setFieldValue(
|
||||
|
||||
Reference in New Issue
Block a user