Spaces:
Sleeping
Sleeping
| import React, { useState, useMemo } from 'react'; | |
| import { | |
| format, | |
| startOfMonth, | |
| endOfMonth, | |
| startOfWeek, | |
| endOfWeek, | |
| eachDayOfInterval, | |
| isSameMonth, | |
| isSameDay, | |
| addMonths, | |
| subMonths, | |
| isToday, | |
| parseISO, | |
| isWithinInterval, | |
| startOfDay, | |
| endOfDay | |
| } from 'date-fns'; | |
| import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, CheckCircle2, Circle, Clock, Users } from 'lucide-react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { cn } from '@/lib/utils'; | |
| import { Task } from '@/services/tasksApi'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; | |
| import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; | |
| interface Employee { | |
| id: number; | |
| firstName: string; | |
| lastName: string; | |
| profileImage?: string; | |
| } | |
| interface MonthlyTaskCalendarProps { | |
| tasks: Task[]; | |
| employees?: Employee[]; | |
| onSelectDate?: (date: Date) => void; | |
| isLoading?: boolean; | |
| } | |
| export const MonthlyTaskCalendar: React.FC<MonthlyTaskCalendarProps> = ({ | |
| tasks, | |
| employees = [], // Default to empty array if not provided | |
| 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 tasks for each day | |
| // Since tasks can span multiple days, we need to check intervals | |
| const getTasksForDate = (date: Date) => { | |
| return tasks.filter(task => { | |
| if (!task.stateDate) return false; | |
| const taskStart = startOfDay(parseISO(task.stateDate)); | |
| let taskEnd = task.endDate ? endOfDay(parseISO(task.endDate)) : taskStart; | |
| // Handle invalid date ranges where end date is before start date | |
| if (taskEnd < taskStart) { | |
| taskEnd = taskStart; | |
| } | |
| try { | |
| return isWithinInterval(date, { start: taskStart, end: taskEnd }); | |
| } catch (e) { | |
| console.error("Error checking date interval for task:", task.id, e); | |
| return false; | |
| } | |
| }); | |
| }; | |
| const getPriorityColor = (priority?: string) => { | |
| switch (priority?.toLowerCase()) { | |
| case 'high': return 'bg-red-100 text-red-700 border-red-200'; | |
| case 'medium': return 'bg-orange-100 text-orange-700 border-orange-200'; | |
| case 'low': return 'bg-green-100 text-green-700 border-green-200'; | |
| default: return 'bg-gray-100 text-gray-700 border-gray-200'; | |
| } | |
| }; | |
| const getStatusColor = (status: string) => { | |
| switch (status) { | |
| case "New": return "bg-blue-500"; | |
| case "In Progress": return "bg-orange-500"; | |
| case "In Review": return "bg-purple-500"; | |
| case "Completed": return "bg-green-500"; | |
| case "Closed": return "bg-gray-500"; | |
| default: return "bg-slate-500"; | |
| } | |
| }; | |
| const getEmployeeDetails = (id: number | null) => { | |
| if (!id) return null; | |
| return employees.find(e => e.id === id); | |
| }; | |
| 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"> | |
| Manage tasks schedule | |
| </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 dayTasks = getTasksForDate(day); | |
| const isCurrentMonth = isSameMonth(day, currentMonth); | |
| const isDayToday = isToday(day); | |
| return ( | |
| <div | |
| key={day.toString()} | |
| className={cn( | |
| "bg-white p-2 min-h-[120px] 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> | |
| {dayTasks.length > 0 && ( | |
| <Badge variant="secondary" className="text-[10px] h-5 px-1.5"> | |
| {dayTasks.length} | |
| </Badge> | |
| )} | |
| </div> | |
| <div className="flex-1 flex flex-col gap-1 overflow-y-auto max-h-[140px] custom-scrollbar"> | |
| {dayTasks.map((task, idx) => { | |
| const assignee = getEmployeeDetails(task.assignedTo); | |
| return ( | |
| <TooltipProvider key={`${task.id}-${dayIdx}`}> | |
| <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 bg-gradient-to-l from-transparent via-transparent to-transparent flex items-center gap-1.5" | |
| )}> | |
| <div className={cn("w-1.5 h-1.5 rounded-full flex-shrink-0", getStatusColor(task.status))} /> | |
| <span className={cn("font-medium truncate flex-1", task.status === 'Completed' && "line-through text-gray-400")}> | |
| {task.title} | |
| </span> | |
| {assignee && ( | |
| <div className="h-4 w-4 bg-gray-100 rounded-full flex items-center justify-center text-[8px] flex-shrink-0" title={`${assignee.firstName} ${assignee.lastName}`}> | |
| {assignee.firstName.charAt(0)}{assignee.lastName.charAt(0)} | |
| </div> | |
| )} | |
| </div> | |
| </TooltipTrigger> | |
| <TooltipContent side="right" className="p-0 border-none shadow-xl z-50"> | |
| <div className="w-64 bg-white rounded-lg border border-gray-200 overflow-hidden"> | |
| <div className={cn("px-3 py-2 border-b border-gray-200 flex justify-between items-center", getPriorityColor(task.type).replace('text-', 'bg-').replace('border-', 'border-transparent text-white'))}> | |
| <span className="font-semibold text-xs text-gray-700">{task.type} Priority</span> | |
| <Badge variant="outline" className="text-[10px] bg-white text-black border-none"> | |
| {task.status} | |
| </Badge> | |
| </div> | |
| <div className="p-3"> | |
| <h4 className="font-bold text-sm text-gray-800 mb-1 leading-tight">{task.title}</h4> | |
| <p className="text-xs text-gray-500 mb-2">#{task.id}</p> | |
| {assignee && ( | |
| <div className="flex items-center gap-2 mb-2"> | |
| <div className="h-5 w-5 bg-indigo-100 rounded-full flex items-center justify-center text-[9px] text-indigo-700 font-bold"> | |
| {assignee.firstName.charAt(0)}{assignee.lastName.charAt(0)} | |
| </div> | |
| <span className="text-xs text-gray-700">{assignee.firstName} {assignee.lastName}</span> | |
| </div> | |
| )} | |
| <div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-gray-100"> | |
| <div className="text-xs"> | |
| <span className="text-gray-400 block mb-0.5">Start</span> | |
| <span className="text-gray-700 font-medium">{task.stateDate ? format(parseISO(task.stateDate), 'MMM d') : '-'}</span> | |
| </div> | |
| <div className="text-xs"> | |
| <span className="text-gray-400 block mb-0.5">Due</span> | |
| <span className="text-gray-700 font-medium">{task.endDate ? format(parseISO(task.endDate), 'MMM d') : '-'}</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </TooltipContent> | |
| </Tooltip> | |
| </TooltipProvider> | |
| ) | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </div> | |
| ); | |
| }; | |