tududi/frontend/components/Shared/DateTimePicker.tsx
2025-12-08 12:23:43 +02:00

486 lines
19 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import {
ChevronLeftIcon,
ChevronRightIcon,
ClockIcon,
} from '@heroicons/react/24/outline';
import {
getFirstDayOfWeek,
getLocaleFirstDayOfWeek,
} from '../../utils/profileService';
import { useTranslation } from 'react-i18next';
import { resolveUserLocale } from '../../utils/localeUtils';
interface DateTimePickerProps {
value: string; // ISO string
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
const DateTimePicker: React.FC<DateTimePickerProps> = ({
value,
onChange,
placeholder = 'Select date and time',
disabled = false,
className = '',
}) => {
const { i18n } = useTranslation();
const displayLocale = useMemo(
() => resolveUserLocale(i18n?.language),
[i18n?.language]
);
const [isOpen, setIsOpen] = useState(false);
const [position, setPosition] = useState({
top: 0,
left: 0,
width: 0,
openUpward: false,
});
const [currentMonth, setCurrentMonth] = useState(new Date());
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
const [selectedTime, setSelectedTime] = useState('12:00');
const [firstDayOfWeek, setFirstDayOfWeek] = useState(0);
const dropdownRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const months = [
'January',
'February',
'March',
'April',
'May',
'June',
'July',
'August',
'September',
'October',
'November',
'December',
];
// Generate time options in 15-minute increments
const generateTimeOptions = () => {
const options = [];
for (let hour = 0; hour < 24; hour++) {
for (let minute = 0; minute < 60; minute += 15) {
const h = String(hour).padStart(2, '0');
const m = String(minute).padStart(2, '0');
options.push(`${h}:${m}`);
}
}
return options;
};
const timeOptions = generateTimeOptions();
const getAllDays = () => ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const getDaysOfWeek = () => {
const allDays = getAllDays();
return [
...allDays.slice(firstDayOfWeek),
...allDays.slice(0, firstDayOfWeek),
];
};
const parseDateTime = (
isoString: string
): { date: Date | null; time: string } => {
if (!isoString) return { date: null, time: '12:00' };
try {
const date = new Date(isoString);
if (isNaN(date.getTime())) return { date: null, time: '12:00' };
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(
Math.floor(date.getMinutes() / 15) * 15
).padStart(2, '0');
return { date, time: `${hours}:${minutes}` };
} catch {
return { date: null, time: '12:00' };
}
};
const formatDisplayDateTime = (isoString: string) => {
if (!isoString) return placeholder;
const { date, time } = parseDateTime(isoString);
if (!date) return placeholder;
const displayDate = new Date(date);
if (time) {
const [hours, minutes] = time.split(':');
displayDate.setHours(parseInt(hours), parseInt(minutes), 0, 0);
}
const dateStr = displayDate.toLocaleDateString(displayLocale, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
const timeStr = displayDate.toLocaleTimeString(displayLocale, {
hour: 'numeric',
minute: '2-digit',
});
return `${dateStr} at ${timeStr}`;
};
useEffect(() => {
const { date, time } = parseDateTime(value);
if (date) {
setSelectedDate(date);
setSelectedTime(time);
setCurrentMonth(new Date(date.getFullYear(), date.getMonth(), 1));
}
}, [value]);
const handleToggle = () => {
if (disabled) return;
if (!isOpen && dropdownRef.current) {
const rect = dropdownRef.current.getBoundingClientRect();
const spaceBelow = window.innerHeight - rect.bottom;
const spaceAbove = rect.top;
const menuHeight = 420; // Calendar + time picker height
const padding = 16;
const wouldFitBelow = spaceBelow >= menuHeight + padding;
const wouldFitAbove = spaceAbove >= menuHeight + padding;
let openUpward = false;
let top = rect.bottom + 8;
if (!wouldFitBelow && wouldFitAbove) {
openUpward = true;
top = rect.top - menuHeight - 8;
} else if (!wouldFitBelow && !wouldFitAbove) {
if (spaceAbove > spaceBelow) {
openUpward = true;
top = Math.max(padding, rect.top - menuHeight - 8);
} else {
top = Math.min(
window.innerHeight - menuHeight - padding,
rect.bottom + 8
);
}
}
const calendarWidth = Math.min(Math.max(rect.width, 280), 320);
const left = Math.min(
Math.max(padding, rect.left),
window.innerWidth - calendarWidth - padding
);
setPosition({
top,
left,
width: calendarWidth,
openUpward,
});
}
setIsOpen(!isOpen);
};
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
menuRef.current &&
!menuRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
const handleDateSelect = (date: Date) => {
setSelectedDate(date);
};
const handleApply = () => {
if (!selectedDate) return;
const [hours, minutes] = selectedTime.split(':');
const dateTime = new Date(selectedDate);
dateTime.setHours(parseInt(hours), parseInt(minutes), 0, 0);
onChange(dateTime.toISOString());
setIsOpen(false);
};
const handleClear = (e: React.MouseEvent) => {
e.stopPropagation();
onChange('');
setSelectedDate(null);
setSelectedTime('12:00');
setIsOpen(false);
};
const navigateMonth = (direction: 'prev' | 'next') => {
setCurrentMonth((prev) => {
const newMonth = new Date(prev);
if (direction === 'prev') {
newMonth.setMonth(newMonth.getMonth() - 1);
} else {
newMonth.setMonth(newMonth.getMonth() + 1);
}
return newMonth;
});
};
const getDaysInMonth = () => {
const year = currentMonth.getFullYear();
const month = currentMonth.getMonth();
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
const daysInMonth = lastDay.getDate();
const startingDayOfWeek = firstDay.getDay();
const adjustedStartingDay =
(startingDayOfWeek - firstDayOfWeek + 7) % 7;
const days = [];
for (let i = 0; i < adjustedStartingDay; i++) {
days.push(null);
}
for (let day = 1; day <= daysInMonth; day++) {
days.push(new Date(year, month, day));
}
return days;
};
const isToday = (date: Date) => {
const today = new Date();
return date.toDateString() === today.toDateString();
};
const isSelected = (date: Date) => {
return (
selectedDate && date.toDateString() === selectedDate.toDateString()
);
};
useEffect(() => {
const loadFirstDayOfWeek = async () => {
try {
const firstDay = await getFirstDayOfWeek();
setFirstDayOfWeek(firstDay);
} catch {
const fallbackFirstDay = getLocaleFirstDayOfWeek(
navigator.language
);
setFirstDayOfWeek(fallbackFirstDay);
}
};
loadFirstDayOfWeek();
}, []);
useEffect(() => {
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
} else {
document.removeEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
return (
<div
ref={dropdownRef}
data-testid="datetimepicker"
data-state={isOpen ? 'open' : 'closed'}
className={`relative inline-block text-left w-full ${className}`}
>
<div className="relative">
<button
type="button"
className={`inline-flex justify-between w-full px-3 py-2 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors ${
disabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
handleToggle();
}}
disabled={disabled}
>
<span
className={`truncate ${!value ? 'text-gray-500 dark:text-gray-400' : ''}`}
>
{formatDisplayDateTime(value)}
</span>
<div className="flex items-center space-x-1">
<ClockIcon className="w-5 h-5 text-gray-500 dark:text-gray-300" />
</div>
</button>
{value && (
<button
type="button"
onClick={handleClear}
className="absolute right-8 top-1/2 transform -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-xs w-4 h-4 flex items-center justify-center"
>
×
</button>
)}
</div>
{isOpen &&
createPortal(
<div
ref={menuRef}
className="fixed z-50 bg-white dark:bg-gray-700 shadow-lg rounded-md border border-gray-200 dark:border-gray-600"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
width: `${position.width}px`,
}}
onClick={(e) => e.stopPropagation()}
>
{/* Calendar Header */}
<div className="flex items-center justify-between p-3 border-b border-gray-200 dark:border-gray-600">
<button
type="button"
onClick={() => navigateMonth('prev')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<ChevronLeftIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{months[currentMonth.getMonth()]}{' '}
{currentMonth.getFullYear()}
</span>
<button
type="button"
onClick={() => navigateMonth('next')}
className="p-1 hover:bg-gray-100 dark:hover:bg-gray-600 rounded"
>
<ChevronRightIcon className="w-5 h-5 text-gray-600 dark:text-gray-300" />
</button>
</div>
{/* Calendar Grid */}
<div className="p-3">
<div className="grid grid-cols-7 gap-1 mb-2">
{getDaysOfWeek().map((day) => (
<div
key={day}
className="text-xs font-medium text-gray-500 dark:text-gray-400 text-center py-1"
>
{day}
</div>
))}
</div>
<div className="grid grid-cols-7 gap-1">
{getDaysInMonth().map((date, index) => (
<div key={index} className="aspect-square">
{date && (
<button
type="button"
onClick={() =>
handleDateSelect(date)
}
className={`w-full h-full text-xs rounded transition-colors ${
isSelected(date)
? 'bg-blue-600 text-white'
: isToday(date)
? 'bg-blue-100 dark:bg-blue-900 text-blue-900 dark:text-blue-100'
: 'hover:bg-gray-100 dark:hover:bg-gray-600 text-gray-900 dark:text-gray-100'
}`}
>
{date.getDate()}
</button>
)}
</div>
))}
</div>
</div>
{/* Time Picker */}
<div className="border-t border-gray-200 dark:border-gray-600 p-3">
<label className="text-xs font-medium text-gray-700 dark:text-gray-300 block mb-2">
Time
</label>
<select
value={selectedTime}
onChange={(e) =>
setSelectedTime(e.target.value)
}
className="w-full px-2 py-1.5 text-sm bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-32"
>
{timeOptions.map((time) => {
const [h, m] = time.split(':');
const hour = parseInt(h);
const ampm = hour >= 12 ? 'PM' : 'AM';
const displayHour =
hour === 0
? 12
: hour > 12
? hour - 12
: hour;
return (
<option key={time} value={time}>
{displayHour}:{m} {ampm}
</option>
);
})}
</select>
</div>
{/* Footer */}
<div className="border-t border-gray-200 dark:border-gray-600 p-3 flex justify-between">
<button
type="button"
onClick={() => {
const now = new Date();
setSelectedDate(now);
const hours = String(
now.getHours()
).padStart(2, '0');
const minutes = String(
Math.floor(now.getMinutes() / 15) * 15
).padStart(2, '0');
setSelectedTime(`${hours}:${minutes}`);
}}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
>
Now
</button>
<div className="flex space-x-2">
{value && (
<button
type="button"
onClick={handleClear}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
Clear
</button>
)}
<button
type="button"
onClick={handleApply}
disabled={!selectedDate}
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 disabled:opacity-50 disabled:cursor-not-allowed"
>
Apply
</button>
</div>
</div>
</div>,
document.body
)}
</div>
);
};
export default DateTimePicker;