Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from "react"; | |
| import { Todo, TodoCreateRequest, TodoUpdateRequest } from "@/types"; | |
| import { TodoItem } from "./TodoItem"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import CustomDialog from "@/components/CustomDialog"; | |
| import { TodoForm } from "./TodoForm"; | |
| import { Plus, Search, SlidersHorizontal, Calendar, Check, ChevronDown, CalendarIcon, X, ListIcon, Kanban, ChevronRight, CheckCircle, Filter } from "lucide-react"; | |
| import { todoService } from "@/services/todoService"; | |
| import { useToast } from "@/components/ui/use-toast"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuLabel, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { cn } from "@/lib/utils"; | |
| import { Calendar as CalendarComponent } from "@/components/ui/calendar"; | |
| import { format, isAfter, isBefore, isSameDay, isWithinInterval, startOfDay, endOfDay, addDays, subDays, isPast } from "date-fns"; | |
| import { Loader2 } from "lucide-react"; | |
| import { ClipboardX } from "lucide-react"; | |
| const sortOptions = [ | |
| { value: "title-asc", label: "Title (A-Z)" }, | |
| { value: "title-desc", label: "Title (Z-A)" }, | |
| { value: "dueDate-asc", label: "Due Date (Ascending)" }, | |
| { value: "dueDate-desc", label: "Due Date (Descending)" }, | |
| { value: "priority-desc", label: "Priority (High to Low)" }, | |
| { value: "priority-asc", label: "Priority (Low to High)" }, | |
| ]; | |
| const dateFilterOptions = [ | |
| { value: "all", label: "All Dates" }, | |
| { value: "today", label: "Due Today" }, | |
| { value: "tomorrow", label: "Due Tomorrow" }, | |
| { value: "thisWeek", label: "Due This Week" }, | |
| { value: "overdue", label: "Overdue" }, | |
| { value: "custom", label: "Custom Range" }, | |
| ]; | |
| // Custom Date Filter Dropdown Component | |
| function CustomDateFilterDropdown({ onChange, value }) { | |
| const [open, setOpen] = useState(false); | |
| const [selectedOption, setSelectedOption] = useState(dateFilterOptions.find(opt => opt.value === value) || dateFilterOptions[0]); | |
| const [startDate, setStartDate] = useState<Date | undefined>(undefined); | |
| const [endDate, setEndDate] = useState<Date | undefined>(undefined); | |
| const [showCalendar, setShowCalendar] = useState(false); | |
| const dropdownRef = React.useRef<HTMLDivElement>(null); | |
| // Handle outside clicks to close the dropdown | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| setShowCalendar(false); | |
| } | |
| }; | |
| // Use mousedown instead of click to ensure this fires before any other click handlers | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => document.removeEventListener('mousedown', handleOutsideClick); | |
| }, [open]); | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (option: typeof dateFilterOptions[0], e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setSelectedOption(option); | |
| if (option.value !== 'custom') { | |
| onChange({ | |
| type: option.value, | |
| startDate: undefined, | |
| endDate: undefined | |
| }); | |
| setStartDate(undefined); | |
| setEndDate(undefined); | |
| setShowCalendar(false); | |
| setOpen(false); | |
| } else { | |
| setShowCalendar(true); | |
| } | |
| }; | |
| const handleDateSelect = (date: Date | undefined) => { | |
| if (!date) return; | |
| if (!startDate) { | |
| setStartDate(date); | |
| } else if (!endDate && isAfter(date, startDate)) { | |
| setEndDate(date); | |
| // After selecting both dates, apply the filter but don't close automatically | |
| onChange({ | |
| type: 'custom', | |
| startDate, | |
| endDate: date | |
| }); | |
| } else { | |
| // Reset and start over if second date is before first | |
| setStartDate(date); | |
| setEndDate(undefined); | |
| } | |
| }; | |
| const handleClearFilter = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setSelectedOption(dateFilterOptions[0]); | |
| setStartDate(undefined); | |
| setEndDate(undefined); | |
| onChange({ | |
| type: 'all', | |
| startDate: undefined, | |
| endDate: undefined | |
| }); | |
| setOpen(false); | |
| }; | |
| let displayText = selectedOption.label; | |
| if (selectedOption.value === 'custom' && startDate) { | |
| displayText = endDate | |
| ? `${format(startDate, 'MMM d')} - ${format(endDate, 'MMM d')}` | |
| : `From ${format(startDate, 'MMM d')}`; | |
| } | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <div | |
| className={cn( | |
| "flex h-10 items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer", | |
| open && "ring-2 ring-ring ring-offset-2" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| <CalendarIcon className="h-4 w-4 mr-2" /> | |
| <span>{displayText}</span> | |
| </div> | |
| <div className="flex items-center"> | |
| {selectedOption.value !== 'all' && ( | |
| <X | |
| className="h-4 w-4 mr-1 hover:text-destructive" | |
| onClick={handleClearFilter} | |
| /> | |
| )} | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </div> | |
| </div> | |
| {open && ( | |
| <div | |
| className="absolute z-[100] mt-1 w-auto min-w-[280px] overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md right-0" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| {!showCalendar ? ( | |
| <div className="p-1"> | |
| {dateFilterOptions.map((option) => ( | |
| <div | |
| key={option.value} | |
| className={cn( | |
| "flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| selectedOption.value === option.value && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => handleOptionClick(option, e)} | |
| > | |
| <span>{option.label}</span> | |
| {selectedOption.value === option.value && <Check className="h-4 w-4" />} | |
| </div> | |
| ))} | |
| </div> | |
| ) : ( | |
| <div className="p-3"> | |
| <div className="mb-2 text-sm font-medium"> | |
| {!startDate ? 'Select start date' : !endDate ? 'Select end date' : 'Date range selected'} | |
| </div> | |
| <CalendarComponent | |
| mode="single" | |
| selected={endDate || startDate} | |
| onSelect={handleDateSelect} | |
| initialFocus | |
| /> | |
| {startDate && !endDate && ( | |
| <div className="mt-2 text-sm text-muted-foreground"> | |
| Start: {format(startDate, 'PPP')} | |
| </div> | |
| )} | |
| {startDate && endDate && ( | |
| <div className="mt-2 flex justify-between"> | |
| <Button | |
| size="sm" | |
| variant="outline" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setStartDate(undefined); | |
| setEndDate(undefined); | |
| }} | |
| type="button" | |
| > | |
| Reset | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| onChange({ | |
| type: 'custom', | |
| startDate, | |
| endDate | |
| }); | |
| setOpen(false); | |
| setShowCalendar(false); | |
| }} | |
| type="button" | |
| > | |
| Apply | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Filter Dropdown Component | |
| function CustomFilterDropdown({ | |
| statusFilter, | |
| priorityFilter, | |
| setStatusFilter, | |
| setPriorityFilter, | |
| clearAllFilters, | |
| activeFiltersCount | |
| }) { | |
| const [open, setOpen] = useState(false); | |
| const dropdownRef = React.useRef<HTMLDivElement>(null); | |
| // Handle outside clicks to close the dropdown | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| } | |
| }; | |
| // Use mousedown instead of click to ensure this fires before any other click handlers | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => document.removeEventListener('mousedown', handleOutsideClick); | |
| }, [open]); | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| className="relative border-blue-200" | |
| onClick={handleButtonClick} | |
| > | |
| <SlidersHorizontal className="h-4 w-4 text-blue-600" /> | |
| {activeFiltersCount > 0 && ( | |
| <span className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-blue-600 text-[10px] flex items-center justify-center text-white font-medium"> | |
| {activeFiltersCount} | |
| </span> | |
| )} | |
| </Button> | |
| {open && ( | |
| <div | |
| className="absolute z-[100] mt-1 w-auto min-w-[280px] overflow-auto rounded-md border bg-popover p-4 text-popover-foreground shadow-md right-0" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <div className="flex flex-col space-y-1.5 mb-3"> | |
| <h2 className="text-sm font-semibold text-blue-800">Filter Todos</h2> | |
| <p className="text-xs text-muted-foreground">Select filters to narrow down your todo list</p> | |
| </div> | |
| <div className="grid gap-4"> | |
| <div> | |
| <h3 className="text-xs font-medium mb-2 text-gray-600">Status</h3> | |
| <div className="grid grid-cols-2 gap-1"> | |
| <Button | |
| type="button" | |
| variant={statusFilter === "All" ? "default" : "outline"} | |
| size="sm" | |
| className={statusFilter === "All" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setStatusFilter("All")} | |
| > | |
| All | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={statusFilter === "Pending" ? "default" : "outline"} | |
| size="sm" | |
| className={statusFilter === "Pending" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setStatusFilter("Pending")} | |
| > | |
| Pending | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={statusFilter === "In Progress" ? "default" : "outline"} | |
| size="sm" | |
| className={statusFilter === "In Progress" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setStatusFilter("In Progress")} | |
| > | |
| In Progress | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={statusFilter === "Completed" ? "default" : "outline"} | |
| size="sm" | |
| className={statusFilter === "Completed" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setStatusFilter("Completed")} | |
| > | |
| Completed | |
| </Button> | |
| </div> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium mb-2 text-gray-600">Priority</h3> | |
| <div className="grid grid-cols-2 gap-1"> | |
| <Button | |
| type="button" | |
| variant={priorityFilter === "All" ? "default" : "outline"} | |
| size="sm" | |
| className={priorityFilter === "All" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setPriorityFilter("All")} | |
| > | |
| All | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={priorityFilter === "Low" ? "default" : "outline"} | |
| size="sm" | |
| className={priorityFilter === "Low" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setPriorityFilter("Low")} | |
| > | |
| <Badge className="bg-emerald-100 text-emerald-800 mr-2 border-emerald-200">Low</Badge> | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={priorityFilter === "Medium" ? "default" : "outline"} | |
| size="sm" | |
| className={priorityFilter === "Medium" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setPriorityFilter("Medium")} | |
| > | |
| <Badge className="bg-amber-100 text-amber-800 mr-2 border-amber-200">Medium</Badge> | |
| </Button> | |
| <Button | |
| type="button" | |
| variant={priorityFilter === "High" ? "default" : "outline"} | |
| size="sm" | |
| className={priorityFilter === "High" ? "bg-blue-600 hover:bg-blue-700" : ""} | |
| onClick={() => setPriorityFilter("High")} | |
| > | |
| <Badge className="bg-rose-100 text-rose-800 mr-2 border-rose-200">High</Badge> | |
| </Button> | |
| </div> | |
| </div> | |
| {activeFiltersCount > 0 && ( | |
| <Button | |
| type="button" | |
| variant="destructive" | |
| size="sm" | |
| onClick={() => { | |
| clearAllFilters(); | |
| setOpen(false); | |
| }} | |
| className="mt-2" | |
| > | |
| Clear All Filters | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Sort By Dropdown Component | |
| function CustomSortDropdown({ | |
| value, | |
| onChange | |
| }) { | |
| const [open, setOpen] = useState(false); | |
| const [selectedOption, setSelectedOption] = useState(sortOptions.find(opt => opt.value === value) || sortOptions[0]); | |
| const dropdownRef = React.useRef<HTMLDivElement>(null); | |
| // Handle outside clicks to close the dropdown | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| } | |
| }; | |
| // Use mousedown instead of click to ensure this fires before any other click handlers | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => document.removeEventListener('mousedown', handleOutsideClick); | |
| }, [open]); | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (option: typeof sortOptions[0], e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setSelectedOption(option); | |
| onChange(option.value); | |
| setOpen(false); | |
| }; | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <div | |
| className={cn( | |
| "flex h-10 items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer w-[180px]", | |
| open && "ring-2 ring-ring ring-offset-2" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <span>{selectedOption.label}</span> | |
| <ChevronDown className="h-4 w-4 opacity-50" /> | |
| </div> | |
| {open && ( | |
| <div | |
| className="absolute z-[100] mt-1 w-auto min-w-[220px] overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md right-0" | |
| onClick={(e) => e.stopPropagation()} | |
| > | |
| <div className="p-1"> | |
| {sortOptions.map((option) => ( | |
| <div | |
| key={option.value} | |
| className={cn( | |
| "flex cursor-pointer items-center justify-between rounded-sm px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| selectedOption.value === option.value && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => handleOptionClick(option, e)} | |
| > | |
| <span>{option.label}</span> | |
| {selectedOption.value === option.value && <Check className="h-4 w-4" />} | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| type TodoListProps = { | |
| projectId?: number; | |
| assigneeId?: number; | |
| }; | |
| export function TodoList({ projectId, assigneeId }: TodoListProps) { | |
| const { userData } = useAuth(); | |
| const [todos, setTodos] = useState<Todo[]>([]); | |
| const [filteredTodos, setFilteredTodos] = useState<Todo[]>([]); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| const [sortBy, setSortBy] = useState("dueDate-asc"); | |
| const [statusFilter, setStatusFilter] = useState<Todo["status"] | "All">("All"); | |
| const [priorityFilter, setPriorityFilter] = useState<Todo["priority"] | "All">("All"); | |
| const [dateFilter, setDateFilter] = useState({ type: 'all', startDate: undefined, endDate: undefined }); | |
| const [isFormOpen, setIsFormOpen] = useState(false); | |
| const [editingTodo, setEditingTodo] = useState<Todo | undefined>(undefined); | |
| const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list'); | |
| const [showCompleted, setShowCompleted] = useState(false); | |
| const [showFilters, setShowFilters] = useState(false); | |
| const { toast } = useToast(); | |
| // Define roles that can add/edit todos | |
| const isAdmin = userData?.roleId === 21; // Admin | |
| // const canManageTodos = isAdmin || | |
| // userData?.roleName === 'Project Manager' || | |
| // userData?.roleName === 'Tech Lead'; | |
| const canManageTodos = true; | |
| // Fetch todos based on props and user role | |
| const fetchTodos = async () => { | |
| setIsLoading(true); | |
| try { | |
| let response; | |
| // If projectId is provided, fetch todos for that project | |
| if (projectId) { | |
| response = await todoService.getTodosByProject(projectId); | |
| } | |
| // If assigneeId is manually provided via props, use that | |
| else if (assigneeId) { | |
| response = await todoService.getTodosByAssignee(assigneeId); | |
| } | |
| // If user is not admin and no specific assigneeId is provided, show user's own todos | |
| else if (!isAdmin && userData?.employeeId) { | |
| response = await todoService.getTodosByAssignee(userData.employeeId); | |
| } | |
| // Admin with no filters gets all todos | |
| else { | |
| response = await todoService.getAllTodos(); | |
| } | |
| if (response.success) { | |
| setTodos(response.data); | |
| } else { | |
| toast({ | |
| title: "Error", | |
| description: response.message || "Failed to fetch todos", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error", | |
| description: "An unexpected error occurred", | |
| variant: "destructive", | |
| }); | |
| console.error("Error fetching todos:", error); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| useEffect(() => { | |
| fetchTodos(); | |
| }, [projectId, assigneeId, userData?.employeeId]); | |
| // Filter and sort todos | |
| useEffect(() => { | |
| let result = [...todos]; | |
| // Apply search filter | |
| if (searchTerm) { | |
| const term = searchTerm.toLowerCase(); | |
| result = result.filter( | |
| (todo) => | |
| todo.title.toLowerCase().includes(term) || | |
| todo.description.toLowerCase().includes(term) | |
| ); | |
| } | |
| // Apply status filter | |
| if (statusFilter !== "All") { | |
| result = result.filter((todo) => todo.status === statusFilter); | |
| } | |
| // Apply priority filter | |
| if (priorityFilter !== "All") { | |
| result = result.filter((todo) => todo.priority === priorityFilter); | |
| } | |
| // Apply date filter | |
| if (dateFilter.type !== 'all') { | |
| const today = startOfDay(new Date()); | |
| const tomorrow = startOfDay(addDays(new Date(), 1)); | |
| const nextWeek = startOfDay(addDays(new Date(), 7)); | |
| result = result.filter((todo) => { | |
| const dueDate = startOfDay(new Date(todo.dueDate)); | |
| switch (dateFilter.type) { | |
| case 'today': | |
| return isSameDay(dueDate, today); | |
| case 'tomorrow': | |
| return isSameDay(dueDate, tomorrow); | |
| case 'thisWeek': | |
| return isAfter(dueDate, today) && isBefore(dueDate, nextWeek); | |
| case 'overdue': | |
| return isBefore(dueDate, today); | |
| case 'custom': | |
| if (dateFilter.startDate && dateFilter.endDate) { | |
| return isWithinInterval(dueDate, { | |
| start: startOfDay(dateFilter.startDate), | |
| end: endOfDay(dateFilter.endDate) | |
| }); | |
| } | |
| return true; | |
| default: | |
| return true; | |
| } | |
| }); | |
| } | |
| // Apply sorting | |
| const [sortField, sortDirection] = sortBy.split("-"); | |
| result.sort((a, b) => { | |
| if (sortField === "dueDate") { | |
| const dateA = new Date(a.dueDate).getTime(); | |
| const dateB = new Date(b.dueDate).getTime(); | |
| return sortDirection === "asc" ? dateA - dateB : dateB - dateA; | |
| } else if (sortField === "priority") { | |
| const priorityOrder = { Low: 1, Medium: 2, High: 3 }; | |
| const priorityA = priorityOrder[a.priority as keyof typeof priorityOrder]; | |
| const priorityB = priorityOrder[b.priority as keyof typeof priorityOrder]; | |
| return sortDirection === "asc" | |
| ? priorityA - priorityB | |
| : priorityB - priorityA; | |
| } else if (sortField === "title") { | |
| return sortDirection === "asc" | |
| ? a.title.localeCompare(b.title) | |
| : b.title.localeCompare(a.title); | |
| } | |
| return 0; | |
| }); | |
| setFilteredTodos(result); | |
| }, [todos, searchTerm, sortBy, statusFilter, priorityFilter, dateFilter]); | |
| // Handle todo creation | |
| const handleCreateTodo = async (data: TodoCreateRequest) => { | |
| setIsLoading(true); | |
| try { | |
| const response = await todoService.createTodo(data); | |
| if (response.success) { | |
| setTodos((prev) => [...prev, response.data]); | |
| setIsFormOpen(false); | |
| toast({ | |
| title: "Success", | |
| description: "Todo created successfully", | |
| }); | |
| } else { | |
| toast({ | |
| title: "Error", | |
| description: response.message || "Failed to create todo", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error", | |
| description: "An unexpected error occurred", | |
| variant: "destructive", | |
| }); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Handle todo update | |
| const handleUpdateTodo = async (data: TodoUpdateRequest) => { | |
| setIsLoading(true); | |
| try { | |
| const response = await todoService.updateTodo(data); | |
| if (response.success) { | |
| setTodos((prev) => | |
| prev.map((todo) => (todo.id === data.id ? response.data : todo)) | |
| ); | |
| setIsFormOpen(false); | |
| setEditingTodo(undefined); | |
| toast({ | |
| title: "Success", | |
| description: "Todo updated successfully", | |
| }); | |
| } else { | |
| toast({ | |
| title: "Error", | |
| description: response.message || "Failed to update todo", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error", | |
| description: "An unexpected error occurred", | |
| variant: "destructive", | |
| }); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Handle todo deletion | |
| const handleDeleteTodo = async (id: number) => { | |
| if (window.confirm("Are you sure you want to delete this todo?")) { | |
| setIsLoading(true); | |
| try { | |
| const response = await todoService.deleteTodo(id); | |
| if (response.success) { | |
| setTodos((prev) => prev.filter((todo) => todo.id !== id)); | |
| toast({ | |
| title: "Success", | |
| description: "Todo deleted successfully", | |
| }); | |
| } else { | |
| toast({ | |
| title: "Error", | |
| description: response.message || "Failed to delete todo", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error", | |
| description: "An unexpected error occurred", | |
| variant: "destructive", | |
| }); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| } | |
| }; | |
| // Handle todo status change | |
| const handleStatusChange = async (id: number, status: Todo["status"]) => { | |
| try { | |
| setIsLoading(true); | |
| const todo = todos.find((t) => t.id === id); | |
| if (!todo) { | |
| toast({ | |
| title: "Error", | |
| description: "Todo not found", | |
| variant: "destructive", | |
| }); | |
| return; | |
| } | |
| const updatedTodo: TodoUpdateRequest = { | |
| ...todo, | |
| status, | |
| }; | |
| const response = await todoService.updateTodo(updatedTodo); | |
| if (response.success) { | |
| setTodos((prev) => | |
| prev.map((t) => (t.id === id ? { ...t, status } : t)) | |
| ); | |
| toast({ | |
| title: "Success", | |
| description: `Todo status updated to ${status}`, | |
| }); | |
| } else { | |
| toast({ | |
| title: "Error", | |
| description: response.message || "Failed to update todo status", | |
| variant: "destructive", | |
| }); | |
| } | |
| } catch (error) { | |
| toast({ | |
| title: "Error", | |
| description: "An unexpected error occurred while updating status", | |
| variant: "destructive", | |
| }); | |
| console.error("Error updating todo status:", error); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Open form for editing | |
| const handleEditTodo = (todo: Todo) => { | |
| setEditingTodo(todo); | |
| setIsFormOpen(true); | |
| }; | |
| // Close form and reset state | |
| const handleCloseForm = () => { | |
| setIsFormOpen(false); | |
| setEditingTodo(undefined); | |
| }; | |
| // Handle form submission | |
| const handleFormSubmit = (data: TodoCreateRequest | TodoUpdateRequest) => { | |
| if ("id" in data) { | |
| handleUpdateTodo(data as TodoUpdateRequest); | |
| } else { | |
| handleCreateTodo(data as TodoCreateRequest); | |
| } | |
| }; | |
| // Generate active filters count for display | |
| const getActiveFiltersCount = () => { | |
| let count = 0; | |
| if (statusFilter !== "All") count++; | |
| if (priorityFilter !== "All") count++; | |
| if (dateFilter.type !== "all") count++; | |
| return count; | |
| }; | |
| // Toggle completed tasks visibility | |
| const toggleCompletedVisibility = () => { | |
| setShowCompleted(!showCompleted); | |
| }; | |
| const activeFiltersCount = getActiveFiltersCount(); | |
| // Group todos by completion status | |
| const activeTodos = filteredTodos.filter(todo => todo.status !== 'Completed'); | |
| const completedTodos = filteredTodos.filter(todo => todo.status === 'Completed'); | |
| // Group todos by status for kanban view | |
| const kanbanTodos = { | |
| 'Pending': filteredTodos.filter(todo => todo.status === 'Pending'), | |
| 'In Progress': filteredTodos.filter(todo => todo.status === 'In Progress'), | |
| 'Completed': filteredTodos.filter(todo => todo.status === 'Completed') | |
| }; | |
| // Generate summary statistics | |
| const stats = { | |
| total: filteredTodos.length, | |
| pending: filteredTodos.filter(todo => todo.status === 'Pending').length, | |
| inProgress: filteredTodos.filter(todo => todo.status === 'In Progress').length, | |
| completed: filteredTodos.filter(todo => todo.status === 'Completed').length, | |
| high: filteredTodos.filter(todo => todo.priority === 'High').length, | |
| overdue: filteredTodos.filter(todo => | |
| isPast(new Date(todo.dueDate)) && todo.status !== 'Completed' | |
| ).length | |
| }; | |
| // Handle clearing all filters | |
| const clearAllFilters = () => { | |
| setStatusFilter("All"); | |
| setPriorityFilter("All"); | |
| setDateFilter({ type: 'all', startDate: undefined, endDate: undefined }); | |
| }; | |
| return ( | |
| <div className="space-y-4 sm:space-y-6"> | |
| {/* Header Section - Mobile Responsive */} | |
| <div className="bg-gradient-to-r from-blue-50 to-indigo-50 rounded-xl border border-blue-100 overflow-hidden"> | |
| <div className="p-4 sm:p-6"> | |
| <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4"> | |
| <div className="flex-1"> | |
| <h2 className="text-xl sm:text-2xl font-bold tracking-tight text-blue-800 mb-2"> | |
| {projectId ? "Project Todos" : | |
| assigneeId ? (userData?.employeeId === assigneeId ? "My Todos" : "Assignee Todos") : | |
| isAdmin ? "All Todos" : "My Todos"} | |
| </h2> | |
| {/* Stats Badges - Mobile Responsive */} | |
| <div className="flex flex-wrap gap-2"> | |
| <Badge variant="outline" className="bg-white text-xs sm:text-sm"> | |
| Total: {stats.total} | |
| </Badge> | |
| <Badge className="bg-amber-100 text-amber-800 hover:bg-amber-200 border-none text-xs sm:text-sm"> | |
| Pending: {stats.pending} | |
| </Badge> | |
| <Badge className="bg-blue-100 text-blue-800 hover:bg-blue-200 border-none text-xs sm:text-sm"> | |
| In Progress: {stats.inProgress} | |
| </Badge> | |
| <Badge className="bg-green-100 text-green-800 hover:bg-green-200 border-none text-xs sm:text-sm"> | |
| Completed: {stats.completed} | |
| </Badge> | |
| {stats.overdue > 0 && ( | |
| <Badge className="bg-rose-100 text-rose-800 hover:bg-rose-200 border-none text-xs sm:text-sm"> | |
| Overdue: {stats.overdue} | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| {canManageTodos && ( | |
| <Button | |
| onClick={() => setIsFormOpen(true)} | |
| className="bg-blue-600 hover:bg-blue-700 w-full sm:w-auto" | |
| size="sm" | |
| > | |
| <Plus className="h-4 w-4 mr-2" /> | |
| <span className="hidden sm:inline">Add Todo</span> | |
| <span className="sm:hidden">Add</span> | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Search and Filters - Mobile Responsive */} | |
| <div className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| {/* Always visible: Search Bar and Filter Toggle */} | |
| <div className="p-4 space-y-4"> | |
| {/* Search Bar with Mobile Filter Toggle */} | |
| <div className="flex gap-3"> | |
| <div className="relative flex-1"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-slate-400" /> | |
| <Input | |
| placeholder="Search todos..." | |
| className="pl-10 border-slate-200 focus-visible:ring-blue-500 h-10" | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| /> | |
| </div> | |
| {/* Mobile Filter Toggle Button */} | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="sm:hidden relative" | |
| onClick={() => setShowFilters(!showFilters)} | |
| > | |
| <Filter className="h-4 w-4" /> | |
| {activeFiltersCount > 0 && ( | |
| <span className="absolute -top-1 -right-1 h-4 w-4 rounded-full bg-blue-600 text-[10px] flex items-center justify-center text-white font-medium"> | |
| {activeFiltersCount} | |
| </span> | |
| )} | |
| </Button> | |
| </div> | |
| {/* Desktop: Always visible filters */} | |
| <div className="hidden sm:flex flex-col sm:flex-row gap-3 sm:gap-4"> | |
| {/* View Mode Toggle */} | |
| <div className="flex bg-slate-100 rounded-lg p-1 w-full sm:w-auto"> | |
| <Button | |
| variant={viewMode === 'list' ? "default" : "ghost"} | |
| size="sm" | |
| className={`flex-1 sm:flex-none rounded-md text-xs sm:text-sm ${ | |
| viewMode === 'list' ? "bg-white shadow-sm" : "hover:bg-slate-200" | |
| }`} | |
| onClick={() => setViewMode('list')} | |
| > | |
| <ListIcon className="h-4 w-4 mr-1 sm:mr-2" /> | |
| <span className="hidden sm:inline">List</span> | |
| <span className="sm:hidden">List</span> | |
| </Button> | |
| <Button | |
| variant={viewMode === 'kanban' ? "default" : "ghost"} | |
| size="sm" | |
| className={`flex-1 sm:flex-none rounded-md text-xs sm:text-sm ${ | |
| viewMode === 'kanban' ? "bg-white shadow-sm" : "hover:bg-slate-200" | |
| }`} | |
| onClick={() => setViewMode('kanban')} | |
| > | |
| <Kanban className="h-4 w-4 mr-1 sm:mr-2" /> | |
| <span className="hidden sm:inline">Kanban</span> | |
| <span className="sm:hidden">Board</span> | |
| </Button> | |
| </div> | |
| {/* Filter Controls */} | |
| <div className="flex flex-wrap gap-2 flex-1 justify-end"> | |
| <CustomSortDropdown | |
| value={sortBy} | |
| onChange={setSortBy} | |
| /> | |
| <CustomDateFilterDropdown | |
| value={dateFilter.type} | |
| onChange={setDateFilter} | |
| /> | |
| <CustomFilterDropdown | |
| statusFilter={statusFilter} | |
| priorityFilter={priorityFilter} | |
| setStatusFilter={setStatusFilter} | |
| setPriorityFilter={setPriorityFilter} | |
| clearAllFilters={clearAllFilters} | |
| activeFiltersCount={activeFiltersCount} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Mobile: Collapsible filters */} | |
| {showFilters && ( | |
| <div className="sm:hidden border-t border-slate-200 bg-slate-50 p-4 space-y-4"> | |
| {/* View Mode Toggle */} | |
| <div> | |
| <label className="text-sm font-medium text-slate-700 mb-2 block">View Mode</label> | |
| <div className="flex bg-slate-100 rounded-lg p-1"> | |
| <Button | |
| variant={viewMode === 'list' ? "default" : "ghost"} | |
| size="sm" | |
| className={`flex-1 rounded-md text-xs ${ | |
| viewMode === 'list' ? "bg-white shadow-sm" : "hover:bg-slate-200" | |
| }`} | |
| onClick={() => setViewMode('list')} | |
| > | |
| <ListIcon className="h-4 w-4 mr-2" /> | |
| List | |
| </Button> | |
| <Button | |
| variant={viewMode === 'kanban' ? "default" : "ghost"} | |
| size="sm" | |
| className={`flex-1 rounded-md text-xs ${ | |
| viewMode === 'kanban' ? "bg-white shadow-sm" : "hover:bg-slate-200" | |
| }`} | |
| onClick={() => setViewMode('kanban')} | |
| > | |
| <Kanban className="h-4 w-4 mr-2" /> | |
| Board | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Filter Controls */} | |
| <div className="space-y-3"> | |
| <label className="text-sm font-medium text-slate-700 block">Filters & Sorting</label> | |
| <div className="grid grid-cols-1 gap-3"> | |
| <CustomSortDropdown | |
| value={sortBy} | |
| onChange={setSortBy} | |
| /> | |
| <CustomDateFilterDropdown | |
| value={dateFilter.type} | |
| onChange={setDateFilter} | |
| /> | |
| <CustomFilterDropdown | |
| statusFilter={statusFilter} | |
| priorityFilter={priorityFilter} | |
| setStatusFilter={setStatusFilter} | |
| setPriorityFilter={setPriorityFilter} | |
| clearAllFilters={clearAllFilters} | |
| activeFiltersCount={activeFiltersCount} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {isLoading && !todos.length ? ( | |
| <div className="text-center py-12 border rounded-lg bg-gradient-to-r from-slate-50 to-blue-50"> | |
| <div className="flex flex-col items-center space-y-4"> | |
| <Loader2 className="h-8 w-8 animate-spin text-blue-600" /> | |
| <p className="text-lg text-blue-800 font-medium">Loading todos...</p> | |
| </div> | |
| </div> | |
| ) : filteredTodos.length === 0 ? ( | |
| <div className="text-center py-12 border rounded-lg bg-gradient-to-r from-slate-50 to-blue-50"> | |
| <div className="flex flex-col items-center space-y-4"> | |
| <div className="h-16 w-16 rounded-full bg-blue-100 flex items-center justify-center"> | |
| <ClipboardX className="h-8 w-8 text-blue-600" /> | |
| </div> | |
| <div> | |
| <p className="text-lg text-blue-800 font-medium">No todos found</p> | |
| <p className="text-sm text-gray-500 mt-1"> | |
| {searchTerm || statusFilter !== "All" || priorityFilter !== "All" || dateFilter.type !== "all" | |
| ? "Try adjusting your filters" | |
| : "Create a new todo to get started"} | |
| </p> | |
| </div> | |
| {canManageTodos && ( | |
| <Button | |
| variant="outline" | |
| className="mt-4 border-blue-300 text-blue-700 hover:bg-blue-50" | |
| onClick={() => setIsFormOpen(true)} | |
| > | |
| <Plus className="h-4 w-4 mr-2" /> Create a todo | |
| </Button> | |
| )} | |
| </div> | |
| </div> | |
| ) : viewMode === 'list' ? ( | |
| <div className="space-y-4"> | |
| {/* Active Todos Section */} | |
| {activeTodos.length > 0 && ( | |
| <div className="space-y-4"> | |
| {activeTodos.map((todo) => ( | |
| <TodoItem | |
| key={todo.id} | |
| todo={todo} | |
| onEdit={canManageTodos ? handleEditTodo : undefined} | |
| onDelete={canManageTodos ? handleDeleteTodo : undefined} | |
| onStatusChange={handleStatusChange} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| {/* Completed Todos Section - Collapsible */} | |
| {completedTodos.length > 0 && ( | |
| <div className="mt-6 border-t border-gray-200 pt-4"> | |
| <button | |
| className="flex items-center text-gray-700 hover:text-blue-600 transition-colors mb-4 group w-full" | |
| onClick={toggleCompletedVisibility} | |
| > | |
| <div className="flex items-center"> | |
| <ChevronRight | |
| className={cn( | |
| "h-5 w-5 mr-2 transition-transform", | |
| showCompleted && "transform rotate-90" | |
| )} | |
| /> | |
| <CheckCircle className="h-5 w-5 mr-2 text-green-600" /> | |
| <span className="font-medium">Completed</span> | |
| <Badge className="ml-2 bg-green-100 text-green-800">{completedTodos.length}</Badge> | |
| </div> | |
| </button> | |
| {showCompleted && ( | |
| <div className="space-y-4 pl-4 border-l-2 border-gray-100"> | |
| {completedTodos.map((todo) => ( | |
| <TodoItem | |
| key={todo.id} | |
| todo={todo} | |
| onEdit={canManageTodos ? handleEditTodo : undefined} | |
| onDelete={canManageTodos ? handleDeleteTodo : undefined} | |
| onStatusChange={handleStatusChange} | |
| /> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ) : ( | |
| <div className="space-y-4"> | |
| {/* Mobile: Stacked columns, Desktop: Grid layout */} | |
| <div className="grid grid-cols-1 lg:grid-cols-3 gap-4 lg:gap-6"> | |
| {(['Pending', 'In Progress', 'Completed'] as Todo['status'][]).map(status => ( | |
| <div key={status} className="bg-white rounded-xl border border-slate-200 overflow-hidden"> | |
| {/* Column header */} | |
| <div className={cn( | |
| "p-3 sm:p-4 font-medium text-sm flex items-center justify-between border-b", | |
| status === 'Pending' ? "bg-slate-50 text-slate-800 border-slate-200" : | |
| status === 'In Progress' ? "bg-blue-50 text-blue-800 border-blue-200" : | |
| "bg-green-50 text-green-800 border-green-200" | |
| )}> | |
| <div className="flex items-center gap-2"> | |
| <div className={cn( | |
| "w-3 h-3 rounded-full", | |
| status === 'Pending' ? "bg-slate-400" : | |
| status === 'In Progress' ? "bg-blue-500" : | |
| "bg-green-500" | |
| )} /> | |
| <span className="font-semibold">{status}</span> | |
| </div> | |
| <Badge className={cn( | |
| "text-xs font-medium", | |
| status === 'Pending' ? "bg-slate-100 text-slate-700" : | |
| status === 'In Progress' ? "bg-blue-100 text-blue-700" : | |
| "bg-green-100 text-green-700" | |
| )}> | |
| {kanbanTodos[status].length} | |
| </Badge> | |
| </div> | |
| {/* Column content */} | |
| <div className="p-3 sm:p-4 space-y-3 min-h-[200px] max-h-[70vh] overflow-y-auto"> | |
| {kanbanTodos[status].length === 0 ? ( | |
| <div className="border-2 border-dashed border-slate-200 rounded-lg p-6 text-center"> | |
| <div className={cn( | |
| "w-12 h-12 rounded-full mx-auto mb-3 flex items-center justify-center", | |
| status === 'Pending' ? "bg-slate-100" : | |
| status === 'In Progress' ? "bg-blue-100" : | |
| "bg-green-100" | |
| )}> | |
| <div className={cn( | |
| "w-6 h-6 rounded-full", | |
| status === 'Pending' ? "bg-slate-300" : | |
| status === 'In Progress' ? "bg-blue-300" : | |
| "bg-green-300" | |
| )} /> | |
| </div> | |
| <p className="text-sm text-slate-500 font-medium"> | |
| No {status.toLowerCase()} todos | |
| </p> | |
| <p className="text-xs text-slate-400 mt-1"> | |
| {status === 'Pending' ? 'Tasks will appear here when created' : | |
| status === 'In Progress' ? 'Tasks will appear here when started' : | |
| 'Tasks will appear here when completed'} | |
| </p> | |
| </div> | |
| ) : ( | |
| kanbanTodos[status].map(todo => ( | |
| <div key={todo.id} className="transform transition-transform hover:scale-[1.02]"> | |
| <TodoItem | |
| todo={todo} | |
| onEdit={canManageTodos ? handleEditTodo : undefined} | |
| onDelete={canManageTodos ? handleDeleteTodo : undefined} | |
| onStatusChange={handleStatusChange} | |
| /> | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <CustomDialog | |
| isOpen={isFormOpen} | |
| onClose={handleCloseForm} | |
| title={editingTodo ? "Edit Todo" : "Create New Todo"} | |
| allowClose={!isLoading} | |
| isPending={isLoading} | |
| > | |
| <TodoForm | |
| todo={editingTodo} | |
| onSubmit={handleFormSubmit} | |
| onCancel={handleCloseForm} | |
| isLoading={isLoading} | |
| /> | |
| </CustomDialog> | |
| </div> | |
| ); | |
| } |