Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo } from 'react'; | |
| import { | |
| format, | |
| startOfMonth, | |
| endOfMonth, | |
| startOfWeek, | |
| endOfWeek, | |
| eachDayOfInterval, | |
| isSameMonth, | |
| isSameDay, | |
| addMonths, | |
| subMonths, | |
| isToday, | |
| parseISO | |
| } from 'date-fns'; | |
| import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, Clock } from 'lucide-react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { cn } from '@/lib/utils'; | |
| import { TimeLog } from '@/services/timeLogApi'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; | |
| interface Task { | |
| id: number; | |
| title: string; | |
| } | |
| interface MonthlyCalendarProps { | |
| timeLogs: TimeLog[]; | |
| tasks: Task[]; | |
| onSelectDate?: (date: Date) => void; | |
| isLoading?: boolean; | |
| } | |
| export const MonthlyCalendar: React.FC<MonthlyCalendarProps> = ({ | |
| timeLogs, | |
| tasks, | |
| onSelectDate, | |
| isLoading = false | |
| }) => { | |
| const [currentMonth, setCurrentMonth] = useState(new Date()); | |
| const nextMonth = () => setCurrentMonth(addMonths(currentMonth, 1)); | |
| const prevMonth = () => setCurrentMonth(subMonths(currentMonth, 1)); | |
| const goToToday = () => setCurrentMonth(new Date()); | |
| const monthStart = startOfMonth(currentMonth); | |
| const monthEnd = endOfMonth(monthStart); | |
| const startDate = startOfWeek(monthStart); | |
| const endDate = endOfWeek(monthEnd); | |
| const calendarDays = eachDayOfInterval({ start: startDate, end: endDate }); | |
| const weekDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; | |
| // Memoize grouped logs for performance | |
| const logsByDate = useMemo(() => { | |
| const grouped: Record<string, TimeLog[]> = {}; | |
| timeLogs.forEach(log => { | |
| // Create a date string that represents the local date of the workDate | |
| // Assuming workDate is ISO string e.g., "2023-10-27T00:00:00.000Z" | |
| const dateStr = format(parseISO(log.workDate), 'yyyy-MM-dd'); | |
| if (!grouped[dateStr]) { | |
| grouped[dateStr] = []; | |
| } | |
| grouped[dateStr].push(log); | |
| }); | |
| return grouped; | |
| }, [timeLogs]); | |
| const getLogsForDate = (date: Date) => { | |
| const dateStr = format(date, 'yyyy-MM-dd'); | |
| return logsByDate[dateStr] || []; | |
| }; | |
| const getTaskTitle = (taskId: number) => { | |
| const task = tasks.find(t => t.id === taskId); | |
| return task ? task.title : `Task #${taskId}`; | |
| }; | |
| const getTotalHoursForDate = (logs: TimeLog[]) => { | |
| return logs.reduce((total, log) => total + log.hrsSpent, 0); | |
| }; | |
| const formatHours = (hours: number) => Number.isInteger(hours) ? hours : hours.toFixed(1); | |
| if (isLoading) { | |
| return ( | |
| <div className="flex justify-center items-center h-96 bg-white rounded-xl shadow-sm border border-gray-100"> | |
| <div className="flex flex-col items-center gap-3"> | |
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600"></div> | |
| <p className="text-sm text-gray-500">Loading calendar...</p> | |
| </div> | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden flex flex-col h-full min-h-[600px]"> | |
| {/* Calendar Header */} | |
| <div className="flex flex-col sm:flex-row items-center justify-between p-4 border-b border-gray-200 gap-4 bg-white"> | |
| <div className="flex items-center gap-3"> | |
| <div className="h-10 w-10 rounded-full bg-indigo-50 flex items-center justify-center text-indigo-600"> | |
| <CalendarIcon className="h-5 w-5" /> | |
| </div> | |
| <div> | |
| <h2 className="text-xl font-bold text-gray-800 leading-none"> | |
| {format(currentMonth, 'MMMM yyyy')} | |
| </h2> | |
| <p className="text-sm text-gray-500 mt-1"> | |
| {timeLogs.length} entries for selected range | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 bg-gray-50 p-1 rounded-lg border border-gray-100"> | |
| <Button variant="ghost" size="icon" onClick={prevMonth} className="h-8 w-8 hover:bg-white hover:shadow-sm"> | |
| <ChevronLeft className="h-4 w-4" /> | |
| </Button> | |
| <Button variant="ghost" size="sm" onClick={goToToday} className="h-8 text-xs font-medium px-3 hover:bg-white hover:shadow-sm"> | |
| Today | |
| </Button> | |
| <Button variant="ghost" size="icon" onClick={nextMonth} className="h-8 w-8 hover:bg-white hover:shadow-sm"> | |
| <ChevronRight className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Week Days Header */} | |
| <div className="grid grid-cols-7 border-b border-gray-200 bg-gray-50/50"> | |
| {weekDays.map(day => ( | |
| <div key={day} className="py-3 text-center text-xs font-semibold text-gray-500 uppercase tracking-wider"> | |
| {day} | |
| </div> | |
| ))} | |
| </div> | |
| {/* Calendar Grid */} | |
| <div className="grid grid-cols-7 flex-1 auto-rows-fr bg-gray-100 gap-px"> | |
| {calendarDays.map((day, dayIdx) => { | |
| const logs = getLogsForDate(day); | |
| const totalHours = getTotalHoursForDate(logs); | |
| const isCurrentMonth = isSameMonth(day, currentMonth); | |
| const isDayToday = isToday(day); | |
| return ( | |
| <div | |
| key={day.toString()} | |
| className={cn( | |
| "bg-white p-2 min-h-[100px] flex flex-col gap-1 transition-all hover:bg-gray-50/80 relative group", | |
| !isCurrentMonth && "bg-gray-50/30 text-gray-400", | |
| isDayToday && "bg-indigo-50/10 ring-1 ring-inset ring-indigo-500/20" | |
| )} | |
| onClick={() => onSelectDate && onSelectDate(day)} | |
| > | |
| <div className="flex justify-between items-start mb-1"> | |
| <span className={cn( | |
| "text-sm font-medium h-7 w-7 flex items-center justify-center rounded-full transition-colors", | |
| isDayToday | |
| ? "bg-indigo-600 text-white shadow-sm" | |
| : "text-gray-700 group-hover:bg-gray-200" | |
| )}> | |
| {format(day, 'd')} | |
| </span> | |
| {totalHours > 0 && ( | |
| <div className={cn( | |
| "flex items-center gap-1 text-[10px] font-bold px-1.5 py-0.5 rounded-full", | |
| totalHours >= 8 | |
| ? "bg-emerald-100 text-emerald-700" | |
| : totalHours > 4 | |
| ? "bg-indigo-100 text-indigo-700" | |
| : "bg-amber-100 text-amber-700" | |
| )}> | |
| <Clock className="h-3 w-3" /> | |
| {formatHours(totalHours)}h | |
| </div> | |
| )} | |
| </div> | |
| <div className="flex-1 flex flex-col gap-1 overflow-y-auto max-h-[120px] custom-scrollbar"> | |
| {logs.map((log, idx) => ( | |
| <TooltipProvider key={log.id}> | |
| <Tooltip delayDuration={0}> | |
| <TooltipTrigger asChild> | |
| <div className={cn( | |
| "text-[10px] p-1.5 rounded-md border truncate cursor-pointer transition-all shadow-sm transform hover:scale-[1.02]", | |
| "bg-white border-gray-200 hover:border-indigo-300 hover:shadow-md hover:z-10", | |
| log.taskId % 4 === 0 ? "border-l-4 border-l-purple-500" : | |
| log.taskId % 4 === 1 ? "border-l-4 border-l-blue-500" : | |
| log.taskId % 4 === 2 ? "border-l-4 border-l-emerald-500" : | |
| "border-l-4 border-l-amber-500" | |
| )}> | |
| <span className="font-semibold block truncate text-gray-700 mb-0.5"> | |
| {getTaskTitle(log.taskId)} | |
| </span> | |
| <div className="flex justify-between items-center text-gray-500 text-[9px]"> | |
| <span className="truncate max-w-[60%]">{log.startTime}</span> | |
| <span className="font-medium bg-gray-100 px-1 rounded">{formatHours(log.hrsSpent)}h</span> | |
| </div> | |
| </div> | |
| </TooltipTrigger> | |
| <TooltipContent side="right" className="p-0 border-none shadow-xl"> | |
| <div className="w-64 bg-white rounded-lg border border-gray-200 overflow-hidden"> | |
| <div className="bg-gray-50 px-3 py-2 border-b border-gray-200 flex justify-between items-center"> | |
| <span className="font-semibold text-xs text-gray-700">Task Details</span> | |
| <Badge variant="outline" className="text-[10px] bg-white"> | |
| {log.hrsSpent}h | |
| </Badge> | |
| </div> | |
| <div className="p-3"> | |
| <h4 className="font-bold text-sm text-gray-800 mb-1">{getTaskTitle(log.taskId)}</h4> | |
| {log.comments && ( | |
| <div | |
| className="text-xs text-gray-600 mt-2 bg-gray-50 p-2 rounded border border-gray-100" | |
| dangerouslySetInnerHTML={{ __html: log.comments }} | |
| /> | |
| )} | |
| <div className="flex items-center gap-2 mt-3 pt-2 border-t border-gray-100 text-xs text-gray-500"> | |
| <Clock className="h-3 w-3" /> | |
| <span>{log.startTime} - {log.endTime}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </TooltipContent> | |
| </Tooltip> | |
| </TooltipProvider> | |
| ))} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| }; | |