pmtool / src /components /time-logs /MonthlyCalendar.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
12.7 kB
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>
);
};