From 4215c6c6ce1a6be6924411300d8e196272dd530e Mon Sep 17 00:00:00 2001 From: rstubryan Date: Wed, 12 Nov 2025 16:54:17 +0700 Subject: [PATCH] feat(FE-208): enhance DateInput component with range selection and modal support --- package-lock.json | 44 +++ package.json | 1 + src/components/Modal.tsx | 10 +- src/components/input/DateInput.tsx | 284 +++++++++++++++--- .../order/PurchaseOrderAcceptApprovalForm.tsx | 5 +- 5 files changed, 298 insertions(+), 46 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2fa0e38b..19f0f85e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 1d181a40..33cf275a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/components/Modal.tsx b/src/components/Modal.tsx index a242b1e4..5a1dc806 100644 --- a/src/components/Modal.tsx +++ b/src/components/Modal.tsx @@ -10,15 +10,19 @@ import { } from 'react'; import { cn } from '@/lib/helper'; -export const useModal = () => { +export const useModal = (isNestingModal = false) => { const ref = useRef(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; diff --git a/src/components/input/DateInput.tsx b/src/components/input/DateInput.tsx index 6e2f1d77..24a4ef8d 100644 --- a/src/components/input/DateInput.tsx +++ b/src/components/input/DateInput.tsx @@ -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; onBlur?: FocusEventHandler; } @@ -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(null); + const [selected, setSelected] = useState(); + const [selectedRange, setSelectedRange] = useState<{ + from?: Date; + to?: Date; + }>({}); + const [displayValue, setDisplayValue] = useState(''); + + 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) => { + e.preventDefault(); + if (!disabled && !readOnly) calendarModal.openModal(); + }; + + const handleBlur: FocusEventHandler = (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; + 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; + onChange?.(syntheticEvent); + } + }; + + const handleResetDate = () => { + setSelected(undefined); + setSelectedRange({}); + setDisplayValue(''); + const syntheticEvent = { + target: { name, value: isRange ? { from: '', to: '' } : '' }, + } as unknown as React.ChangeEvent; + onChange?.(syntheticEvent); + calendarModal.closeModal(); + }; + + const handleSaveDate = () => { + if (internalError) return; + calendarModal.closeModal(); + }; + + const finalIsError = externalError || !!internalError; + const finalErrorMessage = internalError || externalErrorMessage; + return (
{label} {required && ( - <> - {' '} - - * - - + + * + )} )}
- {startAdornment && startAdornment} - - {(isLoading || endAdornment) && ( + {isLoading && (
- {isLoading && } - {endAdornment && endAdornment} +
)} + + handleClick(e as unknown as React.MouseEvent) + } + />
- {!isError && bottomLabel && ( + {!finalIsError && bottomLabel && (

{bottomLabel}

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

{errorMessage}

+ {finalIsError && finalErrorMessage && ( +

{finalErrorMessage}

)} + + + {isRange ? ( + {displayValue}
} + disabled={ + [ + minDate ? { before: minDate } : undefined, + maxDate ? { after: maxDate } : undefined, + ].filter(Boolean) as Matcher[] + } + /> + ) : ( + + )} +
+ {isRange && ( + + Tekan dua kali untuk memilih tanggal awal + + )} + +
+ + {isRange && ( + + )} +
+
+ ); }; diff --git a/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx b/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx index 20b7fc46..dcdf603d 100644 --- a/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx +++ b/src/components/pages/purchase/form/order/PurchaseOrderAcceptApprovalForm.tsx @@ -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 = ({ /> - formik.setFieldValue(