index.tsx323 lines · main
1import { format } from 'date-fns'
2import dayjs from 'dayjs'
3import { ArrowRight, Calendar } from 'lucide-react'
4import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react'
5import {
6 Button,
7 Calendar as CalendarPicker,
8 Popover,
9 PopoverContent,
10 PopoverSeparator,
11 PopoverTrigger,
12} from 'ui'
13import { ButtonProps } from 'ui/src/components/Button/Button'
14
15import { TimeSplitInput } from './TimeSplitInput'
16import type { DatePickerToFrom } from '@/components/interfaces/Settings/Logs/Logs.types'
17
18interface DatePickerProps {
19 onChange?: (args: DatePickerToFrom) => void
20 to?: string // ISO string
21 from?: string // ISO string
22 triggerButtonType?: ButtonProps['type']
23 triggerButtonClassName?: string
24 triggerButtonTitle?: string
25 triggerButtonSize?: 'tiny' | 'small'
26 contentSide?: 'bottom' | 'top'
27 minDate?: Date
28 maxDate?: Date
29 hideTime?: boolean
30 hideClear?: boolean
31 selectsRange?: boolean
32 renderFooter?: (args: DatePickerToFrom) => ReactNode | void
33 children?: ReactNode | ReactNode[] | null
34}
35
36const START_DATE_DEFAULT = new Date()
37const END_DATE_DEFAULT = new Date()
38
39const START_TIME_DEFAULT = { HH: '00', mm: '00', ss: '00' }
40const END_TIME_DEFAULT = { HH: '23', mm: '59', ss: '59' }
41
42const calculateDisabledDays = (minDate?: Date, maxDate?: Date) => {
43 const rules: Array<{ before: Date } | { after: Date }> = []
44
45 if (minDate) {
46 rules.push({ before: dayjs(minDate).startOf('day').toDate() })
47 }
48
49 if (maxDate) {
50 rules.push({ after: dayjs(maxDate).endOf('day').toDate() })
51 }
52
53 if (rules.length === 0) return undefined
54 if (rules.length === 1) return rules[0]
55 return rules
56}
57
58export function DatePicker({
59 to,
60 from,
61 onChange,
62 triggerButtonType = 'default',
63 triggerButtonClassName = '',
64 triggerButtonTitle,
65 triggerButtonSize,
66 contentSide = 'bottom',
67 minDate,
68 maxDate,
69 hideTime = false,
70 hideClear = false,
71 selectsRange = true,
72 renderFooter = () => null,
73 children,
74}: DatePickerProps) {
75 const [open, setOpen] = useState<boolean>(false)
76 const [appliedStartDate, setAppliedStartDate] = useState<null | Date>(null)
77 const [appliedEndDate, setAppliedEndDate] = useState<null | Date>(null)
78 const [startDate, setStartDate] = useState<Date | null>(START_DATE_DEFAULT)
79 const [endDate, setEndDate] = useState<Date | null>(END_DATE_DEFAULT)
80 const [startTime, setStartTime] = useState<any>(START_TIME_DEFAULT)
81 const [endTime, setEndTime] = useState<any>(END_TIME_DEFAULT)
82
83 const disabledDays = useMemo(() => calculateDisabledDays(minDate, maxDate), [minDate, maxDate])
84
85 const clampDateToRange = useCallback(
86 (date: Date | null) => {
87 if (!date) return date
88
89 let nextDate = date
90
91 if (minDate && dayjs(nextDate).isBefore(minDate, 'day')) {
92 nextDate = dayjs(minDate).toDate()
93 }
94
95 if (maxDate && dayjs(nextDate).isAfter(maxDate, 'day')) {
96 nextDate = dayjs(maxDate).toDate()
97 }
98
99 return nextDate
100 },
101 [minDate, maxDate]
102 )
103
104 useEffect(() => {
105 if (!minDate && !maxDate) return
106
107 setStartDate((prev) => {
108 if (!prev) return prev
109 const clamped = clampDateToRange(prev)
110 return dayjs(clamped).isSame(prev) ? prev : clamped
111 })
112
113 setEndDate((prev) => {
114 if (!prev) return prev
115 const clamped = clampDateToRange(prev)
116 return dayjs(clamped).isSame(prev) ? prev : clamped
117 })
118
119 setAppliedStartDate((prev) => {
120 if (!prev) return prev
121 const clamped = clampDateToRange(prev)
122 return dayjs(clamped).isSame(prev) ? prev : clamped
123 })
124
125 setAppliedEndDate((prev) => {
126 if (!prev) return prev
127 const clamped = clampDateToRange(prev)
128 return dayjs(clamped).isSame(prev) ? prev : clamped
129 })
130 }, [minDate, maxDate, clampDateToRange])
131
132 useEffect(() => {
133 if (!from) {
134 setAppliedStartDate(null)
135 } else if (from !== appliedStartDate?.toISOString()) {
136 const start = dayjs(from)
137 const startDate = start.toDate()
138 setAppliedStartDate(startDate)
139 setStartDate(startDate)
140 setStartTime({
141 HH: start.format('HH'),
142 mm: start.format('mm'),
143 ss: start.format('ss'),
144 })
145 }
146
147 if (!to) {
148 setAppliedEndDate(null)
149 } else if (to !== appliedEndDate?.toISOString()) {
150 const end = dayjs(to)
151 const endDate = end.toDate()
152 setAppliedEndDate(endDate)
153 setEndDate(endDate)
154 setEndTime({
155 HH: end.format('HH'),
156 mm: end.format('mm'),
157 ss: end.format('ss'),
158 })
159 }
160 // eslint-disable-next-line react-hooks/exhaustive-deps
161 }, [to, from])
162
163 function handleDatePickerChange(dates: Date | [from: Date | null, to: Date | null] | null) {
164 if (!dates) {
165 setStartDate(null)
166 setEndDate(null)
167 } else if (dates instanceof Date) {
168 setStartDate(dates)
169 setEndDate(dates)
170 } else {
171 const [from, to] = dates
172 setStartDate(from)
173 setEndDate(to)
174 }
175 }
176
177 function handleSubmit() {
178 setOpen(false)
179
180 setAppliedStartDate(startDate)
181 setAppliedEndDate(endDate)
182
183 const payload = {
184 from: dayjs(startDate)
185 .second(Number(startTime.ss))
186 .minute(Number(startTime.mm))
187 .hour(Number(startTime.HH))
188 .toISOString(),
189 to: dayjs(endDate || startDate)
190 .second(Number(endTime.ss))
191 .minute(Number(endTime.mm))
192 .hour(Number(endTime.HH))
193 .toISOString(),
194 }
195 if (onChange) onChange(payload)
196 }
197
198 function handleClear() {
199 setOpen(false)
200 setStartDate(START_DATE_DEFAULT)
201 setEndDate(END_DATE_DEFAULT)
202
203 setStartTime(START_TIME_DEFAULT)
204 setEndTime(END_TIME_DEFAULT)
205
206 setAppliedStartDate(null)
207 setAppliedEndDate(null)
208
209 if (onChange) onChange({ from: null, to: null })
210 }
211
212 return (
213 <Popover modal open={open} onOpenChange={setOpen}>
214 <PopoverTrigger asChild>
215 <Button
216 title={triggerButtonTitle}
217 type={triggerButtonType}
218 icon={<Calendar />}
219 size={triggerButtonSize}
220 className={triggerButtonClassName}
221 >
222 {children !== undefined ? (
223 children
224 ) : (
225 <>
226 {/* Custom */}
227 {selectsRange &&
228 appliedStartDate &&
229 appliedEndDate &&
230 appliedStartDate !== appliedEndDate ? (
231 <>
232 {format(new Date(appliedStartDate), 'dd MMM')} -{' '}
233 {format(new Date(appliedEndDate), 'dd MMM')}
234 </>
235 ) : appliedStartDate || appliedEndDate ? (
236 format(new Date((appliedStartDate || appliedEndDate)!), 'dd MMM')
237 ) : (
238 'Custom'
239 )}
240 </>
241 )}
242 </Button>
243 </PopoverTrigger>
244 <PopoverContent align="center" side={contentSide} className="p-0">
245 <>
246 {hideTime ? null : (
247 <>
248 <div className="flex items-stretch justify-between p-2">
249 {!selectsRange ? null : (
250 <>
251 <div className="flex grow flex-col gap-1">
252 <TimeSplitInput
253 type="start"
254 startTime={startTime}
255 endTime={endTime}
256 time={startTime}
257 setTime={setStartTime}
258 setStartTime={setStartTime}
259 setEndTime={setEndTime}
260 startDate={startDate}
261 endDate={endDate}
262 />
263 </div>
264 <div className="flex items-center justify-center w-12 text-foreground-lighter">
265 <ArrowRight strokeWidth={1.5} size={14} />
266 </div>
267 </>
268 )}
269 <div className="flex grow flex-col gap-1">
270 <TimeSplitInput
271 type="end"
272 startTime={startTime}
273 endTime={endTime}
274 time={endTime}
275 setTime={setEndTime}
276 setStartTime={setStartTime}
277 setEndTime={setEndTime}
278 startDate={startDate}
279 endDate={endDate}
280 />
281 </div>
282 </div>
283 </>
284 )}
285 <div className="p-2">
286 {selectsRange ? (
287 <CalendarPicker
288 mode="range"
289 disabled={disabledDays}
290 selected={{ from: startDate ?? undefined, to: endDate ?? undefined }}
291 onSelect={(range) => {
292 handleDatePickerChange([range?.from ?? null, range?.to ?? null])
293 }}
294 />
295 ) : (
296 <CalendarPicker
297 mode="single"
298 disabled={disabledDays}
299 selected={endDate ?? undefined}
300 onSelect={(date) => {
301 handleDatePickerChange(date ?? null)
302 }}
303 />
304 )}
305 </div>
306 {renderFooter({
307 from: startDate?.toISOString() || null,
308 to: endDate?.toISOString() || null,
309 })}
310 <PopoverSeparator />
311 <div className="flex items-center justify-end gap-2 py-2 px-3 pb-4">
312 {!hideClear && (
313 <Button type="default" onClick={() => handleClear()}>
314 Clear
315 </Button>
316 )}
317 <Button onClick={() => handleSubmit()}>Apply</Button>
318 </div>
319 </>
320 </PopoverContent>
321 </Popover>
322 )
323}