Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from "react"; | |
| import { Todo } from "@/types"; | |
| import { Card, CardContent, CardHeader } from "@/components/ui/card"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| Pencil, | |
| Trash2, | |
| CheckCircle2, | |
| User, | |
| Briefcase, | |
| CalendarClock, | |
| Clock, | |
| ChevronRight, | |
| CircleCheck, | |
| Circle, | |
| CheckCircle | |
| } from "lucide-react"; | |
| import { format, isAfter, isPast, formatDistance } from "date-fns"; | |
| import { employeeService, projectService } from "@/lib/api"; | |
| import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; | |
| import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import { cn } from "@/lib/utils"; | |
| type TodoItemProps = { | |
| todo: Todo; | |
| onEdit?: (todo: Todo) => void; | |
| onDelete?: (id: number) => void; | |
| onStatusChange: (id: number, status: Todo['status']) => void; | |
| }; | |
| const getPriorityColor = (priority: Todo['priority']) => { | |
| switch (priority) { | |
| case 'Low': | |
| return { | |
| bg: 'bg-white', | |
| border: 'border-l-4 border-emerald-400', | |
| text: 'text-emerald-700', | |
| badge: 'bg-emerald-50 text-emerald-700 border border-emerald-200' | |
| }; | |
| case 'Medium': | |
| return { | |
| bg: 'bg-white', | |
| border: 'border-l-4 border-amber-400', | |
| text: 'text-amber-700', | |
| badge: 'bg-amber-50 text-amber-700 border border-amber-200' | |
| }; | |
| case 'High': | |
| return { | |
| bg: 'bg-white', | |
| border: 'border-l-4 border-rose-500', | |
| text: 'text-rose-700', | |
| badge: 'bg-rose-50 text-rose-700 border border-rose-200' | |
| }; | |
| default: | |
| return { | |
| bg: 'bg-white', | |
| border: 'border-l-4 border-slate-400', | |
| text: 'text-slate-700', | |
| badge: 'bg-slate-50 text-slate-700 border border-slate-200' | |
| }; | |
| } | |
| }; | |
| const getStatusColor = (status: Todo['status']) => { | |
| switch (status) { | |
| case 'Pending': | |
| return { | |
| bg: 'bg-slate-50', | |
| text: 'text-slate-700', | |
| badge: 'bg-white text-slate-700 border border-slate-200', | |
| icon: <Clock className="h-3.5 w-3.5 mr-1 text-slate-500" /> | |
| }; | |
| case 'In Progress': | |
| return { | |
| bg: 'bg-blue-50', | |
| text: 'text-blue-700', | |
| badge: 'bg-white text-blue-700 border border-blue-200', | |
| icon: <Progress className="h-1.5 w-14 mr-1" value={50} /> | |
| }; | |
| case 'Completed': | |
| return { | |
| bg: 'bg-green-50', | |
| text: 'text-green-700', | |
| badge: 'bg-white text-green-700 border border-green-200', | |
| icon: <CircleCheck className="h-3.5 w-3.5 mr-1 text-green-500" /> | |
| }; | |
| default: | |
| return { | |
| bg: 'bg-white', | |
| text: 'text-slate-700', | |
| badge: 'bg-white text-slate-700 border border-slate-200', | |
| icon: null | |
| }; | |
| } | |
| }; | |
| const getStatusProgress = (status: Todo['status']): number => { | |
| switch (status) { | |
| case 'Pending': return 0; | |
| case 'In Progress': return 50; | |
| case 'Completed': return 100; | |
| default: return 0; | |
| } | |
| }; | |
| export function TodoItem({ todo, onEdit, onDelete, onStatusChange }: TodoItemProps) { | |
| const [assignee, setAssignee] = useState<{ name: string; email: string } | null>(null); | |
| const [project, setProject] = useState<{ name: string } | null>(null); | |
| const [isLoadingAssignee, setIsLoadingAssignee] = useState(false); | |
| const [isLoadingProject, setIsLoadingProject] = useState(false); | |
| const [isExpanded, setIsExpanded] = useState(false); | |
| const [isHovered, setIsHovered] = useState(false); | |
| const priorityStyles = getPriorityColor(todo.priority); | |
| const statusStyles = getStatusColor(todo.status); | |
| const dueDate = new Date(todo.dueDate); | |
| const isPastDue = isPast(dueDate) && todo.status !== 'Completed'; | |
| const statusProgress = getStatusProgress(todo.status); | |
| const isCompleted = todo.status === 'Completed'; | |
| useEffect(() => { | |
| const fetchAssignee = async () => { | |
| if (!todo.assigneeId) return; | |
| setIsLoadingAssignee(true); | |
| try { | |
| const response = await employeeService.getById(todo.assigneeId); | |
| if (response && response.id) { | |
| setAssignee({ | |
| name: `${response.firstName} ${response.lastName}`, | |
| email: response.email | |
| }); | |
| } | |
| } catch (error) { | |
| console.error("Failed to fetch assignee details", error); | |
| } finally { | |
| setIsLoadingAssignee(false); | |
| } | |
| }; | |
| fetchAssignee(); | |
| }, [todo.assigneeId]); | |
| useEffect(() => { | |
| const fetchProject = async () => { | |
| if (!todo.projectId) return; | |
| setIsLoadingProject(true); | |
| try { | |
| const response = await projectService.getById(todo.projectId); | |
| if (response && response.id) { | |
| setProject({ | |
| name: response.projectName | |
| }); | |
| } | |
| } catch (error) { | |
| console.error("Failed to fetch project details", error); | |
| } finally { | |
| setIsLoadingProject(false); | |
| } | |
| }; | |
| fetchProject(); | |
| }, [todo.projectId]); | |
| const handleStatusChange = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| let newStatus: Todo['status'] = 'Pending'; | |
| switch (todo.status) { | |
| case 'Pending': | |
| newStatus = 'In Progress'; | |
| break; | |
| case 'In Progress': | |
| newStatus = 'Completed'; | |
| break; | |
| case 'Completed': | |
| newStatus = 'Pending'; | |
| break; | |
| } | |
| onStatusChange(todo.id, newStatus); | |
| }; | |
| const toggleComplete = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| const newStatus = isCompleted ? 'Pending' : 'Completed'; | |
| onStatusChange(todo.id, newStatus); | |
| }; | |
| const getInitials = (name: string) => { | |
| return name.split(' ') | |
| .map(part => part.charAt(0)) | |
| .join('') | |
| .toUpperCase(); | |
| }; | |
| const toggleExpand = () => { | |
| setIsExpanded(!isExpanded); | |
| }; | |
| return ( | |
| <Card | |
| className={cn( | |
| "transition-all duration-200 hover:shadow-lg", | |
| priorityStyles.bg, | |
| priorityStyles.border, | |
| "border border-slate-200 rounded-xl", | |
| isCompleted && "opacity-90 bg-slate-50", | |
| "shadow-sm overflow-hidden" | |
| )} | |
| > | |
| <div className="cursor-pointer group"> | |
| <CardHeader className="p-3 sm:p-4 pb-2"> | |
| {/* Mobile-first layout */} | |
| <div className="flex items-start gap-3"> | |
| {/* Checkbox */} | |
| <div | |
| className="mt-1 flex-shrink-0 relative" | |
| onClick={toggleComplete} | |
| onMouseEnter={() => setIsHovered(true)} | |
| onMouseLeave={() => setIsHovered(false)} | |
| > | |
| <div className={cn( | |
| "size-5 rounded-full flex items-center justify-center transition-colors", | |
| isCompleted ? | |
| "text-blue-600 bg-blue-50 border border-blue-300" : | |
| "text-gray-400 border border-gray-300 bg-white", | |
| isHovered && !isCompleted && "border-blue-500 text-blue-500", | |
| isHovered && isCompleted && "bg-blue-100" | |
| )}> | |
| {isCompleted ? ( | |
| <CheckCircle className="size-5 fill-blue-600 text-white" /> | |
| ) : ( | |
| <Circle className="size-5" /> | |
| )} | |
| </div> | |
| </div> | |
| {/* Main content */} | |
| <div className="flex-1 min-w-0" onClick={toggleExpand}> | |
| {/* Title and Priority - Mobile responsive */} | |
| <div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-2 mb-2"> | |
| <h3 className={cn( | |
| "text-sm sm:text-base font-medium flex-1", | |
| priorityStyles.text, | |
| isCompleted && "line-through text-gray-500" | |
| )}> | |
| {todo.title} | |
| </h3> | |
| <Badge className={cn( | |
| "font-normal text-xs rounded-full px-2 py-0.5 self-start sm:self-center", | |
| priorityStyles.badge | |
| )}> | |
| {todo.priority} | |
| </Badge> | |
| </div> | |
| {/* Description */} | |
| <p className={cn( | |
| "text-xs sm:text-sm text-gray-500 line-clamp-2 mb-2", | |
| isCompleted && "line-through" | |
| )}> | |
| {todo.description} | |
| </p> | |
| {/* Status and Date - Mobile responsive */} | |
| <div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4"> | |
| <TooltipProvider> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <Badge className={cn( | |
| "font-normal flex items-center rounded-full px-2 py-1 text-xs w-fit", | |
| statusStyles.badge | |
| )}> | |
| {statusStyles.icon} | |
| <span className="hidden sm:inline">{todo.status}</span> | |
| <span className="sm:hidden"> | |
| {todo.status === 'In Progress' ? 'In Prog.' : todo.status} | |
| </span> | |
| </Badge> | |
| </TooltipTrigger> | |
| <TooltipContent> | |
| <p>Status: {todo.status}</p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </TooltipProvider> | |
| <div className={cn( | |
| "flex items-center text-xs", | |
| isPastDue ? "text-rose-600" : "text-gray-500" | |
| )}> | |
| <CalendarClock className="h-3 w-3 mr-1" /> | |
| <span className="hidden sm:inline">{format(dueDate, 'MMM dd, yyyy')}</span> | |
| <span className="sm:hidden">{format(dueDate, 'MMM dd')}</span> | |
| {isPastDue && ( | |
| <span className="ml-1 font-medium text-rose-600"> | |
| <span className="hidden sm:inline">(Overdue)</span> | |
| <span className="sm:hidden">!</span> | |
| </span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| {/* Progress bar */} | |
| <div className="px-3 sm:px-4 pb-2" onClick={toggleExpand}> | |
| <Progress | |
| value={statusProgress} | |
| className={cn( | |
| "h-1.5", | |
| todo.status === 'Pending' ? "bg-slate-100" : | |
| todo.status === 'In Progress' ? "bg-blue-100" : "bg-green-100" | |
| )} | |
| /> | |
| </div> | |
| </div> | |
| {isExpanded && ( | |
| <CardContent className="p-3 sm:p-4 pt-2 bg-slate-50 border-t border-slate-200"> | |
| {/* Full description */} | |
| <div className="text-xs sm:text-sm text-gray-600 mb-4"> | |
| {todo.description} | |
| </div> | |
| {/* Details grid - Mobile responsive */} | |
| <div className="grid grid-cols-1 gap-3 text-xs sm:text-sm mb-4"> | |
| <div className="flex items-center text-gray-600"> | |
| <CalendarClock className="h-4 w-4 mr-2 text-gray-500 flex-shrink-0" /> | |
| <span className="font-medium mr-1">Due:</span> | |
| <span>{format(dueDate, 'PPP')}</span> | |
| </div> | |
| {todo.assigneeId && ( | |
| <div className="flex items-center text-gray-600"> | |
| <User className="h-4 w-4 mr-2 text-gray-500 flex-shrink-0" /> | |
| <span className="font-medium mr-1">Assigned to:</span> | |
| {isLoadingAssignee ? ( | |
| <span className="italic">Loading...</span> | |
| ) : assignee ? ( | |
| <div className="flex items-center"> | |
| <Avatar className="h-5 w-5 mr-2"> | |
| <AvatarFallback className="text-[10px] bg-blue-100 text-blue-700"> | |
| {getInitials(assignee.name)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="font-medium">{assignee.name}</span> | |
| </div> | |
| ) : ( | |
| <span>Employee {todo.assigneeId}</span> | |
| )} | |
| </div> | |
| )} | |
| {todo.projectId && ( | |
| <div className="flex items-center text-gray-600"> | |
| <Briefcase className="h-4 w-4 mr-2 text-gray-500 flex-shrink-0" /> | |
| <span className="font-medium mr-1">Project:</span> | |
| {isLoadingProject ? ( | |
| <span className="italic">Loading...</span> | |
| ) : project ? ( | |
| <span className="font-medium">{project.name}</span> | |
| ) : ( | |
| <span>Project {todo.projectId}</span> | |
| )} | |
| </div> | |
| )} | |
| <div className="flex items-center text-gray-600"> | |
| <Clock className="h-4 w-4 mr-2 text-gray-500 flex-shrink-0" /> | |
| <span className="font-medium mr-1">Created:</span> | |
| <span>{format(new Date(todo.createdAt || Date.now()), 'MMM dd, yyyy')}</span> | |
| </div> | |
| </div> | |
| {/* Action buttons - Mobile responsive */} | |
| <div className="flex flex-col sm:flex-row gap-2 sm:justify-between sm:items-center pt-3 border-t border-slate-200"> | |
| <div className="flex flex-wrap gap-2"> | |
| {onEdit && ( | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onEdit(todo); | |
| }} | |
| className="bg-white hover:bg-gray-50 border-gray-200 text-gray-700 flex-1 sm:flex-none" | |
| > | |
| <Pencil className="h-3.5 w-3.5 mr-1" /> | |
| <span className="hidden sm:inline">Edit</span> | |
| <span className="sm:hidden">Edit</span> | |
| </Button> | |
| )} | |
| {onDelete && ( | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className="text-rose-600 hover:text-rose-700 hover:bg-rose-50 bg-white border-rose-200 flex-1 sm:flex-none" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| onDelete(todo.id); | |
| }} | |
| > | |
| <Trash2 className="h-3.5 w-3.5 mr-1" /> | |
| <span className="hidden sm:inline">Delete</span> | |
| <span className="sm:hidden">Delete</span> | |
| </Button> | |
| )} | |
| </div> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| className={cn( | |
| "bg-white border w-full sm:w-auto", | |
| todo.status === 'Pending' ? "text-blue-600 hover:text-blue-700 hover:bg-blue-50 border-blue-200" : | |
| todo.status === 'In Progress' ? "text-blue-600 hover:text-blue-700 hover:bg-blue-50 border-blue-200" : | |
| "text-green-600 hover:text-green-700 hover:bg-green-50 border-green-200" | |
| )} | |
| onClick={handleStatusChange} | |
| > | |
| <CheckCircle2 className="h-3.5 w-3.5 mr-1" /> | |
| {todo.status === 'Pending' ? 'Start Task' : | |
| todo.status === 'In Progress' ? 'Mark Complete' : 'Reopen Task'} | |
| </Button> | |
| </div> | |
| </CardContent> | |
| )} | |
| </Card> | |
| ); | |
| } |