From 19d413ac1ea075104e2c0ac93560c7b010c29330 Mon Sep 17 00:00:00 2001
From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Date: Wed, 19 Feb 2025 10:56:18 +0800
Subject: [PATCH] feat: date and time picker (#13985)
---
.../calendar/days-of-week.tsx | 21 ++
.../date-and-time-picker/calendar/index.tsx | 27 ++
.../date-and-time-picker/calendar/item.tsx | 30 ++
.../common/option-list-item.tsx | 38 +++
.../date-picker/footer.tsx | 59 ++++
.../date-picker/header.tsx | 41 +++
.../date-picker/index.tsx | 279 ++++++++++++++++++
.../base/date-and-time-picker/hooks.ts | 49 +++
.../time-picker/footer.tsx | 37 +++
.../time-picker/header.tsx | 16 +
.../time-picker/index.tsx | 151 ++++++++++
.../time-picker/options.tsx | 71 +++++
.../base/date-and-time-picker/types.ts | 101 +++++++
.../base/date-and-time-picker/utils.ts | 64 ++++
.../year-and-month-picker/footer.tsx | 25 ++
.../year-and-month-picker/header.tsx | 27 ++
.../year-and-month-picker/options.tsx | 55 ++++
web/app/styles/globals.css | 15 +-
web/i18n/de-DE/time.ts | 3 +
web/i18n/en-US/time.ts | 37 +++
web/i18n/es-ES/time.ts | 3 +
web/i18n/fa-IR/time.ts | 3 +
web/i18n/fr-FR/time.ts | 3 +
web/i18n/hi-IN/time.ts | 3 +
web/i18n/i18next-config.ts | 1 +
web/i18n/it-IT/time.ts | 3 +
web/i18n/ja-JP/time.ts | 3 +
web/i18n/ko-KR/time.ts | 3 +
web/i18n/pl-PL/time.ts | 3 +
web/i18n/pt-BR/time.ts | 3 +
web/i18n/ro-RO/time.ts | 3 +
web/i18n/ru-RU/time.ts | 3 +
web/i18n/sl-SI/time.ts | 3 +
web/i18n/th-TH/time.ts | 3 +
web/i18n/tr-TR/time.ts | 3 +
web/i18n/uk-UA/time.ts | 3 +
web/i18n/vi-VN/time.ts | 3 +
web/i18n/zh-Hans/time.ts | 37 +++
web/i18n/zh-Hant/time.ts | 3 +
39 files changed, 1234 insertions(+), 1 deletion(-)
create mode 100644 web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx
create mode 100644 web/app/components/base/date-and-time-picker/calendar/index.tsx
create mode 100644 web/app/components/base/date-and-time-picker/calendar/item.tsx
create mode 100644 web/app/components/base/date-and-time-picker/common/option-list-item.tsx
create mode 100644 web/app/components/base/date-and-time-picker/date-picker/footer.tsx
create mode 100644 web/app/components/base/date-and-time-picker/date-picker/header.tsx
create mode 100644 web/app/components/base/date-and-time-picker/date-picker/index.tsx
create mode 100644 web/app/components/base/date-and-time-picker/hooks.ts
create mode 100644 web/app/components/base/date-and-time-picker/time-picker/footer.tsx
create mode 100644 web/app/components/base/date-and-time-picker/time-picker/header.tsx
create mode 100644 web/app/components/base/date-and-time-picker/time-picker/index.tsx
create mode 100644 web/app/components/base/date-and-time-picker/time-picker/options.tsx
create mode 100644 web/app/components/base/date-and-time-picker/types.ts
create mode 100644 web/app/components/base/date-and-time-picker/utils.ts
create mode 100644 web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx
create mode 100644 web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx
create mode 100644 web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx
create mode 100644 web/i18n/de-DE/time.ts
create mode 100644 web/i18n/en-US/time.ts
create mode 100644 web/i18n/es-ES/time.ts
create mode 100644 web/i18n/fa-IR/time.ts
create mode 100644 web/i18n/fr-FR/time.ts
create mode 100644 web/i18n/hi-IN/time.ts
create mode 100644 web/i18n/it-IT/time.ts
create mode 100644 web/i18n/ja-JP/time.ts
create mode 100644 web/i18n/ko-KR/time.ts
create mode 100644 web/i18n/pl-PL/time.ts
create mode 100644 web/i18n/pt-BR/time.ts
create mode 100644 web/i18n/ro-RO/time.ts
create mode 100644 web/i18n/ru-RU/time.ts
create mode 100644 web/i18n/sl-SI/time.ts
create mode 100644 web/i18n/th-TH/time.ts
create mode 100644 web/i18n/tr-TR/time.ts
create mode 100644 web/i18n/uk-UA/time.ts
create mode 100644 web/i18n/vi-VN/time.ts
create mode 100644 web/i18n/zh-Hans/time.ts
create mode 100644 web/i18n/zh-Hant/time.ts
diff --git a/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx
new file mode 100644
index 0000000000..9de122d254
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/calendar/days-of-week.tsx
@@ -0,0 +1,21 @@
+import React from 'react'
+import { useDaysOfWeek } from '../hooks'
+
+export const DaysOfWeek = () => {
+ const daysOfWeek = useDaysOfWeek()
+
+ return (
+
+ {daysOfWeek.map(day => (
+
+ {day}
+
+ ))}
+
+ )
+}
+
+export default React.memo(DaysOfWeek)
diff --git a/web/app/components/base/date-and-time-picker/calendar/index.tsx b/web/app/components/base/date-and-time-picker/calendar/index.tsx
new file mode 100644
index 0000000000..00612fcb37
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/calendar/index.tsx
@@ -0,0 +1,27 @@
+import type { FC } from 'react'
+import type { CalendarProps } from '../types'
+import { DaysOfWeek } from './days-of-week'
+import CalendarItem from './item'
+
+const Calendar: FC = ({
+ days,
+ selectedDate,
+ onDateClick,
+ wrapperClassName,
+}) => {
+ return
+
+
+ {
+ days.map(day => )
+ }
+
+
+}
+
+export default Calendar
diff --git a/web/app/components/base/date-and-time-picker/calendar/item.tsx b/web/app/components/base/date-and-time-picker/calendar/item.tsx
new file mode 100644
index 0000000000..166cc2eb23
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/calendar/item.tsx
@@ -0,0 +1,30 @@
+import React, { type FC } from 'react'
+import type { CalendarItemProps } from '../types'
+import cn from '@/utils/classnames'
+import dayjs from 'dayjs'
+
+const Item: FC = ({
+ day,
+ selectedDate,
+ onClick,
+}) => {
+ const { date, isCurrentMonth } = day
+ const isSelected = selectedDate?.isSame(date, 'date')
+ const isToday = date.isSame(dayjs(), 'date')
+
+ return (
+
+ )
+}
+
+export default React.memo(Item)
diff --git a/web/app/components/base/date-and-time-picker/common/option-list-item.tsx b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx
new file mode 100644
index 0000000000..3e2fccce7b
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/common/option-list-item.tsx
@@ -0,0 +1,38 @@
+import React, { type FC, useEffect, useRef } from 'react'
+import cn from '@/utils/classnames'
+
+type OptionListItemProps = {
+ isSelected: boolean
+ onClick: () => void
+} & React.LiHTMLAttributes
+
+const OptionListItem: FC = ({
+ isSelected,
+ onClick,
+ children,
+}) => {
+ const listItemRef = useRef(null)
+
+ useEffect(() => {
+ if (isSelected)
+ listItemRef.current?.scrollIntoView({ behavior: 'instant' })
+ }, [])
+
+ return (
+ {
+ listItemRef.current?.scrollIntoView({ behavior: 'smooth' })
+ onClick()
+ }}
+ >
+ {children}
+
+ )
+}
+
+export default React.memo(OptionListItem)
diff --git a/web/app/components/base/date-and-time-picker/date-picker/footer.tsx b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx
new file mode 100644
index 0000000000..6233ea714b
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/date-picker/footer.tsx
@@ -0,0 +1,59 @@
+import React, { type FC } from 'react'
+import Button from '../../button'
+import { type DatePickerFooterProps, ViewType } from '../types'
+import { RiTimeLine } from '@remixicon/react'
+import cn from '@/utils/classnames'
+import { useTranslation } from 'react-i18next'
+
+const Footer: FC = ({
+ needTimePicker,
+ displayTime,
+ view,
+ handleClickTimePicker,
+ handleSelectCurrentDate,
+ handleConfirmDate,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+ {/* Time Picker */}
+ {needTimePicker && (
+
+ )}
+
+ {/* Now */}
+
+ {/* Confirm Button */}
+
+
+
+ )
+}
+
+export default React.memo(Footer)
diff --git a/web/app/components/base/date-and-time-picker/date-picker/header.tsx b/web/app/components/base/date-and-time-picker/date-picker/header.tsx
new file mode 100644
index 0000000000..009e1eaec5
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/date-picker/header.tsx
@@ -0,0 +1,41 @@
+import React, { type FC } from 'react'
+import { RiArrowDownSLine, RiArrowUpSLine } from '@remixicon/react'
+import type { DatePickerHeaderProps } from '../types'
+import { useMonths } from '../hooks'
+
+const Header: FC = ({
+ handleOpenYearMonthPicker,
+ currentDate,
+ onClickNextMonth,
+ onClickPrevMonth,
+}) => {
+ const months = useMonths()
+
+ return (
+
+
+
+
+
+
+
+ )
+}
+
+export default React.memo(Header)
diff --git a/web/app/components/base/date-and-time-picker/date-picker/index.tsx b/web/app/components/base/date-and-time-picker/date-picker/index.tsx
new file mode 100644
index 0000000000..06dd459ece
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/date-picker/index.tsx
@@ -0,0 +1,279 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import dayjs, { type Dayjs } from 'dayjs'
+import { RiCalendarLine, RiCloseCircleFill } from '@remixicon/react'
+import cn from '@/utils/classnames'
+import type { DatePickerProps, Period } from '../types'
+import { ViewType } from '../types'
+import { cloneTime, getDaysInMonth, getHourIn12Hour } from '../utils'
+import {
+ PortalToFollowElem,
+ PortalToFollowElemContent,
+ PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import DatePickerHeader from './header'
+import Calendar from '../calendar'
+import DatePickerFooter from './footer'
+import YearAndMonthPickerHeader from '../year-and-month-picker/header'
+import YearAndMonthPickerOptions from '../year-and-month-picker/options'
+import YearAndMonthPickerFooter from '../year-and-month-picker/footer'
+import TimePickerHeader from '../time-picker/header'
+import TimePickerOptions from '../time-picker/options'
+import { useTranslation } from 'react-i18next'
+
+const DatePicker = ({
+ value,
+ onChange,
+ onClear,
+ placeholder,
+ needTimePicker = true,
+ renderTrigger,
+}: DatePickerProps) => {
+ const { t } = useTranslation()
+ const [isOpen, setIsOpen] = useState(false)
+ const [view, setView] = useState(ViewType.date)
+ const containerRef = useRef(null)
+
+ const [currentDate, setCurrentDate] = useState(value || dayjs())
+ const [selectedDate, setSelectedDate] = useState(value)
+
+ const [selectedMonth, setSelectedMonth] = useState((value || dayjs()).month())
+ const [selectedYear, setSelectedYear] = useState((value || dayjs()).year())
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
+ setIsOpen(false)
+ setView(ViewType.date)
+ }
+ }
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [])
+
+ const handleClickTrigger = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (isOpen) {
+ setIsOpen(false)
+ return
+ }
+ setView(ViewType.date)
+ setIsOpen(true)
+ }
+
+ const handleClear = (e: React.MouseEvent) => {
+ const newDate = dayjs()
+ e.stopPropagation()
+ setSelectedDate(undefined)
+ setCurrentDate(prev => prev || newDate)
+ setSelectedMonth(prev => prev || newDate.month())
+ setSelectedYear(prev => prev || newDate.year())
+ if (!isOpen)
+ onClear()
+ }
+
+ const days = useMemo(() => {
+ return getDaysInMonth(currentDate)
+ }, [currentDate])
+
+ const handleClickNextMonth = useCallback(() => {
+ setCurrentDate(currentDate.clone().add(1, 'month'))
+ }, [currentDate])
+
+ const handleClickPrevMonth = useCallback(() => {
+ setCurrentDate(currentDate.clone().subtract(1, 'month'))
+ }, [currentDate])
+
+ const handleDateSelect = useCallback((day: Dayjs) => {
+ const newDate = cloneTime(day, selectedDate || dayjs())
+ setCurrentDate(newDate)
+ setSelectedDate(newDate)
+ }, [selectedDate])
+
+ const handleSelectCurrentDate = () => {
+ const newDate = dayjs()
+ setCurrentDate(newDate)
+ setSelectedDate(newDate)
+ onChange(newDate)
+ setIsOpen(false)
+ }
+
+ const handleConfirmDate = () => {
+ onChange(selectedDate)
+ setIsOpen(false)
+ }
+
+ const handleClickTimePicker = () => {
+ if (view === ViewType.date) {
+ setView(ViewType.time)
+ return
+ }
+ if (view === ViewType.time)
+ setView(ViewType.date)
+ }
+
+ const handleTimeSelect = (hour: string, minute: string, period: Period) => {
+ const newTime = cloneTime(dayjs(), dayjs(`1/1/2000 ${hour}:${minute} ${period}`))
+ setSelectedDate((prev) => {
+ return prev ? cloneTime(prev, newTime) : newTime
+ })
+ }
+
+ const handleSelectHour = useCallback((hour: string) => {
+ const selectedTime = selectedDate || dayjs()
+ handleTimeSelect(hour, selectedTime.minute().toString().padStart(2, '0'), selectedTime.format('A') as Period)
+ }, [selectedDate])
+
+ const handleSelectMinute = useCallback((minute: string) => {
+ const selectedTime = selectedDate || dayjs()
+ handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), minute, selectedTime.format('A') as Period)
+ }, [selectedDate])
+
+ const handleSelectPeriod = useCallback((period: Period) => {
+ const selectedTime = selectedDate || dayjs()
+ handleTimeSelect(getHourIn12Hour(selectedTime).toString().padStart(2, '0'), selectedTime.minute().toString().padStart(2, '0'), period)
+ }, [selectedDate])
+
+ const handleOpenYearMonthPicker = () => {
+ setSelectedMonth(currentDate.month())
+ setSelectedYear(currentDate.year())
+ setView(ViewType.yearMonth)
+ }
+
+ const handleCloseYearMonthPicker = useCallback(() => {
+ setView(ViewType.date)
+ }, [])
+
+ const handleMonthSelect = useCallback((month: number) => {
+ setSelectedMonth(month)
+ }, [])
+
+ const handleYearSelect = useCallback((year: number) => {
+ setSelectedYear(year)
+ }, [])
+
+ const handleYearMonthCancel = useCallback(() => {
+ setView(ViewType.date)
+ }, [])
+
+ const handleYearMonthConfirm = () => {
+ setCurrentDate((prev) => {
+ return prev ? prev.clone().month(selectedMonth).year(selectedYear) : dayjs().month(selectedMonth).year(selectedYear)
+ })
+ setView(ViewType.date)
+ }
+
+ const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY'
+ const displayValue = value?.format(timeFormat) || ''
+ const displayTime = (selectedDate || dayjs().startOf('day')).format('hh:mm A')
+ const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
+
+ return (
+
+
+ {renderTrigger ? (renderTrigger({
+ value,
+ selectedDate,
+ isOpen,
+ handleClear,
+ handleClickTrigger,
+ })) : (
+
+
+
+
+
+ )}
+
+
+
+ {/* Header */}
+ {view === ViewType.date ? (
+
+ ) : view === ViewType.yearMonth ? (
+
+ ) : (
+
+ )}
+
+ {/* Content */}
+ {
+ view === ViewType.date ? (
+
+ ) : view === ViewType.yearMonth ? (
+
+ ) : (
+
+ )
+ }
+
+ {/* Footer */}
+ {
+ [ViewType.date, ViewType.time].includes(view) ? (
+
+ ) : (
+
+ )
+ }
+
+
+
+ )
+}
+
+export default DatePicker
diff --git a/web/app/components/base/date-and-time-picker/hooks.ts b/web/app/components/base/date-and-time-picker/hooks.ts
new file mode 100644
index 0000000000..0976234b4d
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/hooks.ts
@@ -0,0 +1,49 @@
+import dayjs from 'dayjs'
+import { Period } from './types'
+import { useTranslation } from 'react-i18next'
+
+const YEAR_RANGE = 100
+
+export const useDaysOfWeek = () => {
+ const { t } = useTranslation()
+ const daysOfWeek = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map(day => t(`time.daysInWeek.${day}`))
+
+ return daysOfWeek
+}
+
+export const useMonths = () => {
+ const { t } = useTranslation()
+ const months = [
+ 'January',
+ 'February',
+ 'March',
+ 'April',
+ 'May',
+ 'June',
+ 'July',
+ 'August',
+ 'September',
+ 'October',
+ 'November',
+ 'December',
+ ].map(month => t(`time.months.${month}`))
+
+ return months
+}
+
+export const useYearOptions = () => {
+ const yearOptions = Array.from({ length: 200 }, (_, i) => dayjs().year() - YEAR_RANGE / 2 + i)
+ return yearOptions
+}
+
+export const useTimeOptions = () => {
+ const hourOptions = Array.from({ length: 12 }, (_, i) => (i + 1).toString().padStart(2, '0'))
+ const minuteOptions = Array.from({ length: 60 }, (_, i) => i.toString().padStart(2, '0'))
+ const periodOptions = [Period.AM, Period.PM]
+
+ return {
+ hourOptions,
+ minuteOptions,
+ periodOptions,
+ }
+}
diff --git a/web/app/components/base/date-and-time-picker/time-picker/footer.tsx b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx
new file mode 100644
index 0000000000..6209ef5c08
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/time-picker/footer.tsx
@@ -0,0 +1,37 @@
+import React, { type FC } from 'react'
+import type { TimePickerFooterProps } from '../types'
+import Button from '../../button'
+import { useTranslation } from 'react-i18next'
+
+const Footer: FC = ({
+ handleSelectCurrentTime,
+ handleConfirm,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {/* Now */}
+
+ {/* Confirm Button */}
+
+
+
+ )
+}
+
+export default React.memo(Footer)
diff --git a/web/app/components/base/date-and-time-picker/time-picker/header.tsx b/web/app/components/base/date-and-time-picker/time-picker/header.tsx
new file mode 100644
index 0000000000..fc4a1fe2a0
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/time-picker/header.tsx
@@ -0,0 +1,16 @@
+import React from 'react'
+import { useTranslation } from 'react-i18next'
+
+const Header = () => {
+ const { t } = useTranslation()
+
+ return (
+
+
+ {t('time.title.pickTime')}
+
+
+ )
+}
+
+export default React.memo(Header)
diff --git a/web/app/components/base/date-and-time-picker/time-picker/index.tsx b/web/app/components/base/date-and-time-picker/time-picker/index.tsx
new file mode 100644
index 0000000000..348079e535
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/time-picker/index.tsx
@@ -0,0 +1,151 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react'
+import dayjs from 'dayjs'
+import type { Period, TimePickerProps } from '../types'
+import { cloneTime, getHourIn12Hour } from '../utils'
+import {
+ PortalToFollowElem,
+ PortalToFollowElemContent,
+ PortalToFollowElemTrigger,
+} from '@/app/components/base/portal-to-follow-elem'
+import Footer from './footer'
+import Options from './options'
+import Header from './header'
+import { useTranslation } from 'react-i18next'
+import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react'
+import cn from '@/utils/classnames'
+
+const TimePicker = ({
+ value,
+ placeholder,
+ onChange,
+ onClear,
+ renderTrigger,
+}: TimePickerProps) => {
+ const { t } = useTranslation()
+ const [isOpen, setIsOpen] = useState(false)
+ const containerRef = useRef(null)
+ const [selectedTime, setSelectedTime] = useState(value)
+
+ useEffect(() => {
+ const handleClickOutside = (event: MouseEvent) => {
+ if (containerRef.current && !containerRef.current.contains(event.target as Node))
+ setIsOpen(false)
+ }
+ document.addEventListener('mousedown', handleClickOutside)
+ return () => document.removeEventListener('mousedown', handleClickOutside)
+ }, [])
+
+ const handleClickTrigger = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ if (isOpen) {
+ setIsOpen(false)
+ return
+ }
+ setIsOpen(true)
+ }
+
+ const handleClear = (e: React.MouseEvent) => {
+ e.stopPropagation()
+ setSelectedTime(undefined)
+ if (!isOpen)
+ onClear()
+ }
+
+ const handleTimeSelect = (hour: string, minute: string, period: Period) => {
+ const newTime = cloneTime(dayjs(), dayjs(`1/1/2000 ${hour}:${minute} ${period}`))
+ setSelectedTime((prev) => {
+ return prev ? cloneTime(prev, newTime) : newTime
+ })
+ }
+
+ const handleSelectHour = useCallback((hour: string) => {
+ const time = selectedTime || dayjs().startOf('day')
+ handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
+ }, [selectedTime])
+
+ const handleSelectMinute = useCallback((minute: string) => {
+ const time = selectedTime || dayjs().startOf('day')
+ handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
+ }, [selectedTime])
+
+ const handleSelectPeriod = useCallback((period: Period) => {
+ const time = selectedTime || dayjs().startOf('day')
+ handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
+ }, [selectedTime])
+
+ const handleSelectCurrentTime = useCallback(() => {
+ const newDate = dayjs()
+ setSelectedTime(newDate)
+ onChange(newDate)
+ setIsOpen(false)
+ }, [onChange])
+
+ const handleConfirm = useCallback(() => {
+ onChange(selectedTime)
+ setIsOpen(false)
+ }, [onChange, selectedTime])
+
+ const timeFormat = 'hh:mm A'
+ const displayValue = value?.format(timeFormat) || ''
+ const placeholderDate = isOpen && selectedTime ? selectedTime.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
+
+ return (
+
+
+ {renderTrigger ? (renderTrigger()) : (
+
+
+
+
+
+ )}
+
+
+
+ {/* Header */}
+
+
+ {/* Time Options */}
+
+
+ {/* Footer */}
+
+
+
+
+
+ )
+}
+
+export default TimePicker
diff --git a/web/app/components/base/date-and-time-picker/time-picker/options.tsx b/web/app/components/base/date-and-time-picker/time-picker/options.tsx
new file mode 100644
index 0000000000..0e297c91b8
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/time-picker/options.tsx
@@ -0,0 +1,71 @@
+import React, { type FC } from 'react'
+import { useTimeOptions } from '../hooks'
+import type { TimeOptionsProps } from '../types'
+import OptionListItem from '../common/option-list-item'
+
+const Options: FC = ({
+ selectedTime,
+ handleSelectHour,
+ handleSelectMinute,
+ handleSelectPeriod,
+}) => {
+ const { hourOptions, minuteOptions, periodOptions } = useTimeOptions()
+
+ return (
+
+ {/* Hour */}
+
+ {
+ hourOptions.map((hour) => {
+ const isSelected = selectedTime?.format('hh') === hour
+ return (
+
+ {hour}
+
+ )
+ })
+ }
+
+ {/* Minute */}
+
+ {
+ minuteOptions.map((minute) => {
+ const isSelected = selectedTime?.format('mm') === minute
+ return (
+
+ {minute}
+
+ )
+ })
+ }
+
+ {/* Period */}
+
+ {
+ periodOptions.map((period) => {
+ const isSelected = selectedTime?.format('A') === period
+ return (
+
+ {period}
+
+ )
+ })
+ }
+
+
+ )
+}
+
+export default React.memo(Options)
diff --git a/web/app/components/base/date-and-time-picker/types.ts b/web/app/components/base/date-and-time-picker/types.ts
new file mode 100644
index 0000000000..03fc103739
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/types.ts
@@ -0,0 +1,101 @@
+import type { Dayjs } from 'dayjs'
+
+export enum ViewType {
+ date = 'date',
+ yearMonth = 'yearMonth',
+ time = 'time',
+}
+
+export enum Period {
+ AM = 'AM',
+ PM = 'PM',
+}
+
+type TriggerProps = {
+ value: Dayjs | undefined
+ selectedDate: Dayjs | undefined
+ isOpen: boolean
+ handleClear: (e: React.MouseEvent) => void
+ handleClickTrigger: (e: React.MouseEvent) => void
+}
+
+export type DatePickerProps = {
+ value: Dayjs | undefined
+ placeholder?: string
+ needTimePicker?: boolean
+ onChange: (date: Dayjs | undefined) => void
+ onClear: () => void
+ renderTrigger?: (props: TriggerProps) => React.ReactNode
+}
+
+export type DatePickerHeaderProps = {
+ handleOpenYearMonthPicker: () => void
+ currentDate: Dayjs
+ onClickNextMonth: () => void
+ onClickPrevMonth: () => void
+}
+
+export type DatePickerFooterProps = {
+ needTimePicker: boolean
+ displayTime: string
+ view: ViewType
+ handleClickTimePicker: () => void
+ handleSelectCurrentDate: () => void
+ handleConfirmDate: () => void
+}
+
+export type TimePickerProps = {
+ value: Dayjs | undefined
+ placeholder?: string
+ onChange: (date: Dayjs | undefined) => void
+ onClear: () => void
+ renderTrigger?: () => React.ReactNode
+}
+
+export type TimePickerFooterProps = {
+ handleSelectCurrentTime: () => void
+ handleConfirm: () => void
+}
+
+export type Day = {
+ date: Dayjs
+ isCurrentMonth: boolean
+}
+
+export type CalendarProps = {
+ days: Day[]
+ selectedDate: Dayjs | undefined
+ onDateClick: (date: Dayjs) => void
+ wrapperClassName?: string
+}
+
+export type CalendarItemProps = {
+ day: Day
+ selectedDate: Dayjs | undefined
+ onClick: (date: Dayjs) => void
+}
+
+export type TimeOptionsProps = {
+ selectedTime: Dayjs | undefined
+ handleSelectHour: (hour: string) => void
+ handleSelectMinute: (minute: string) => void
+ handleSelectPeriod: (period: Period) => void
+}
+
+export type YearAndMonthPickerHeaderProps = {
+ selectedYear: number
+ selectedMonth: number
+ onClick: () => void
+}
+
+export type YearAndMonthPickerOptionsProps = {
+ selectedYear: number
+ selectedMonth: number
+ handleYearSelect: (year: number) => void
+ handleMonthSelect: (month: number) => void
+}
+
+export type YearAndMonthPickerFooterProps = {
+ handleYearMonthCancel: () => void
+ handleYearMonthConfirm: () => void
+}
diff --git a/web/app/components/base/date-and-time-picker/utils.ts b/web/app/components/base/date-and-time-picker/utils.ts
new file mode 100644
index 0000000000..6d1258c8df
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/utils.ts
@@ -0,0 +1,64 @@
+import type { Dayjs } from 'dayjs'
+import type { Day } from './types'
+
+const monthMaps: Record = {}
+
+export const cloneTime = (targetDate: Dayjs, sourceDate: Dayjs) => {
+ return targetDate.clone()
+ .set('hour', sourceDate.hour())
+ .set('minute', sourceDate.minute())
+}
+
+export const getDaysInMonth = (currentDate: Dayjs) => {
+ const key = currentDate.format('YYYY-MM')
+ // return the cached days
+ if (monthMaps[key])
+ return monthMaps[key]
+
+ const daysInCurrentMonth = currentDate.daysInMonth()
+ const firstDay = currentDate.startOf('month').day()
+ const lastDay = currentDate.endOf('month').day()
+ const lastDayInLastMonth = currentDate.clone().subtract(1, 'month').endOf('month')
+ const firstDayInNextMonth = currentDate.clone().add(1, 'month').startOf('month')
+ const days: Day[] = []
+ const daysInOneWeek = 7
+ const totalLines = 6
+
+ // Add cells for days before the first day of the month
+ for (let i = firstDay - 1; i >= 0; i--) {
+ const date = cloneTime(lastDayInLastMonth.subtract(i, 'day'), currentDate)
+ days.push({
+ date,
+ isCurrentMonth: false,
+ })
+ }
+
+ // Add days of the month
+ for (let i = 1; i <= daysInCurrentMonth; i++) {
+ const date = cloneTime(currentDate.startOf('month').add(i - 1, 'day'), currentDate)
+ days.push({
+ date,
+ isCurrentMonth: true,
+ })
+ }
+
+ // Add cells for days after the last day of the month
+ const totalLinesOfCurrentMonth = Math.ceil((daysInCurrentMonth - ((daysInOneWeek - firstDay) + lastDay + 1)) / 7) + 2
+ const needAdditionalLine = totalLinesOfCurrentMonth < totalLines
+ for (let i = 0; lastDay + i < (needAdditionalLine ? 2 * daysInOneWeek - 1 : daysInOneWeek - 1); i++) {
+ const date = cloneTime(firstDayInNextMonth.add(i, 'day'), currentDate)
+ days.push({
+ date,
+ isCurrentMonth: false,
+ })
+ }
+
+ // cache the days
+ monthMaps[key] = days
+ return days
+}
+
+export const getHourIn12Hour = (date: Dayjs) => {
+ const hour = date.hour()
+ return hour === 0 ? 12 : hour >= 12 ? hour - 12 : hour
+}
diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx
new file mode 100644
index 0000000000..8e0566aefc
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/footer.tsx
@@ -0,0 +1,25 @@
+import type { FC } from 'react'
+import React from 'react'
+import Button from '../../button'
+import type { YearAndMonthPickerFooterProps } from '../types'
+import { useTranslation } from 'react-i18next'
+
+const Footer: FC = ({
+ handleYearMonthCancel,
+ handleYearMonthConfirm,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+ )
+}
+
+export default React.memo(Footer)
diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx
new file mode 100644
index 0000000000..121c784b0a
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/header.tsx
@@ -0,0 +1,27 @@
+import React, { type FC } from 'react'
+import type { YearAndMonthPickerHeaderProps } from '../types'
+import { useMonths } from '../hooks'
+import { RiArrowUpSLine } from '@remixicon/react'
+
+const Header: FC = ({
+ selectedYear,
+ selectedMonth,
+ onClick,
+}) => {
+ const months = useMonths()
+
+ return (
+
+ {/* Year and Month */}
+
+
+ )
+}
+
+export default React.memo(Header)
diff --git a/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx
new file mode 100644
index 0000000000..5864cc94e7
--- /dev/null
+++ b/web/app/components/base/date-and-time-picker/year-and-month-picker/options.tsx
@@ -0,0 +1,55 @@
+import React, { type FC } from 'react'
+import type { YearAndMonthPickerOptionsProps } from '../types'
+import { useMonths, useYearOptions } from '../hooks'
+import OptionListItem from '../common/option-list-item'
+
+const Options: FC = ({
+ selectedMonth,
+ selectedYear,
+ handleMonthSelect,
+ handleYearSelect,
+}) => {
+ const months = useMonths()
+ const yearOptions = useYearOptions()
+
+ return (
+
+ {/* Month Picker */}
+
+ {
+ months.map((month, index) => {
+ const isSelected = selectedMonth === index
+ return (
+
+ {month}
+
+ )
+ })
+ }
+
+ {/* Year Picker */}
+
+ {
+ yearOptions.map((year) => {
+ const isSelected = selectedYear === year
+ return (
+
+ {year}
+
+ )
+ })
+ }
+
+
+ )
+}
+
+export default React.memo(Options)
diff --git a/web/app/styles/globals.css b/web/app/styles/globals.css
index 573523fd48..93ef2ce166 100644
--- a/web/app/styles/globals.css
+++ b/web/app/styles/globals.css
@@ -684,4 +684,17 @@ button:focus-within {
@import "../components/base/action-button/index.css";
@import "../components/base/modal/index.css";
-@tailwind utilities;
\ No newline at end of file
+@tailwind utilities;
+
+@layer utilities {
+ /* Hide scrollbar for Chrome, Safari and Opera */
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+
+ /* Hide scrollbar for IE, Edge and Firefox */
+ .no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
+}
\ No newline at end of file
diff --git a/web/i18n/de-DE/time.ts b/web/i18n/de-DE/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/de-DE/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/en-US/time.ts b/web/i18n/en-US/time.ts
new file mode 100644
index 0000000000..40adad0231
--- /dev/null
+++ b/web/i18n/en-US/time.ts
@@ -0,0 +1,37 @@
+const translation = {
+ daysInWeek: {
+ Sun: 'Sun',
+ Mon: 'Mon',
+ Tue: 'Tue',
+ Wed: 'Wed',
+ Thu: 'Thu',
+ Fri: 'Fri',
+ Sat: 'Sat',
+ },
+ months: {
+ January: 'January',
+ February: 'February',
+ March: 'March',
+ April: 'April',
+ May: 'May',
+ June: 'June',
+ July: 'July',
+ August: 'August',
+ September: 'September',
+ October: 'October',
+ November: 'November',
+ December: 'December',
+ },
+ operation: {
+ now: 'Now',
+ ok: 'OK',
+ cancel: 'Cancel',
+ pickDate: 'Pick Date',
+ },
+ title: {
+ pickTime: 'Pick Time',
+ },
+ defaultPlaceholder: 'Pick a time...',
+}
+
+export default translation
diff --git a/web/i18n/es-ES/time.ts b/web/i18n/es-ES/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/es-ES/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/fa-IR/time.ts b/web/i18n/fa-IR/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/fa-IR/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/fr-FR/time.ts b/web/i18n/fr-FR/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/fr-FR/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/hi-IN/time.ts b/web/i18n/hi-IN/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/hi-IN/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/i18next-config.ts b/web/i18n/i18next-config.ts
index bbba4c7c35..7215b7818e 100644
--- a/web/i18n/i18next-config.ts
+++ b/web/i18n/i18next-config.ts
@@ -30,6 +30,7 @@ const loadLangResources = (lang: string) => ({
runLog: require(`./${lang}/run-log`).default,
plugin: require(`./${lang}/plugin`).default,
pluginTags: require(`./${lang}/plugin-tags`).default,
+ time: require(`./${lang}/time`).default,
},
})
diff --git a/web/i18n/it-IT/time.ts b/web/i18n/it-IT/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/it-IT/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/ja-JP/time.ts b/web/i18n/ja-JP/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/ja-JP/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/ko-KR/time.ts b/web/i18n/ko-KR/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/ko-KR/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/pl-PL/time.ts b/web/i18n/pl-PL/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/pl-PL/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/pt-BR/time.ts b/web/i18n/pt-BR/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/pt-BR/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/ro-RO/time.ts b/web/i18n/ro-RO/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/ro-RO/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/ru-RU/time.ts b/web/i18n/ru-RU/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/ru-RU/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/sl-SI/time.ts b/web/i18n/sl-SI/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/sl-SI/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/th-TH/time.ts b/web/i18n/th-TH/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/th-TH/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/tr-TR/time.ts b/web/i18n/tr-TR/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/tr-TR/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/uk-UA/time.ts b/web/i18n/uk-UA/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/uk-UA/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/vi-VN/time.ts b/web/i18n/vi-VN/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/vi-VN/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation
diff --git a/web/i18n/zh-Hans/time.ts b/web/i18n/zh-Hans/time.ts
new file mode 100644
index 0000000000..8a223d9dd1
--- /dev/null
+++ b/web/i18n/zh-Hans/time.ts
@@ -0,0 +1,37 @@
+const translation = {
+ daysInWeek: {
+ Sun: '日',
+ Mon: '一',
+ Tue: '二',
+ Wed: '三',
+ Thu: '四',
+ Fri: '五',
+ Sat: '六',
+ },
+ months: {
+ January: '一月',
+ February: '二月',
+ March: '三月',
+ April: '四月',
+ May: '五月',
+ June: '六月',
+ July: '七月',
+ August: '八月',
+ September: '九月',
+ October: '十月',
+ November: '十一月',
+ December: '十二月',
+ },
+ operation: {
+ now: '此刻',
+ ok: '确定',
+ cancel: '取消',
+ },
+ title: {
+ pickTime: '选择时间',
+ },
+ pickDate: '选择日期',
+ defaultPlaceholder: '请选择时间...',
+}
+
+export default translation
diff --git a/web/i18n/zh-Hant/time.ts b/web/i18n/zh-Hant/time.ts
new file mode 100644
index 0000000000..e2410dd34b
--- /dev/null
+++ b/web/i18n/zh-Hant/time.ts
@@ -0,0 +1,3 @@
+const translation = {}
+
+export default translation