diff --git a/src/app/(modules)/ticket/[ticketId]/components/actions/AssignTicketAction.tsx b/src/app/(modules)/ticket/[ticketId]/components/actions/AssignTicketAction.tsx index 4e138cc..bda2fd1 100644 --- a/src/app/(modules)/ticket/[ticketId]/components/actions/AssignTicketAction.tsx +++ b/src/app/(modules)/ticket/[ticketId]/components/actions/AssignTicketAction.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { Users, Mail, Phone, Send, ShieldCheck } from 'lucide-react'; -import { DatePicker } from '@/components/form/DatePicker'; +import { DatePickerSimple } from '@/components/form/DatePickerSimple'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; @@ -21,6 +21,15 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) { const [assignNote, setAssignNote] = useState(''); const assignMutation = useAssignTicketMutation(ticket.id); + const today = useMemo(() => { + const date = new Date(); + date.setHours(0, 0, 0, 0); + return date; + }, []); + const maxDueMonth = useMemo( + () => new Date(today.getFullYear() + 5, 11), + [today], + ); return ( @@ -56,13 +65,20 @@ export function AssignTicketAction({ ticket }: { ticket: TicketDetail }) { ) : null}
- - + Due Date * + +
diff --git a/src/components/form/DatePickerSimple.tsx b/src/components/form/DatePickerSimple.tsx new file mode 100644 index 0000000..f107794 --- /dev/null +++ b/src/components/form/DatePickerSimple.tsx @@ -0,0 +1,129 @@ +'use client'; + +import * as React from 'react'; +import type { Matcher } from 'react-day-picker'; + +import { Button } from '@/components/ui/button'; +import { CalendarGridPicker } from '@/components/ui/calendar-grid-picker'; +import { FormField } from '@/components/form/FormField'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +export interface DatePickerSimpleProps { + value?: Date; + defaultValue?: Date; + onChange?: (date: Date | undefined) => void; + label?: string; + placeholder?: string; + disabled?: boolean; + className?: string; + id?: string; + showLabel?: boolean; + startMonth?: Date; + endMonth?: Date; + disabledDates?: Matcher | Matcher[]; +} + +function startOfDay(date: Date) { + const next = new Date(date); + next.setHours(0, 0, 0, 0); + return next; +} + +function clampDateToRange(date: Date, min: Date, max: Date) { + if (date < min) return min; + if (date > max) return max; + return date; +} + +export function DatePickerSimple(props: DatePickerSimpleProps) { + const { + value, + defaultValue, + onChange, + label = 'Date of birth', + placeholder = 'Select date', + disabled = false, + className, + id = 'date', + showLabel = true, + startMonth, + endMonth, + disabledDates, + } = props; + const isControlled = Object.hasOwn(props, 'value'); + const [open, setOpen] = React.useState(false); + const [internalDate, setInternalDate] = React.useState( + defaultValue, + ); + + const selectedDate = isControlled ? value : internalDate; + const today = React.useMemo(() => startOfDay(new Date()), []); + const resolvedStartMonth = React.useMemo( + () => startMonth ?? new Date(today.getFullYear() - 100, 0), + [startMonth, today], + ); + const resolvedEndMonth = React.useMemo( + () => endMonth ?? today, + [endMonth, today], + ); + const initialMonth = selectedDate + ? startOfDay(selectedDate) + : clampDateToRange(today, resolvedStartMonth, resolvedEndMonth); + + const handleSelect = (date: Date | undefined) => { + if (!isControlled) setInternalDate(date); + onChange?.(date); + setOpen(false); + }; + + const picker = ( + { + setOpen(nextOpen); + }} + > + + + + + + + + ); + + if (!showLabel) { + return
{picker}
; + } + + return ( + + {picker} + + ); +} diff --git a/src/components/form/index.ts b/src/components/form/index.ts index f048e60..92f2452 100644 --- a/src/components/form/index.ts +++ b/src/components/form/index.ts @@ -5,3 +5,5 @@ export { PhoneInput } from './phone-input/PhoneInput'; export { SelectPopover } from './SelectPopover'; export type { SelectPopoverOption } from './SelectPopover'; export { PasswordField } from './PasswordField'; +export { DatePickerSimple } from './DatePickerSimple'; +export type { DatePickerSimpleProps } from './DatePickerSimple'; diff --git a/src/components/ui/calendar-grid-picker.tsx b/src/components/ui/calendar-grid-picker.tsx new file mode 100644 index 0000000..3176d34 --- /dev/null +++ b/src/components/ui/calendar-grid-picker.tsx @@ -0,0 +1,537 @@ +'use client'; + +import * as React from 'react'; +import { ChevronLeftIcon, ChevronRightIcon } from 'lucide-react'; +import { useDayPicker, type MonthCaptionProps } from 'react-day-picker'; + +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; +import { Calendar, CalendarDayButton } from '@/components/ui/calendar'; + +type CalendarView = 'days' | 'months' | 'years'; + +const MONTH_LABELS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] as const; + +const PICKER_WIDTH = 'w-[280px]'; +const PICKER_SHELL_CLASS = + 'group/calendar p-3 text-popover-foreground [--cell-size:--spacing(8)]'; +const PICKER_HEADER_CLASS = + 'grid min-h-8 grid-cols-[2rem_1fr_2rem] items-center gap-1 border-b border-border pb-2'; +const PICKER_NAV_BUTTON_CLASS = 'size-8 shrink-0 p-0'; +const GRID_BUTTON_CLASS = + 'h-9 w-full rounded-md font-medium text-foreground hover:bg-accent/60 hover:text-accent-foreground'; +const GRID_BUTTON_ACTIVE_CLASS = + 'bg-muted text-foreground hover:bg-muted hover:text-foreground'; +const GRID_BUTTON_DISABLED_CLASS = + 'text-muted-foreground opacity-50 hover:bg-transparent hover:text-muted-foreground'; + +function getMonthIndex(date: Date) { + return date.getFullYear() * 12 + date.getMonth(); +} + +function isYearOutsideRange(year: number, startMonth?: Date, endMonth?: Date) { + if (startMonth && year < startMonth.getFullYear()) return true; + if (endMonth && year > endMonth.getFullYear()) return true; + return false; +} + +function isMonthOutsideRange( + year: number, + month: number, + startMonth?: Date, + endMonth?: Date, +) { + const monthIndex = year * 12 + month; + + if (startMonth && monthIndex < getMonthIndex(startMonth)) return true; + if (endMonth && monthIndex > getMonthIndex(endMonth)) return true; + + return false; +} + +function clampMonthToRange( + year: number, + month: number, + startMonth?: Date, + endMonth?: Date, +) { + let nextMonth = month; + + if (startMonth && year === startMonth.getFullYear()) { + nextMonth = Math.max(nextMonth, startMonth.getMonth()); + } + + if (endMonth && year === endMonth.getFullYear()) { + nextMonth = Math.min(nextMonth, endMonth.getMonth()); + } + + return Math.min(11, Math.max(0, nextMonth)); +} + +type CalendarGridPickerContextValue = { + view: CalendarView; + setView: (view: CalendarView) => void; + pickerYear: number; + setPickerYear: React.Dispatch>; + decadeStart: number; + setDecadeStart: React.Dispatch>; +}; + +const CalendarGridPickerContext = + React.createContext(null); + +function useCalendarGridPicker() { + const context = React.useContext(CalendarGridPickerContext); + if (!context) { + throw new Error( + 'useCalendarGridPicker must be used within CalendarGridPicker', + ); + } + return context; +} + +function PickerNav({ + label, + onPrevious, + onNext, + onLabelClick, + disablePrevious = false, + disableNext = false, +}: { + label: string; + onPrevious: () => void; + onNext: () => void; + onLabelClick?: () => void; + disablePrevious?: boolean; + disableNext?: boolean; +}) { + return ( +
+ + {onLabelClick ? ( + + ) : ( + + {label} + + )} + +
+ ); +} + +function MonthGrid({ + year, + activeMonth, + onSelect, + startMonth, + endMonth, +}: { + year: number; + activeMonth: number; + onSelect: (month: number) => void; + startMonth?: Date; + endMonth?: Date; +}) { + return ( +
+ {MONTH_LABELS.map((label, month) => { + const isDisabled = isMonthOutsideRange( + year, + month, + startMonth, + endMonth, + ); + + return ( + + ); + })} +
+ ); +} + +function YearGrid({ + decadeStart, + activeYear, + onSelect, + startMonth, + endMonth, +}: { + decadeStart: number; + activeYear: number; + onSelect: (year: number) => void; + startMonth?: Date; + endMonth?: Date; +}) { + const years = React.useMemo( + () => Array.from({ length: 10 }, (_, index) => decadeStart + index), + [decadeStart], + ); + + return ( +
+ {years.map((year) => { + const isDisabled = isYearOutsideRange(year, startMonth, endMonth); + + return ( + + ); + })} +
+ ); +} + +function CalendarMonthCaption({ + calendarMonth, + className, + children: _children, + displayIndex: _displayIndex, + ...props +}: MonthCaptionProps) { + const { goToMonth, previousMonth, nextMonth } = useDayPicker(); + const { setView, setPickerYear, setDecadeStart } = useCalendarGridPicker(); + const date = calendarMonth.date; + const monthName = date.toLocaleString('default', { month: 'long' }); + const year = date.getFullYear(); + + return ( +
+ + +
+ + +
+ + +
+ ); +} + +function CalendarGridPickerPanel({ + month, + onMonthChange, + startMonth, + endMonth, +}: { + month: Date; + onMonthChange: (month: Date) => void; + startMonth?: Date; + endMonth?: Date; +}) { + const { + view, + setView, + pickerYear, + setPickerYear, + decadeStart, + setDecadeStart, + } = useCalendarGridPicker(); + + const minYear = startMonth?.getFullYear(); + const maxYear = endMonth?.getFullYear(); + const activeMonth = clampMonthToRange( + pickerYear, + month.getMonth(), + startMonth, + endMonth, + ); + + if (view === 'months') { + return ( +
+ setPickerYear((year) => year - 1)} + onNext={() => setPickerYear((year) => year + 1)} + onLabelClick={() => { + setDecadeStart(Math.floor(pickerYear / 10) * 10); + setView('years'); + }} + disablePrevious={minYear !== undefined && pickerYear <= minYear} + disableNext={maxYear !== undefined && pickerYear >= maxYear} + /> + { + onMonthChange(new Date(pickerYear, nextMonth, 1)); + setView('days'); + }} + /> +
+ ); + } + + if (view === 'years') { + return ( +
+ setDecadeStart((start) => start - 10)} + onNext={() => setDecadeStart((start) => start + 10)} + disablePrevious={minYear !== undefined && decadeStart <= minYear} + disableNext={maxYear !== undefined && decadeStart + 10 > maxYear} + /> + { + const nextMonth = clampMonthToRange( + year, + activeMonth, + startMonth, + endMonth, + ); + + setPickerYear(year); + onMonthChange(new Date(year, nextMonth, 1)); + setView('months'); + }} + /> +
+ ); + } + + return null; +} + +export type CalendarGridPickerProps = Omit< + Extract, { mode: 'single' }>, + 'captionLayout' | 'navLayout' | 'hideNavigation' +> & { + onViewChange?: (view: CalendarView) => void; +}; + +function CalendarGridPicker({ + className, + classNames, + components, + formatters, + month, + defaultMonth, + onMonthChange, + onViewChange, + startMonth, + endMonth, + ...props +}: CalendarGridPickerProps) { + const initialMonth = month ?? defaultMonth ?? new Date(); + const [view, setView] = React.useState('days'); + const [pickerYear, setPickerYear] = React.useState( + initialMonth.getFullYear(), + ); + const [decadeStart, setDecadeStart] = React.useState( + Math.floor(initialMonth.getFullYear() / 10) * 10, + ); + const [internalMonth, setInternalMonth] = React.useState(initialMonth); + + const currentMonth = month ?? internalMonth; + + React.useEffect(() => { + if (month) { + setPickerYear(month.getFullYear()); + setDecadeStart(Math.floor(month.getFullYear() / 10) * 10); + } + }, [month]); + + const handleViewChange = React.useCallback( + (nextView: CalendarView) => { + setView(nextView); + onViewChange?.(nextView); + }, + [onViewChange], + ); + + const handleMonthChange = React.useCallback( + (nextMonth: Date) => { + if (!month) setInternalMonth(nextMonth); + setPickerYear(nextMonth.getFullYear()); + setDecadeStart(Math.floor(nextMonth.getFullYear() / 10) * 10); + onMonthChange?.(nextMonth); + }, + [month, onMonthChange], + ); + + const contextValue = React.useMemo( + () => ({ + view, + setView: handleViewChange, + pickerYear, + setPickerYear, + decadeStart, + setDecadeStart, + }), + [view, handleViewChange, pickerYear, decadeStart], + ); + + if (view !== 'days') { + return ( + +
+ +
+
+ ); + } + + return ( + + + date.toLocaleString('en-US', { weekday: 'short' }).slice(0, 2), + ...formatters, + }} + classNames={{ + month: 'flex w-full flex-col gap-2', + month_caption: 'p-0', + weekdays: 'flex w-full', + weekday: + 'flex-1 text-center text-[0.8rem] font-medium text-muted-foreground select-none', + week: 'mt-1 flex w-full', + ...classNames, + }} + components={{ + MonthCaption: CalendarMonthCaption, + DayButton: (dayButtonProps) => ( + + ), + ...components, + }} + {...(props as Extract< + React.ComponentProps, + { mode: 'single' } + >)} + /> + + ); +} + +export { CalendarGridPicker, type CalendarView };