index.tsx323 lines · main
| 1 | import { format } from 'date-fns' |
| 2 | import dayjs from 'dayjs' |
| 3 | import { ArrowRight, Calendar } from 'lucide-react' |
| 4 | import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react' |
| 5 | import { |
| 6 | Button, |
| 7 | Calendar as CalendarPicker, |
| 8 | Popover, |
| 9 | PopoverContent, |
| 10 | PopoverSeparator, |
| 11 | PopoverTrigger, |
| 12 | } from 'ui' |
| 13 | import { ButtonProps } from 'ui/src/components/Button/Button' |
| 14 | |
| 15 | import { TimeSplitInput } from './TimeSplitInput' |
| 16 | import type { DatePickerToFrom } from '@/components/interfaces/Settings/Logs/Logs.types' |
| 17 | |
| 18 | interface 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 | |
| 36 | const START_DATE_DEFAULT = new Date() |
| 37 | const END_DATE_DEFAULT = new Date() |
| 38 | |
| 39 | const START_TIME_DEFAULT = { HH: '00', mm: '00', ss: '00' } |
| 40 | const END_TIME_DEFAULT = { HH: '23', mm: '59', ss: '59' } |
| 41 | |
| 42 | const 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 | |
| 58 | export 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 | } |