index.tsx 9.4 KB

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