Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect, createContext, useContext, useRef } from "react"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { format, isSameDay } from "date-fns"; | |
| import { | |
| AlertCircle, | |
| CheckCircle, | |
| ChevronDown, | |
| Clock, | |
| Filter, | |
| LayoutDashboard, | |
| LayoutList, | |
| MoreHorizontal, | |
| Plus, | |
| RefreshCw, | |
| Search, | |
| Trash2, | |
| Eye, | |
| Edit2, | |
| Bug, | |
| Check, | |
| User, | |
| ShieldAlert, | |
| FlameIcon, | |
| X, | |
| Timer, | |
| CalendarIcon, | |
| ExternalLink, | |
| ChevronRight, | |
| ListFilter, | |
| Download, | |
| FileSpreadsheet, | |
| FileText | |
| } from "lucide-react"; | |
| import * as XLSX from 'xlsx'; | |
| import { jsPDF } from 'jspdf'; | |
| import { saveAs } from 'file-saver'; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; | |
| import { | |
| Card, | |
| CardContent, | |
| CardHeader, | |
| CardTitle, | |
| } from "@/components/ui/card"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| DropdownMenuRadioGroup, | |
| DropdownMenuRadioItem, | |
| DropdownMenuLabel, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import Layout from "@/components/layout/layout"; | |
| // Import Task and tasksApi from tasksApi | |
| import { Task, tasksApi } from "@/services/tasksApi"; | |
| import { employeeService, projectService, teamMemberService } from "@/lib/api"; | |
| import { Employee, ProjectApi } from "@/types"; | |
| import { toast } from "sonner"; | |
| import { cn } from "@/lib/utils"; | |
| import DashboardBugs from "@/components/dashboard/dashboard-bugs"; | |
| import { Dropdown, DropdownOption } from "@/components/ui/dropdown"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| import { timeLogApi, TimeLogCreateRequest } from "@/services/timeLogApi"; | |
| import ImportTasksDialog from "@/components/tasks/import-tasks-dialog"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover"; | |
| import { | |
| Tooltip, | |
| TooltipContent, | |
| TooltipProvider, | |
| TooltipTrigger, | |
| } from "@/components/ui/tooltip"; | |
| import { | |
| Calendar, | |
| } from "@/components/ui/calendar"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import CustomModal from "@/components/custom-modal"; // Import the custom modal | |
| import { exportToExcel } from "@/lib/excel-export"; | |
| import autoTable from 'jspdf-autotable'; | |
| // Create a dropdown context to manage all dropdowns globally | |
| const DropdownContext = createContext<{ | |
| openDropdown: string | null; | |
| setOpenDropdown: (id: string | null) => void; | |
| }>({ | |
| openDropdown: null, | |
| setOpenDropdown: () => { }, | |
| }); | |
| // Create a dropdown provider component | |
| function DropdownProvider({ children }: { children: React.ReactNode }) { | |
| const [openDropdown, setOpenDropdown] = useState<string | null>(null); | |
| return ( | |
| <DropdownContext.Provider value={{ openDropdown, setOpenDropdown }}> | |
| {openDropdown && <div className="dropdown-overlay" onClick={() => setOpenDropdown(null)} />} | |
| {children} | |
| </DropdownContext.Provider> | |
| ); | |
| } | |
| // Custom Action Menu component for table actions | |
| interface CustomActionMenuProps { | |
| align?: "left" | "right"; | |
| bug: Task; | |
| onView: (bug: Task) => void; | |
| onEdit: (bug: Task) => void; | |
| onDelete: (id: number) => void; | |
| isDeleting: boolean; | |
| deleteId: number | null; | |
| } | |
| function CustomActionMenu({ | |
| align = "right", | |
| bug, | |
| onView, | |
| onEdit, | |
| onDelete, | |
| isDeleting, | |
| deleteId | |
| }: CustomActionMenuProps) { | |
| const [open, setOpen] = useState(false); | |
| const menuRef = React.useRef<HTMLDivElement>(null); | |
| const [menuPosition, setMenuPosition] = useState({ | |
| top: 0, | |
| right: undefined as number | undefined, | |
| left: undefined as number | undefined | |
| }); | |
| const updateMenuPosition = () => { | |
| if (menuRef.current) { | |
| const rect = menuRef.current.getBoundingClientRect(); | |
| setMenuPosition({ | |
| top: rect.bottom + window.scrollY, | |
| right: align === "right" ? window.innerWidth - rect.right : undefined, | |
| left: align === "left" ? rect.left : undefined | |
| }); | |
| } | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| if (!open) { | |
| // Update position when opening | |
| setTimeout(updateMenuPosition, 0); | |
| } | |
| }; | |
| const handleAction = (action: () => void, e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| action(); | |
| setOpen(false); | |
| }; | |
| // Update position when open state changes | |
| useEffect(() => { | |
| if (open) { | |
| updateMenuPosition(); | |
| } | |
| }, [open]); | |
| // Update position on scroll and resize | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleScroll = () => { | |
| updateMenuPosition(); | |
| }; | |
| const handleResize = () => { | |
| updateMenuPosition(); | |
| }; | |
| window.addEventListener('scroll', handleScroll, true); | |
| window.addEventListener('resize', handleResize); | |
| return () => { | |
| window.removeEventListener('scroll', handleScroll, true); | |
| window.removeEventListener('resize', handleResize); | |
| }; | |
| }, [open]); | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (menuRef.current && !menuRef.current.contains(e.target as Node)) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [open]); | |
| return ( | |
| <div className="relative" ref={menuRef}> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0" | |
| onClick={handleButtonClick} | |
| > | |
| <span className="sr-only">Open menu</span> | |
| <MoreHorizontal className="h-4 w-4" /> | |
| </Button> | |
| {open && ( | |
| <div | |
| className="fixed z-[9999] min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md" | |
| style={{ | |
| top: menuPosition.top, | |
| right: menuPosition.right, | |
| left: menuPosition.left | |
| }} | |
| > | |
| <div | |
| className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground" | |
| onClick={(e) => handleAction(() => onView(bug), e)} | |
| > | |
| <Eye className="mr-2 h-4 w-4" /> | |
| View Details | |
| </div> | |
| <div | |
| className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground" | |
| onClick={(e) => handleAction(() => onEdit(bug), e)} | |
| > | |
| <Edit2 className="mr-2 h-4 w-4" /> | |
| Edit | |
| </div> | |
| <div className="h-px my-1 -mx-1 bg-muted"></div> | |
| <div | |
| className={cn( | |
| "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none text-red-600 hover:bg-accent hover:text-red-600", | |
| (isDeleting && deleteId === bug.id) && "opacity-50 cursor-not-allowed" | |
| )} | |
| onClick={(e) => { | |
| if (!(isDeleting && deleteId === bug.id)) { | |
| handleAction(() => onDelete(bug.id), e); | |
| } else { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| }} | |
| > | |
| {isDeleting && deleteId === bug.id ? ( | |
| <> | |
| <div className="h-4 w-4 mr-2 rounded-full border-b-2 border-red-600"></div> | |
| Deleting... | |
| </> | |
| ) : ( | |
| <> | |
| <Trash2 className="mr-2 h-4 w-4" /> | |
| Delete | |
| </> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Improved CustomDropdown component with dropdown context | |
| interface CustomDropdownProps { | |
| id?: string; // Optional identifier for this dropdown | |
| value: string; | |
| onChange: (value: string) => void; | |
| options: { value: string; label: string }[]; | |
| placeholder: string; | |
| } | |
| function CustomDropdown({ id = "dropdown-" + Math.random().toString(36).substring(2, 9), value, onChange, options, placeholder }: CustomDropdownProps) { | |
| const { openDropdown, setOpenDropdown } = useContext(DropdownContext); | |
| const isOpen = openDropdown === id; | |
| const dropdownRef = React.useRef<HTMLDivElement>(null); | |
| const [menuPosition, setMenuPosition] = useState({ | |
| top: 0, | |
| left: 0, | |
| width: 0 | |
| }); | |
| const getLabel = () => { | |
| return options.find(option => option.value === value)?.label || placeholder; | |
| }; | |
| const updateMenuPosition = () => { | |
| if (dropdownRef.current) { | |
| const rect = dropdownRef.current.getBoundingClientRect(); | |
| // Calculate available space below and above | |
| const spaceBelow = window.innerHeight - rect.bottom; | |
| const spaceAbove = rect.top; | |
| // Determine if dropdown should open upward or downward | |
| const openUpward = spaceBelow < 300 && spaceAbove > spaceBelow; | |
| setMenuPosition({ | |
| top: openUpward ? rect.top - 5 : rect.bottom + 5, | |
| left: rect.left, | |
| width: rect.width | |
| }); | |
| } | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // If already open, close it | |
| if (isOpen) { | |
| setOpenDropdown(null); | |
| return; | |
| } | |
| // Otherwise, update position and open this dropdown | |
| // Close any other open dropdown | |
| setTimeout(() => { | |
| updateMenuPosition(); | |
| setOpenDropdown(id); | |
| }, 0); | |
| }; | |
| const handleOptionClick = (optionValue: string, e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Close dropdown first | |
| setOpenDropdown(null); | |
| // Then change the value | |
| setTimeout(() => { | |
| onChange(optionValue); | |
| }, 0); | |
| }; | |
| // Update position when open state changes | |
| useEffect(() => { | |
| if (isOpen) { | |
| updateMenuPosition(); | |
| } | |
| }, [isOpen]); | |
| // Update position on scroll and resize when open | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| const handleScroll = () => { | |
| updateMenuPosition(); | |
| }; | |
| const handleResize = () => { | |
| updateMenuPosition(); | |
| }; | |
| window.addEventListener('scroll', handleScroll, true); | |
| window.addEventListener('resize', handleResize); | |
| return () => { | |
| window.removeEventListener('scroll', handleScroll, true); | |
| window.removeEventListener('resize', handleResize); | |
| }; | |
| }, [isOpen]); | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpenDropdown(null); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [isOpen, setOpenDropdown]); | |
| return ( | |
| <div className="relative w-full dropdown-container" ref={dropdownRef}> | |
| <div | |
| className={cn( | |
| "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer dropdown-trigger", | |
| isOpen && "ring-2 ring-ring ring-offset-2" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <span className={!value || value === "all" ? "text-muted-foreground" : ""}>{getLabel()}</span> | |
| <ChevronDown className={cn("h-4 w-4 shrink-0 opacity-50", isOpen && "")} /> | |
| </div> | |
| {isOpen && ( | |
| <div | |
| className="fixed overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md dropdown-menu custom-scrollbar dropdown-active" | |
| style={{ | |
| top: menuPosition.top, | |
| left: menuPosition.left, | |
| width: menuPosition.width, | |
| maxHeight: "300px" | |
| }} | |
| > | |
| {options.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 dropdown-item", | |
| value === option.value && "bg-accent text-accent-foreground dropdown-item active" | |
| )} | |
| onClick={(e) => handleOptionClick(option.value, e)} | |
| > | |
| {option.label} | |
| {value === option.value && <Check className="h-4 w-4" />} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Improved CustomEmployeeDropdown component with dropdown context | |
| interface CustomEmployeeDropdownProps { | |
| id?: string; // Optional identifier for this dropdown | |
| value: string | number; | |
| onChange: (value: string | number) => void; | |
| employees: Employee[]; | |
| placeholder: string; | |
| } | |
| function CustomEmployeeDropdown({ id = "employee-dropdown-" + Math.random().toString(36).substring(2, 9), value, onChange, employees, placeholder }: CustomEmployeeDropdownProps) { | |
| const { openDropdown, setOpenDropdown } = useContext(DropdownContext); | |
| const isOpen = openDropdown === id; | |
| const dropdownRef = React.useRef<HTMLDivElement>(null); | |
| const [menuPosition, setMenuPosition] = useState({ | |
| top: 0, | |
| left: 0, | |
| width: 0 | |
| }); | |
| const getEmployeeName = (id: string | number | null) => { | |
| if (id === null || id === "all" || id === "unassigned") return placeholder; | |
| if (typeof id === "string" && id !== "all") { | |
| id = parseInt(id, 10); | |
| } | |
| const employee = employees.find(emp => emp.id === id); | |
| return employee ? `${employee.firstName} ${employee.lastName}` : `Employee ${id}`; | |
| }; | |
| const getInitials = (id: number) => { | |
| const employee = employees.find(emp => emp.id === id); | |
| if (!employee) return "??"; | |
| return (employee.firstName.charAt(0) + employee.lastName.charAt(0)).toUpperCase(); | |
| }; | |
| const updateMenuPosition = () => { | |
| if (dropdownRef.current) { | |
| const rect = dropdownRef.current.getBoundingClientRect(); | |
| // Calculate available space below and above | |
| const spaceBelow = window.innerHeight - rect.bottom; | |
| const spaceAbove = rect.top; | |
| // Determine if dropdown should open upward or downward | |
| const openUpward = spaceBelow < 300 && spaceAbove > spaceBelow; | |
| setMenuPosition({ | |
| top: openUpward ? rect.top - 5 : rect.bottom + 5, | |
| left: rect.left, | |
| width: rect.width | |
| }); | |
| } | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // If already open, close it | |
| if (isOpen) { | |
| setOpenDropdown(null); | |
| return; | |
| } | |
| // Otherwise, update position and open this dropdown | |
| setTimeout(() => { | |
| updateMenuPosition(); | |
| setOpenDropdown(id); | |
| }, 0); | |
| }; | |
| const handleOptionClick = (employeeId: string | number, e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Close menu first | |
| setOpenDropdown(null); | |
| // Then change the value with a small delay | |
| setTimeout(() => { | |
| onChange(employeeId); | |
| }, 0); | |
| }; | |
| // Update position when open state changes | |
| useEffect(() => { | |
| if (isOpen) { | |
| updateMenuPosition(); | |
| } | |
| }, [isOpen]); | |
| // Update position on scroll and resize | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| const handleScroll = () => { | |
| updateMenuPosition(); | |
| }; | |
| const handleResize = () => { | |
| updateMenuPosition(); | |
| }; | |
| window.addEventListener('scroll', handleScroll, true); | |
| window.addEventListener('resize', handleResize); | |
| return () => { | |
| window.removeEventListener('scroll', handleScroll, true); | |
| window.removeEventListener('resize', handleResize); | |
| }; | |
| }, [isOpen]); | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setOpenDropdown(null); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [isOpen, setOpenDropdown]); | |
| return ( | |
| <div className="relative w-full dropdown-container" ref={dropdownRef}> | |
| <div | |
| className={cn( | |
| "flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background cursor-pointer dropdown-trigger", | |
| isOpen && "ring-2 ring-ring ring-offset-2" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| {value !== "all" && typeof value === "number" ? ( | |
| <Avatar className="h-5 w-5 mr-2"> | |
| <AvatarFallback className="text-[10px]"> | |
| {getInitials(value)} | |
| </AvatarFallback> | |
| </Avatar> | |
| ) : ( | |
| <User className="h-4 w-4 mr-2 opacity-50" /> | |
| )} | |
| <span className={value === "all" ? "text-muted-foreground" : ""}> | |
| {getEmployeeName(value)} | |
| </span> | |
| </div> | |
| <ChevronDown className={cn("h-4 w-4 shrink-0 opacity-50", isOpen && "")} /> | |
| </div> | |
| {isOpen && ( | |
| <div | |
| className="fixed overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md dropdown-menu custom-scrollbar dropdown-active" | |
| style={{ | |
| top: menuPosition.top, | |
| left: menuPosition.left, | |
| width: menuPosition.width, | |
| maxHeight: "300px" | |
| }} | |
| > | |
| <div | |
| key="all" | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground dropdown-item", | |
| value === "all" && "bg-accent text-accent-foreground dropdown-item active" | |
| )} | |
| onClick={(e) => handleOptionClick("all", e)} | |
| > | |
| <User className="h-4 w-4 mr-2 opacity-50" /> | |
| <span>All Assignees</span> | |
| {value === "all" && ( | |
| <Check className="ml-auto h-4 w-4" /> | |
| )} | |
| </div> | |
| {employees.map((employee) => ( | |
| <div | |
| key={employee.id} | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground dropdown-item", | |
| value === employee.id && "bg-accent text-accent-foreground dropdown-item active" | |
| )} | |
| onClick={(e) => handleOptionClick(employee.id, e)} | |
| > | |
| <Avatar className="h-5 w-5 mr-2"> | |
| <AvatarFallback className="text-[10px]"> | |
| {(employee.firstName.charAt(0) + employee.lastName.charAt(0)).toUpperCase()} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span>{employee.firstName} {employee.lastName}</span> | |
| {value === employee.id && ( | |
| <Check className="ml-auto h-4 w-4" /> | |
| )} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // The Task interface in tasksApi.ts now includes all required fields | |
| const Bugs = () => { | |
| const navigate = useNavigate(); | |
| const { userData } = useAuth(); // Add useAuth to get current user data | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [statusFilter, setStatusFilter] = useState<string | "all">("all"); | |
| const [severityFilter, setSeverityFilter] = useState<string | "all">("all"); | |
| const [assigneeFilter, setAssigneeFilter] = useState<string | "all">("all"); | |
| const [projectFilter, setProjectFilter] = useState<string>("all"); | |
| // Add new filter for My/Team bugs | |
| const [scopeFilter, setScopeFilter] = useState<"my" | "all">("my"); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [bugs, setBugs] = useState<Task[]>([]); | |
| const [employees, setEmployees] = useState<Employee[]>([]); | |
| const [teamMembers, setTeamMembers] = useState<Employee[]>([]); // Add state for team members | |
| const [projects, setProjects] = useState<Record<number, string>>({}); | |
| const [userProjects, setUserProjects] = useState<ProjectApi[]>([]); // Add state for user's projects | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| const [deleteId, setDeleteId] = useState<number | null>(null); | |
| const [openMenuId, setOpenMenuId] = useState<number | null>(null); | |
| const [error, setError] = useState<string | null>(null); | |
| const [sortField, setSortField] = useState<string>("createdAt"); | |
| const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); // Newest first by default | |
| const [viewMode, setViewMode] = useState<"dashboard" | "list">("list"); | |
| // Add state for filter card expansion | |
| const [filtersExpanded, setFiltersExpanded] = useState(false); | |
| // Quick View Dialog state | |
| const [isQuickViewOpen, setIsQuickViewOpen] = useState(false); | |
| const [selectedBugForQuickView, setSelectedBugForQuickView] = useState<Task | null>(null); | |
| // Time logging state | |
| const [isLoggingTime, setIsLoggingTime] = useState(false); | |
| const [selectedBugForTimeLog, setSelectedBugForTimeLog] = useState<Task | null>(null); | |
| const [timeLogHours, setTimeLogHours] = useState<string>(""); | |
| const [remainingHours, setRemainingHours] = useState<string>(""); | |
| const [timeLogDescription, setTimeLogDescription] = useState<string>(""); | |
| const [startTime, setStartTime] = useState<string>(""); | |
| const [endTime, setEndTime] = useState<string>(""); | |
| const [workDate, setWorkDate] = useState<Date | undefined>(new Date()); | |
| const [timeOptions, setTimeOptions] = useState<Array<{ label: string, value: string }>>([]); | |
| // Status update state | |
| const [isUpdatingStatus, setIsUpdatingStatus] = useState(false); | |
| const [updateStatusId, setUpdateStatusId] = useState<number | null>(null); | |
| // Add new state for collapsing completed bugs | |
| const [collapseCompletedBugs, setCollapseCompletedBugs] = useState(true); | |
| const statusOptions = ["Open", "In Progress", "In Review", "Ready for QA", "Resolved", "Completed", "Closed", "Reopened"]; | |
| const severityOptions = ["High", "Medium", "Low"]; | |
| // Create API data cache to prevent multiple calls | |
| const apiCache = useRef<{ | |
| teamMembers: any[] | null; | |
| allTeamMembers: any[] | null; | |
| allEmployees: Employee[] | null; | |
| }>({ | |
| teamMembers: null, | |
| allTeamMembers: null, | |
| allEmployees: null | |
| }); | |
| // We don't need this cache anymore | |
| // const projectTeamsCache = React.useRef<Record<number, any[]>>({}); | |
| // Time logging functions | |
| const generateTimeOptions = () => { | |
| const options: Array<{ label: string, value: string }> = []; | |
| // Generate times from 12:00 AM to 11:45 PM in 15-minute increments | |
| for (let hour = 0; hour < 24; hour++) { | |
| for (let minute = 0; minute < 60; minute += 15) { | |
| // Format the time as AM/PM | |
| const period = hour < 12 ? 'AM' : 'PM'; | |
| const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; | |
| const timeString = `${displayHour}:${minute.toString().padStart(2, '0')} ${period}`; | |
| options.push({ | |
| label: timeString, | |
| value: timeString | |
| }); | |
| } | |
| } | |
| setTimeOptions(options); | |
| }; | |
| useEffect(() => { | |
| const loadData = async () => { | |
| try { | |
| if (!userData) { | |
| toast.error("User data not found. Please log in again."); | |
| return; | |
| } | |
| setIsLoading(true); | |
| console.log("DEBUG: Starting data loading process"); | |
| // Load everything sequentially to prevent duplicate calls | |
| // 1. First get the employee data for the entire app | |
| const employeeData = await employeeService.getAll(); | |
| console.log(`DEBUG: Loaded ${employeeData.length} employees`); | |
| setEmployees(employeeData); | |
| // 2. Get user's projects | |
| const projectsData = await projectService.getByEmployeeId(userData.employeeId); | |
| console.log(`DEBUG: Loaded ${projectsData.length} projects for user ${userData.employeeId}`); | |
| setUserProjects(projectsData); | |
| const projectsMap = projectsData.reduce((acc: Record<number, string>, project: ProjectApi) => { | |
| acc[project.id] = project.projectName; | |
| return acc; | |
| }, {}); | |
| setProjects(projectsMap); | |
| console.log("DEBUG: Created projects map:", projectsMap); | |
| // 3. Get team members (only if we have projects and need to) | |
| if (projectsData.length > 0 && teamMembers.length === 0) { | |
| // Get all team members | |
| const allTeamMembers = await teamMemberService.getAll(); | |
| console.log(`DEBUG: Loaded ${allTeamMembers.length} team members`); | |
| // We don't need to fetch project teams anymore since we're using all employees | |
| console.log("DEBUG: Using all employees for assignee dropdown"); | |
| // Set team members to be the same as all employees | |
| setTeamMembers(employeeData); | |
| } | |
| // 4. Finally, get the bugs | |
| console.log("DEBUG: Fetching bugs with tasksApi.getByType('Bug')"); | |
| const bugTasks = await tasksApi.getByType('Bug'); | |
| console.log(`DEBUG: Loaded ${bugTasks.length} bug tasks from API`); | |
| if (bugTasks.length === 0) { | |
| console.warn("DEBUG: No bugs returned from API"); | |
| } else { | |
| console.log("DEBUG: First bug from API:", bugTasks[0]); | |
| } | |
| // Map tasks to include projectId | |
| const bugsWithProjectId = bugTasks.map(task => { | |
| // Using issuesId as projectId, but handle the case where it might be null | |
| return { ...task, projectId: task.issuesId || null } as Task; | |
| }); | |
| // Filter bugs by project only if projectFilter is applied, otherwise show all bugs | |
| let filteredBugs = bugsWithProjectId; | |
| // Only filter by project if there are projects and we're not in "all projects" mode | |
| if (projectsData.length > 0 && projectFilter !== "all") { | |
| filteredBugs = bugsWithProjectId.filter(bug => | |
| bug.projectName === projectFilter | |
| ); | |
| console.log(`DEBUG: Filtered to ${filteredBugs.length} bugs for selected project ${projectFilter}`); | |
| console.log(`DEBUG: Project filter value: ${projectFilter}`); | |
| console.log(`DEBUG: Sample bug projectName: ${filteredBugs.length > 0 ? filteredBugs[0].projectName : 'none'}`); | |
| } | |
| console.log(`DEBUG: Final bugs count: ${filteredBugs.length} bugs`); | |
| if (filteredBugs.length === 0) { | |
| console.warn("DEBUG: No bugs left after filtering"); | |
| if (bugTasks.length > 0) { | |
| console.log("DEBUG: Example bug before filtering:", bugTasks[0]); | |
| console.log("DEBUG: Project IDs in bugs:", bugsWithProjectId.map(b => b.projectId)); | |
| console.log("DEBUG: User projects:", projectsData.map(p => p.id)); | |
| } | |
| } else { | |
| console.log("DEBUG: Example bug after filtering:", filteredBugs[0]); | |
| } | |
| // Set the bugs state with our filtered bugs | |
| setBugs(filteredBugs); | |
| } catch (error) { | |
| console.error("Error loading data:", error); | |
| toast.error("Error loading data. Please try again."); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| // Only load data once when user data is available | |
| loadData(); | |
| // Also initialize time options | |
| generateTimeOptions(); | |
| }, [userData]); // Only run when userData changes | |
| const getYesterday = () => { | |
| const yesterday = new Date(); | |
| yesterday.setDate(yesterday.getDate() - 1); | |
| return yesterday; | |
| }; | |
| const calculateHoursSpent = (start: string, end: string): string => { | |
| // Extract hours and minutes from time strings like "9:30 AM" or "2:45 PM" | |
| const parseTime = (timeStr: string) => { | |
| const [timePart, periodPart] = timeStr.split(' '); | |
| const [hourStr, minuteStr] = timePart.split(':'); | |
| let hour = parseInt(hourStr, 10); | |
| const minute = parseInt(minuteStr, 10); | |
| // Adjust hour based on AM/PM | |
| if (periodPart === 'PM' && hour < 12) { | |
| hour += 12; | |
| } else if (periodPart === 'AM' && hour === 12) { | |
| hour = 0; | |
| } | |
| return { hour, minute }; | |
| }; | |
| const startTime = parseTime(start); | |
| const endTime = parseTime(end); | |
| // Calculate total minutes | |
| let startMinutes = startTime.hour * 60 + startTime.minute; | |
| let endMinutes = endTime.hour * 60 + endTime.minute; | |
| // If end time is earlier than start time, assume it's the next day | |
| if (endMinutes < startMinutes) { | |
| endMinutes += 24 * 60; // Add 24 hours in minutes | |
| } | |
| // Calculate difference in hours | |
| const hoursDiff = (endMinutes - startMinutes) / 60; | |
| // Round to nearest quarter hour (0.25) | |
| const roundedHours = Math.round(hoursDiff * 4) / 4; | |
| return roundedHours.toFixed(2); | |
| }; | |
| const convertTo24HourFormat = (timeStr: string): string => { | |
| const [timePart, ampm] = timeStr.split(' '); | |
| let [hourStr, minuteStr] = timePart.split(':'); | |
| let hour = parseInt(hourStr, 10); | |
| const minute = parseInt(minuteStr, 10); | |
| if (ampm === 'PM' && hour < 12) hour += 12; | |
| if (ampm === 'AM' && hour === 12) hour = 0; | |
| return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`; | |
| }; | |
| const handleTimeLogSubmit = async (bugId: number) => { | |
| if (!timeLogHours || isNaN(Number(timeLogHours)) || Number(timeLogHours) <= 0) { | |
| toast.error("Please enter valid hours"); | |
| return; | |
| } | |
| if (!startTime || !endTime) { | |
| toast.error("Please enter both start and end time"); | |
| return; | |
| } | |
| if (!workDate) { | |
| toast.error("Please select a work date"); | |
| return; | |
| } | |
| try { | |
| // Convert AM/PM time to 24-hour format for API | |
| const startTime24 = convertTo24HourFormat(startTime); | |
| const endTime24 = convertTo24HourFormat(endTime); | |
| const timeLogData: TimeLogCreateRequest = { | |
| workDate: workDate.toISOString(), | |
| startTime: startTime24, // Using 24-hour format | |
| endTime: endTime24, // Using 24-hour format | |
| comments: timeLogDescription || "", | |
| createdBy: String(userData.userId), | |
| updatedBy: String(userData.userId), | |
| remainingHr: parseFloat(remainingHours) || 0, | |
| taskId: bugId, | |
| hrsSpent: Number(timeLogHours), | |
| }; | |
| await timeLogApi.create(timeLogData); | |
| toast.success("Time logged successfully"); | |
| setIsLoggingTime(false); | |
| setSelectedBugForTimeLog(null); | |
| setTimeLogHours(""); | |
| setTimeLogDescription(""); | |
| setStartTime(""); | |
| setEndTime(""); | |
| setWorkDate(new Date()); | |
| } catch (error) { | |
| console.error("Error logging time:", error); | |
| toast.error("Failed to log time"); | |
| } | |
| }; | |
| const getEmployeeName = (employeeId: number): string => { | |
| const employee = employees.find(emp => emp.id === employeeId); | |
| return employee ? `${employee.firstName} ${employee.lastName}` : `User ${employeeId}`; | |
| }; | |
| const getEmployeeInitials = (employeeId: number): string => { | |
| const employee = employees.find(emp => emp.id === employeeId); | |
| return employee ? `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`.toUpperCase() : "UN"; | |
| }; | |
| const sortBugs = (bugsToSort: Task[]): Task[] => { | |
| return [...bugsToSort].sort((a, b) => { | |
| let valueA, valueB; | |
| // Extract the values to compare based on the sort field | |
| switch (sortField) { | |
| case "title": | |
| valueA = a.title?.toLowerCase() || ""; | |
| valueB = b.title?.toLowerCase() || ""; | |
| break; | |
| case "status": | |
| valueA = a.status?.toLowerCase() || ""; | |
| valueB = b.status?.toLowerCase() || ""; | |
| break; | |
| case "assignedTo": | |
| valueA = getEmployeeName(a.assignedTo || 0).toLowerCase(); | |
| valueB = getEmployeeName(b.assignedTo || 0).toLowerCase(); | |
| break; | |
| case "severity": | |
| valueA = a.type?.toLowerCase() || ""; | |
| valueB = b.type?.toLowerCase() || ""; | |
| break; | |
| case "createdAt": | |
| default: | |
| // For dates, we need to convert to actual Date objects | |
| valueA = a.createdAt ? new Date(a.createdAt).getTime() : 0; | |
| valueB = b.createdAt ? new Date(b.createdAt).getTime() : 0; | |
| break; | |
| } | |
| // Compare based on sort order | |
| if (sortOrder === "asc") { | |
| return valueA > valueB ? 1 : valueA < valueB ? -1 : 0; | |
| } else { | |
| return valueA < valueB ? 1 : valueA > valueB ? -1 : 0; | |
| } | |
| }); | |
| }; | |
| // Helper function to get severity value from a bug | |
| const getBugSeverity = (bug: Task): string => { | |
| // First check if we have a severity field | |
| if (bug.severity) { | |
| return bug.severity; | |
| } | |
| // Fall back to type field which may have been used for severity | |
| return bug.type || ''; | |
| }; | |
| // Add this function to determine if a bug should be hidden | |
| const shouldShowBug = (bug: Task) => { | |
| const completedStatuses = ["Completed", "Closed", "Resolved"]; | |
| if (collapseCompletedBugs && completedStatuses.includes(bug.status)) { | |
| return false; | |
| } | |
| return true; | |
| }; | |
| // Add this function to determine if a bug should be shown based on the scope filter | |
| const isInSelectedScope = (bug: Task) => { | |
| if (scopeFilter === "all") return true; | |
| if (scopeFilter === "my" && bug.assignedTo === userData?.employeeId) return true; | |
| return false; | |
| }; | |
| // Modify the filtered and sorted bugs function to handle collapsing | |
| const filteredAndSortedBugs = () => { | |
| console.log("DEBUG: Running filteredAndSortedBugs function"); | |
| console.log(`DEBUG: Current bugs array length: ${bugs.length}`); | |
| let filtered = [...bugs]; | |
| // Log current filters | |
| console.log("DEBUG: Applied filters:", { | |
| searchQuery, | |
| statusFilter, | |
| severityFilter, | |
| assigneeFilter, | |
| projectFilter, | |
| scopeFilter | |
| }); | |
| // Apply scope filter (My/Team/All bugs) | |
| if (scopeFilter !== "all") { | |
| filtered = filtered.filter(isInSelectedScope); | |
| console.log(`DEBUG: After scope filter: ${filtered.length} bugs`); | |
| } | |
| // Apply text search filter | |
| if (searchQuery) { | |
| const query = searchQuery.toLowerCase(); | |
| filtered = filtered.filter(bug => | |
| bug.title?.toLowerCase().includes(query) || | |
| bug.description?.toLowerCase().includes(query) || | |
| bug.taskCode?.toLowerCase().includes(query) || | |
| bug.version?.toLowerCase().includes(query) | |
| ); | |
| console.log(`DEBUG: After search filter: ${filtered.length} bugs`); | |
| } | |
| // Apply status filter | |
| if (statusFilter !== "all") { | |
| filtered = filtered.filter(bug => bug.status === statusFilter); | |
| console.log(`DEBUG: After status filter: ${filtered.length} bugs`); | |
| } | |
| // Apply severity filter (check both severity and type fields) | |
| if (severityFilter !== "all") { | |
| filtered = filtered.filter(bug => | |
| getBugSeverity(bug) === severityFilter || bug.type === severityFilter | |
| ); | |
| console.log(`DEBUG: After severity filter: ${filtered.length} bugs`); | |
| } | |
| // Apply assignee filter | |
| if (assigneeFilter !== "all") { | |
| filtered = filtered.filter(bug => bug.assignedTo?.toString() === assigneeFilter); | |
| console.log(`DEBUG: After assignee filter: ${filtered.length} bugs`); | |
| } | |
| // Apply project filter | |
| if (projectFilter !== "all") { | |
| filtered = filtered.filter(bug => | |
| bug.projectName === projectFilter | |
| ); | |
| console.log(`DEBUG: After project filter: ${filtered.length} bugs`); | |
| console.log(`DEBUG: Project filter value: ${projectFilter}`); | |
| console.log(`DEBUG: Sample bug projectName: ${filtered.length > 0 ? filtered[0].projectName : 'none'}`); | |
| } | |
| console.log(`DEBUG: Final filtered bugs count: ${filtered.length}`); | |
| // Apply sorting | |
| const sorted = sortBugs(filtered); | |
| return sorted; | |
| }; | |
| // Count completed bugs that are currently filtered | |
| const getCompletedBugsCount = () => { | |
| const completedStatuses = ["Completed", "Closed", "Resolved"]; | |
| return filteredAndSortedBugs().filter(bug => | |
| completedStatuses.includes(bug.status) | |
| ).length; | |
| }; | |
| const handleCreate = () => { | |
| navigate("/bugs/new"); | |
| }; | |
| const handleEdit = (bug: Task) => { | |
| navigate(`/bugs/${bug.id}/edit`); | |
| }; | |
| const handleViewDetails = (bug: Task) => { | |
| navigate(`/bugs/${bug.id}`); | |
| }; | |
| const handleDelete = async (id: number) => { | |
| try { | |
| setIsDeleting(true); | |
| setDeleteId(id); | |
| await tasksApi.delete(id); | |
| toast.success("Bug deleted successfully"); | |
| // Refresh the bug list | |
| const bugTasks = await tasksApi.getByType('Bug'); | |
| // Map tasks to include projectId | |
| const bugsWithProjectId = bugTasks.map(task => { | |
| return { ...task, projectId: task.issuesId } as Task; | |
| }) | |
| .filter(bug => { | |
| // If no user projects are loaded yet, don't filter | |
| if (userProjects.length === 0) return true; | |
| // Keep bugs that belong to the user's projects | |
| return bug.projectName && userProjects.some(p => p.projectName === bug.projectName); | |
| }); | |
| setBugs(bugsWithProjectId); | |
| } catch (error) { | |
| console.error("Error deleting bug:", error); | |
| toast.error("Failed to delete bug"); | |
| } finally { | |
| setIsDeleting(false); | |
| setDeleteId(null); | |
| } | |
| }; | |
| const getStatusBadge = (status: string) => { | |
| switch (status) { | |
| case "Completed": | |
| case "Closed": | |
| case "Resolved": | |
| return ( | |
| <Badge variant="outline" className="bg-green-50 text-green-700 border-green-200 flex items-center gap-1 px-2 font-medium"> | |
| <CheckCircle className="h-3 w-3" /> | |
| {status} | |
| </Badge> | |
| ); | |
| case "In Progress": | |
| return ( | |
| <Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200 flex items-center gap-1 px-2 font-medium"> | |
| <RefreshCw className="h-3 w-3" /> | |
| {status} | |
| </Badge> | |
| ); | |
| case "Re-Open": | |
| case "Reopened": | |
| return ( | |
| <Badge variant="outline" className="bg-orange-50 text-orange-700 border-orange-200 flex items-center gap-1 px-2 font-medium"> | |
| <RefreshCw className="h-3 w-3" /> | |
| {status} | |
| </Badge> | |
| ); | |
| case "In Review": | |
| case "Ready for QA": | |
| return ( | |
| <Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-200 flex items-center gap-1 px-2 font-medium"> | |
| <Clock className="h-3 w-3" /> | |
| {status} | |
| </Badge> | |
| ); | |
| case "Open": | |
| case "New": | |
| return ( | |
| <Badge variant="outline" className="bg-slate-50 text-slate-700 border-slate-200 flex items-center gap-1 px-2 font-medium"> | |
| <AlertCircle className="h-3 w-3" /> | |
| {status || "New"} | |
| </Badge> | |
| ); | |
| default: | |
| return ( | |
| <Badge variant="outline" className="bg-gray-50 text-gray-700 border-gray-200 flex items-center gap-1 px-2 font-medium"> | |
| <AlertCircle className="h-3 w-3" /> | |
| {status || "Unknown"} | |
| </Badge> | |
| ); | |
| } | |
| }; | |
| const getSeverityBadge = (severity: string) => { | |
| // Use our helper function if we're passed a bug object | |
| if (typeof severity === 'object') { | |
| severity = getBugSeverity(severity); | |
| } | |
| switch (severity) { | |
| case "Blocker": | |
| return ( | |
| <Badge variant="outline" className="bg-red-50 border-red-200 text-red-700 flex items-center gap-1 px-2 font-medium"> | |
| <ShieldAlert className="h-3 w-3" /> | |
| {severity} | |
| </Badge> | |
| ); | |
| case "Critical": | |
| return ( | |
| <Badge variant="outline" className="bg-rose-50 border-rose-200 text-rose-700 flex items-center gap-1 px-2 font-medium"> | |
| <FlameIcon className="h-3 w-3" /> | |
| {severity} | |
| </Badge> | |
| ); | |
| case "Major": | |
| case "High": | |
| return ( | |
| <Badge variant="outline" className="bg-amber-50 border-amber-200 text-amber-700 flex items-center gap-1 px-2 font-medium"> | |
| <AlertCircle className="h-3 w-3" /> | |
| {severity} | |
| </Badge> | |
| ); | |
| case "Minor": | |
| case "Medium": | |
| return ( | |
| <Badge variant="outline" className="bg-indigo-50 border-indigo-200 text-indigo-700 flex items-center gap-1 px-2 font-medium"> | |
| <Check className="h-3 w-3" /> | |
| {severity} | |
| </Badge> | |
| ); | |
| case "Trivial": | |
| case "Low": | |
| return ( | |
| <Badge variant="outline" className="bg-green-50 border-green-200 text-green-600 flex items-center gap-1 px-2 font-medium"> | |
| <Check className="h-3 w-3" /> | |
| {severity} | |
| </Badge> | |
| ); | |
| default: | |
| return ( | |
| <Badge variant="outline" className="bg-gray-50 border-gray-200 text-gray-600 flex items-center gap-1 px-2 font-medium"> | |
| {severity || "Unknown"} | |
| </Badge> | |
| ); | |
| } | |
| }; | |
| const handleSortChange = (field: string) => { | |
| if (field === sortField) { | |
| // If clicking the same field, toggle the sort order | |
| setSortOrder(sortOrder === "asc" ? "desc" : "asc"); | |
| } else { | |
| // If clicking a different field, set it as the new sort field and default to ascending | |
| setSortField(field); | |
| setSortOrder("asc"); | |
| } | |
| }; | |
| const renderSortIcon = (field: string) => { | |
| if (field !== sortField) return null; | |
| return ( | |
| <span className="ml-1"> | |
| {sortOrder === "asc" ? "↑" : "↓"} | |
| </span> | |
| ); | |
| }; | |
| // Add useEffect to track filter changes | |
| useEffect(() => { | |
| console.log("Filters changed:", { statusFilter, severityFilter, assigneeFilter, projectFilter }); | |
| }, [statusFilter, severityFilter, assigneeFilter, projectFilter]); | |
| // Update filter handlers to ensure state update triggers re-render | |
| const handleStatusFilterChange = (value: string) => { | |
| console.log("Status filter changing to:", value); | |
| setStatusFilter(value); | |
| }; | |
| const handleSeverityFilterChange = (value: string) => { | |
| console.log("Severity filter changing to:", value); | |
| setSeverityFilter(value); | |
| }; | |
| const handleAssigneeFilterChange = (value: string | number) => { | |
| console.log("Assignee filter changing to:", value); | |
| setAssigneeFilter(value === "all" ? "all" : value.toString()); | |
| }; | |
| const handleProjectFilterChange = (value: string) => { | |
| console.log(`Project filter changed to: ${value}`); | |
| setProjectFilter(value); | |
| console.log(`Setting project filter to: ${value}`); | |
| }; | |
| // Add new function to handle quick status change | |
| const handleStatusChange = async (bug: Task, newStatus: string) => { | |
| if (isUpdatingStatus) return; | |
| try { | |
| setIsUpdatingStatus(true); | |
| setUpdateStatusId(bug.id); | |
| // Create updated bug object with new status | |
| const updatedBug: Task = { | |
| ...bug, | |
| status: newStatus | |
| }; | |
| // Call API to update the bug | |
| await tasksApi.update(bug.id, updatedBug); | |
| // Update local state | |
| setBugs(prevBugs => | |
| prevBugs.map(b => | |
| b.id === bug.id ? { ...b, status: newStatus } : b | |
| ) | |
| ); | |
| toast.success(`Bug status updated to ${newStatus}`); | |
| } catch (error) { | |
| console.error("Error updating bug status:", error); | |
| toast.error("Failed to update status"); | |
| } finally { | |
| setIsUpdatingStatus(false); | |
| setUpdateStatusId(null); | |
| } | |
| }; | |
| // We don't need a separate fetchBugs function anymore as it's handled in the loadData function | |
| // But we need a refresh function for the refresh button | |
| const handleRefreshBugs = async () => { | |
| try { | |
| setIsLoading(true); | |
| // Only refresh the bugs without reloading team data | |
| const bugTasks = await tasksApi.getByType('Bug'); | |
| console.log(`DEBUG: Refreshed ${bugTasks.length} bug tasks from API`); | |
| // Map tasks to include projectId | |
| const bugsWithProjectId = bugTasks.map(task => { | |
| return { ...task, projectId: task.issuesId || null } as Task; | |
| }); | |
| // Don't filter by user projects, show all bugs | |
| console.log(`DEBUG: Mapped ${bugsWithProjectId.length} bugs with project IDs`); | |
| setBugs(bugsWithProjectId); | |
| toast.success("Bug list refreshed"); | |
| } catch (error) { | |
| console.error("Error refreshing bugs:", error); | |
| toast.error("Failed to refresh bug list"); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleQuickView = (bug: Task, e?: React.MouseEvent) => { | |
| if (e) { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| } | |
| setSelectedBugForQuickView(bug); | |
| setIsQuickViewOpen(true); | |
| }; | |
| // Export functionality | |
| const handleExportToExcel = () => { | |
| try { | |
| console.log("handleExportToExcel function called"); | |
| // Get filtered bugs data | |
| const filteredBugs = filteredAndSortedBugs(); | |
| console.log(`Found ${filteredBugs.length} bugs to export to Excel`); | |
| if (filteredBugs.length === 0) { | |
| toast.error("No bugs to export"); | |
| return; | |
| } | |
| // Format data for Excel - include all available columns | |
| const excelData = filteredBugs.map(bug => { | |
| // Cast bug to Task for accessing specific properties | |
| const bugTask = bug as Task; | |
| return { | |
| 'Bug ID': bug.taskCode || `BUG-${bug.id}`, | |
| 'Title': bug.title || '', | |
| 'Status': bug.status || '', | |
| 'Severity': getBugSeverity(bug) || '', | |
| 'Priority': bug.priority || '', | |
| 'Assignee': bug.assignedTo ? getEmployeeName(bug.assignedTo) : 'Unassigned', | |
| 'Created By': bugTask.createdByName || 'N/A', | |
| 'Project': bugTask.projectName || (bugTask.projectId ? projects[bugTask.projectId as number] || `Project ID: ${bugTask.projectId}` : 'N/A'), | |
| 'Issue': bugTask.issueName || 'N/A', | |
| 'Deliverable': bugTask.deliverableName || 'N/A', | |
| 'Description': bug.description || '', | |
| 'Steps to Reproduce': bugTask.stepsToReproduce || '', | |
| 'Expected Behavior': bugTask.expectedBehavior || '', | |
| 'Actual Behavior': bugTask.actualBehavior || '', | |
| 'Related URL': bugTask.relatedUrl || '', | |
| 'Version': bug.version || '', | |
| 'Created Date': bug.createdAt ? format(new Date(bug.createdAt), "MM/dd/yyyy") : 'N/A' | |
| }; | |
| }); | |
| console.log("Creating Excel workbook directly"); | |
| // Create workbook directly | |
| const worksheet = XLSX.utils.json_to_sheet(excelData); | |
| const workbook = XLSX.utils.book_new(); | |
| XLSX.utils.book_append_sheet(workbook, worksheet, "Bugs"); | |
| // Set column widths | |
| const colWidths = [ | |
| { wch: 15 }, // Bug ID | |
| { wch: 50 }, // Title | |
| { wch: 15 }, // Status | |
| { wch: 15 }, // Severity | |
| { wch: 15 }, // Priority | |
| { wch: 25 }, // Assignee | |
| { wch: 25 }, // Created By | |
| { wch: 25 }, // Project | |
| { wch: 25 }, // Issue | |
| { wch: 25 }, // Deliverable | |
| { wch: 80 }, // Description | |
| { wch: 80 }, // Steps to Reproduce | |
| { wch: 50 }, // Expected Behavior | |
| { wch: 50 }, // Actual Behavior | |
| { wch: 40 }, // Related URL | |
| { wch: 15 }, // Version | |
| { wch: 15 } // Created Date | |
| ]; | |
| worksheet['!cols'] = colWidths; | |
| // Generate Excel file directly | |
| const filename = `bug_list_${format(new Date(), 'yyyy-MM-dd')}.xlsx`; | |
| console.log(`Generating Excel file: ${filename}`); | |
| // Use writeFile to directly download the file | |
| XLSX.writeFile(workbook, filename); | |
| console.log("Excel export completed successfully"); | |
| toast.success("Bugs exported to Excel successfully"); | |
| } catch (error) { | |
| console.error('Error exporting to Excel:', error); | |
| toast.error('Failed to export bugs to Excel'); | |
| } | |
| }; | |
| const handleExportToPDF = () => { | |
| try { | |
| console.log("handleExportToPDF function called"); | |
| // Get filtered bugs data | |
| const filteredBugs = filteredAndSortedBugs(); | |
| console.log(`Found ${filteredBugs.length} bugs to export to PDF`); | |
| if (filteredBugs.length === 0) { | |
| toast.error("No bugs to export"); | |
| return; | |
| } | |
| // Create a new PDF document | |
| console.log("Creating new jsPDF document"); | |
| const doc = new jsPDF('landscape'); | |
| // Add title | |
| doc.setFontSize(18); | |
| doc.text('Bug Report', 14, 22); | |
| // Add generated date | |
| doc.setFontSize(10); | |
| doc.text(`Generated on: ${format(new Date(), 'MM/dd/yyyy HH:mm')}`, 14, 30); | |
| // Filter and status info | |
| const filterInfo = []; | |
| if (statusFilter !== 'all') filterInfo.push(`Status: ${statusFilter}`); | |
| if (severityFilter !== 'all') filterInfo.push(`Severity: ${severityFilter}`); | |
| if (assigneeFilter !== 'all') filterInfo.push(`Assignee: ${employees.find(emp => emp.id.toString() === assigneeFilter)?.firstName || assigneeFilter}`); | |
| if (projectFilter !== 'all') filterInfo.push(`Project: ${projectFilter}`); | |
| if (filterInfo.length > 0) { | |
| doc.text(`Filters: ${filterInfo.join(', ')}`, 14, 36); | |
| } | |
| // Define table columns - add new fields | |
| const tableColumn = ["Bug ID", "Title", "Status", "Severity", "Assignee", "Created By", "Project", "Created Date"]; | |
| // Create data for the table - simplified for better PDF display | |
| const tableRows = filteredBugs.map(bug => { | |
| const bugTask = bug as Task; | |
| return [ | |
| bug.taskCode || `BUG-${bug.id}`, | |
| bug.title || '', // Full title without trimming | |
| bug.status || '', | |
| getBugSeverity(bug) || '', | |
| bug.assignedTo ? getEmployeeName(bug.assignedTo) : 'Unassigned', | |
| bugTask.createdByName || 'N/A', | |
| bugTask.projectName || (bugTask.projectId ? projects[bugTask.projectId as number] || `Project ID: ${bugTask.projectId}` : 'N/A'), | |
| bug.createdAt ? format(new Date(bug.createdAt), "MM/dd/yyyy") : 'N/A', | |
| ]; | |
| }); | |
| // Check if autoTable is available | |
| if (typeof autoTable !== 'function') { | |
| console.error("autoTable is not properly loaded - check the jspdf-autotable import"); | |
| throw new Error("autoTable is not properly loaded - check the jspdf-autotable import"); | |
| } | |
| // Get page dimensions | |
| const pageWidth = doc.internal.pageSize.getWidth(); | |
| const pageHeight = doc.internal.pageSize.getHeight(); | |
| // Define minimal margins for maximum usable width | |
| const margin = 10; | |
| console.log("Adding table to PDF document"); | |
| try { | |
| // Use the autoTable function directly | |
| autoTable(doc, { | |
| head: [tableColumn], | |
| body: tableRows, | |
| startY: 45, | |
| theme: 'grid', | |
| styles: { | |
| fontSize: 8, | |
| cellPadding: 2, | |
| overflow: 'linebreak', // This setting will wrap long text | |
| lineWidth: 0.1, | |
| halign: 'left', // Horizontal alignment | |
| }, | |
| // Set column widths to undefined to allow autoTable to calculate | |
| // optimal widths for filling the whole page width | |
| columnStyles: {}, | |
| headStyles: { | |
| fillColor: [66, 66, 124], | |
| textColor: 255, | |
| fontStyle: 'bold' | |
| }, | |
| alternateRowStyles: { fillColor: [240, 240, 240] }, | |
| // Set margins to use maximum space | |
| margin: { top: 45, left: margin, right: margin, bottom: 20 }, | |
| // Set to 'auto' to use full available width | |
| tableWidth: 'auto', | |
| didDrawPage: (data) => { | |
| // Add page number at the bottom | |
| doc.setFontSize(8); | |
| doc.text(`Page ${data.pageNumber}`, margin, pageHeight - 10); | |
| } | |
| }); | |
| } catch (tableError) { | |
| console.error("Error creating table in PDF:", tableError); | |
| throw tableError; | |
| } | |
| // Create filename | |
| const filename = `bug_report_${format(new Date(), 'yyyy-MM-dd')}.pdf`; | |
| console.log(`Saving PDF as: ${filename}`); | |
| // Save the PDF directly | |
| try { | |
| doc.save(filename); | |
| console.log("PDF saved successfully"); | |
| } catch (saveError) { | |
| console.error("Error saving PDF:", saveError); | |
| throw saveError; | |
| } | |
| toast.success('Bugs exported to PDF successfully'); | |
| } catch (error) { | |
| console.error('Error exporting to PDF:', error); | |
| toast.error('Failed to export bugs to PDF: ' + (error instanceof Error ? error.message : 'Unknown error')); | |
| } | |
| }; | |
| // Test function to see if file download works | |
| const testFileDownload = () => { | |
| try { | |
| console.log("Testing file download"); | |
| // Create a simple text file | |
| const content = "This is a test file to verify if download works"; | |
| const blob = new Blob([content], { type: 'text/plain' }); | |
| // Create download link | |
| const url = window.URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = 'test-download.txt'; | |
| // Add to DOM, click, and remove | |
| document.body.appendChild(a); | |
| a.click(); | |
| // Clean up | |
| setTimeout(() => { | |
| window.URL.revokeObjectURL(url); | |
| document.body.removeChild(a); | |
| console.log("Download test completed"); | |
| }, 100); | |
| toast.success("Test download initiated"); | |
| } catch (error) { | |
| console.error("Error in test download:", error); | |
| toast.error("Failed to test download"); | |
| } | |
| }; | |
| // Add a custom export dropdown component | |
| function ExportDropdown() { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const dropdownRef = useRef<HTMLDivElement>(null); | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsOpen(!isOpen); | |
| }; | |
| const handleOptionClick = (action: () => void) => { | |
| // Close dropdown first | |
| setIsOpen(false); | |
| // Then execute the action with a small delay | |
| setTimeout(() => { | |
| action(); | |
| }, 50); | |
| }; | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!isOpen) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) { | |
| setIsOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [isOpen]); | |
| return ( | |
| <div className="relative" ref={dropdownRef}> | |
| <Button | |
| variant="outline" | |
| className="gap-1" | |
| onClick={handleButtonClick} | |
| > | |
| <Download className="h-4 w-4" /> | |
| Export | |
| <ChevronDown className={cn("h-4 w-4 opacity-50", isOpen && "transform rotate-180")} /> | |
| </Button> | |
| {isOpen && ( | |
| <div | |
| className="absolute right-0 z-50 mt-1 min-w-[160px] rounded-md border bg-popover p-1 text-popover-foreground shadow-md" | |
| > | |
| <div | |
| className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground" | |
| onClick={() => handleOptionClick(() => { | |
| console.log("Excel export clicked"); | |
| handleExportToExcel(); | |
| })} | |
| > | |
| <FileSpreadsheet className="mr-2 h-4 w-4 text-green-600" /> | |
| Excel | |
| </div> | |
| <div | |
| className="flex cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground" | |
| onClick={() => handleOptionClick(() => { | |
| console.log("PDF export clicked"); | |
| handleExportToPDF(); | |
| })} | |
| > | |
| <FileText className="mr-2 h-4 w-4 text-red-600" /> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| return ( | |
| <> | |
| {/* Quick View Dialog - Using custom modal */} | |
| <CustomModal | |
| isOpen={isQuickViewOpen} | |
| onClose={() => setIsQuickViewOpen(false)} | |
| title={selectedBugForQuickView?.title} | |
| > | |
| {selectedBugForQuickView && ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center gap-2 text-muted-foreground mb-2"> | |
| <span className="text-xs font-mono bg-muted px-2 py-1 rounded"> | |
| {selectedBugForQuickView.taskCode || `BUG-${selectedBugForQuickView.id}`} | |
| </span> | |
| {selectedBugForQuickView.createdAt && ( | |
| <span className="text-xs"> | |
| Created on {format(new Date(selectedBugForQuickView.createdAt), "MMMM d, yyyy")} | |
| </span> | |
| )} | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-3 mb-3"> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Status</p> | |
| {getStatusBadge(selectedBugForQuickView.status)} | |
| </div> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Severity</p> | |
| {getSeverityBadge(getBugSeverity(selectedBugForQuickView))} | |
| </div> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Version</p> | |
| <p className="font-mono text-xs">{selectedBugForQuickView.version || "N/A"}</p> | |
| </div> | |
| </div> | |
| <div className="border-t pt-3 mb-3"> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Assignee</p> | |
| {selectedBugForQuickView.assignedTo ? ( | |
| <div className="flex items-center gap-2"> | |
| <Avatar className="h-6 w-6 bg-indigo-100"> | |
| <AvatarFallback className="text-xs bg-indigo-500 text-white"> | |
| {getEmployeeInitials(selectedBugForQuickView.assignedTo)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="text-sm"> | |
| {getEmployeeName(selectedBugForQuickView.assignedTo)} | |
| </span> | |
| </div> | |
| ) : ( | |
| <span className="text-muted-foreground text-sm">Unassigned</span> | |
| )} | |
| </div> | |
| </div> | |
| <div className="border-t pt-3 mb-3"> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Description</p> | |
| <div className="text-sm bg-muted/50 p-3 rounded-md whitespace-pre-wrap text-sm"> | |
| {selectedBugForQuickView.description || "No description provided."} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Steps to Reproduce section */} | |
| <div className="border-t pt-3 mb-3"> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Steps to Reproduce</p> | |
| <div className="text-sm bg-muted/50 p-3 rounded-md"> | |
| {selectedBugForQuickView.stepsToReproduce ? ( | |
| <ol className="list-decimal pl-5 space-y-1 text-sm"> | |
| {selectedBugForQuickView.stepsToReproduce | |
| .split('\n') | |
| .filter(Boolean) | |
| .map((step, index) => ( | |
| <li key={index}>{step.trim()}</li> | |
| ))} | |
| </ol> | |
| ) : ( | |
| <span className="text-muted-foreground text-sm">No steps to reproduce provided</span> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| {/* Expected Behavior section */} | |
| <div className="border-t pt-3 mb-3"> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Expected Behavior</p> | |
| <div className="text-sm bg-muted/50 p-3 rounded-md whitespace-pre-wrap"> | |
| {selectedBugForQuickView.expectedBehavior || | |
| <span className="text-muted-foreground text-sm">No expected behavior provided</span> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| {/* Actual Behavior section */} | |
| <div className="border-t pt-3 mb-3"> | |
| <div className="space-y-1"> | |
| <p className="text-xs font-medium text-muted-foreground">Actual Behavior</p> | |
| <div className="text-sm bg-muted/50 p-3 rounded-md whitespace-pre-wrap"> | |
| {selectedBugForQuickView.actualBehavior || | |
| <span className="text-muted-foreground text-sm">No actual behavior provided</span> | |
| } | |
| </div> | |
| </div> | |
| </div> | |
| <div className="border-t pt-3 flex flex-wrap gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => handleViewDetails(selectedBugForQuickView)} | |
| className="h-8" | |
| > | |
| <ExternalLink className="h-3.5 w-3.5 mr-1.5" /> | |
| Open Full View | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => { | |
| setIsQuickViewOpen(false); | |
| handleEdit(selectedBugForQuickView); | |
| }} | |
| className="h-8" | |
| > | |
| <Edit2 className="h-3.5 w-3.5 mr-1.5" /> | |
| Edit Bug | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => { | |
| setIsQuickViewOpen(false); | |
| setSelectedBugForTimeLog(selectedBugForQuickView); | |
| setIsLoggingTime(true); | |
| }} | |
| className="h-8" | |
| > | |
| <Timer className="h-3.5 w-3.5 mr-1.5" /> | |
| Log Time | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </CustomModal> | |
| {/* Time Logging Modal - Mobile Responsive */} | |
| {isLoggingTime && selectedBugForTimeLog && ( | |
| <div | |
| className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-3 sm:p-4" | |
| onClick={() => { | |
| // Only close if clicking the backdrop | |
| setIsLoggingTime(false); | |
| setSelectedBugForTimeLog(null); | |
| setTimeLogHours(""); | |
| setRemainingHours(""); | |
| setTimeLogDescription(""); | |
| setStartTime(""); | |
| setEndTime(""); | |
| setWorkDate(new Date()); | |
| }} | |
| > | |
| <div | |
| className="bg-background rounded-lg shadow-lg w-full max-w-md max-h-[90vh] overflow-y-auto p-4 sm:p-6 relative" | |
| onClick={(e) => e.stopPropagation()} | |
| style={{ zIndex: 10000 }} | |
| > | |
| {/* Close button in the corner */} | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="absolute right-2 top-2" | |
| onClick={() => { | |
| setIsLoggingTime(false); | |
| setSelectedBugForTimeLog(null); | |
| setTimeLogHours(""); | |
| setRemainingHours(""); | |
| setTimeLogDescription(""); | |
| setStartTime(""); | |
| setEndTime(""); | |
| setWorkDate(new Date()); | |
| }} | |
| > | |
| <X className="h-4 w-4" /> | |
| </Button> | |
| <div className="mb-4"> | |
| <h3 className="text-base sm:text-lg font-semibold mb-1">Log Time</h3> | |
| <p className="text-sm text-muted-foreground line-clamp-2">{selectedBugForTimeLog.title}</p> | |
| </div> | |
| <div className="space-y-4"> | |
| <div> | |
| <label className="text-sm font-medium mb-2 block">Work Date</label> | |
| <div className="space-y-3"> | |
| <div className="grid grid-cols-2 gap-2"> | |
| <Button | |
| variant={workDate && isSameDay(workDate, getYesterday()) ? "default" : "outline"} | |
| className="w-full justify-center text-xs sm:text-sm h-9" | |
| onClick={() => { | |
| const yesterday = getYesterday(); | |
| setWorkDate(yesterday); | |
| }} | |
| type="button" | |
| > | |
| Yesterday | |
| </Button> | |
| <Button | |
| variant={workDate && isSameDay(workDate, new Date()) ? "default" : "outline"} | |
| className="w-full justify-center text-xs sm:text-sm h-9" | |
| onClick={() => { | |
| const today = new Date(); | |
| setWorkDate(today); | |
| }} | |
| type="button" | |
| > | |
| Today | |
| </Button> | |
| </div> | |
| <Popover> | |
| <PopoverTrigger asChild> | |
| <Button | |
| variant="outline" | |
| className={cn( | |
| "w-full justify-start text-left font-normal h-9 text-sm", | |
| !workDate && "text-muted-foreground" | |
| )} | |
| > | |
| <CalendarIcon className="mr-2 h-4 w-4" /> | |
| {workDate ? format(workDate, "PPP") : <span>Pick a date</span>} | |
| </Button> | |
| </PopoverTrigger> | |
| <PopoverContent className="w-auto p-0"> | |
| <Calendar | |
| mode="single" | |
| selected={workDate} | |
| onSelect={setWorkDate} | |
| initialFocus | |
| /> | |
| </PopoverContent> | |
| </Popover> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> | |
| <div className="space-y-2" style={{ zIndex: 45, position: 'relative' }}> | |
| <label className="text-sm font-medium block">Start Time</label> | |
| <Dropdown | |
| options={timeOptions} | |
| value={startTime} | |
| onValueChange={(value) => { | |
| setStartTime(value); | |
| if (endTime) { | |
| // Calculate hours spent between start and end | |
| const hoursSpent = calculateHoursSpent(value, endTime); | |
| setTimeLogHours(hoursSpent); | |
| } | |
| }} | |
| placeholder="Select start time" | |
| className="w-full text-sm" | |
| /> | |
| </div> | |
| <div className="space-y-2" style={{ zIndex: 40, position: 'relative' }}> | |
| <label className="text-sm font-medium block">End Time</label> | |
| <Dropdown | |
| options={timeOptions} | |
| value={endTime} | |
| onValueChange={(value) => { | |
| setEndTime(value); | |
| if (startTime) { | |
| // Calculate hours spent between start and end | |
| const hoursSpent = calculateHoursSpent(startTime, value); | |
| setTimeLogHours(hoursSpent); | |
| } | |
| }} | |
| placeholder="Select end time" | |
| className="w-full text-sm" | |
| /> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4"> | |
| <div className="space-y-2"> | |
| <label className="text-sm font-medium block">Hours Spent</label> | |
| <Input | |
| type="number" | |
| min="0" | |
| step="0.5" | |
| value={timeLogHours} | |
| onChange={(e) => setTimeLogHours(e.target.value)} | |
| placeholder="Hours worked" | |
| className="text-sm h-9" | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-sm font-medium block">Hours Remaining</label> | |
| <Input | |
| type="number" | |
| min="0" | |
| step="0.5" | |
| value={remainingHours} | |
| onChange={(e) => setRemainingHours(e.target.value)} | |
| placeholder="Hours remaining" | |
| className="text-sm h-9" | |
| /> | |
| </div> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-sm font-medium block">Description</label> | |
| <Textarea | |
| value={timeLogDescription} | |
| onChange={(e) => setTimeLogDescription(e.target.value)} | |
| placeholder="What did you work on?" | |
| rows={3} | |
| className="text-sm resize-none" | |
| /> | |
| </div> | |
| <Button | |
| className="w-full h-10 bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-700 hover:to-indigo-700 text-white text-sm font-medium" | |
| onClick={() => handleTimeLogSubmit(selectedBugForTimeLog.id)} | |
| > | |
| <Clock className="h-4 w-4 mr-2" /> Log Time | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| <Layout> | |
| <TooltipProvider> | |
| <DropdownProvider> | |
| <div className="pb-6 w-full"> | |
| <div className="bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-gray-900 dark:to-gray-800 border-b"> | |
| <div className="flex flex-col gap-4 px-3 sm:px-4 md:px-6 py-4 sm:py-6"> | |
| <div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 sm:gap-4"> | |
| <div className="flex-1 min-w-0"> | |
| <h1 className="text-2xl sm:text-3xl font-bold tracking-tight flex items-center gap-2 mb-1"> | |
| <Bug className="h-6 w-6 sm:h-7 sm:w-7 text-indigo-600 flex-shrink-0" /> | |
| <span className="truncate">Bug Tracker</span> | |
| </h1> | |
| <p className="text-muted-foreground text-sm sm:text-base max-w-lg"> | |
| Track, manage, and resolve bugs and issues across your projects | |
| </p> | |
| </div> | |
| {/* Mobile-first action buttons */} | |
| <div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 sm:gap-2 flex-shrink-0"> | |
| <ImportTasksDialog | |
| taskType="Bug" | |
| projectId={undefined} | |
| onImportComplete={handleRefreshBugs} | |
| /> | |
| <Button | |
| onClick={handleCreate} | |
| className="bg-blue-600 hover:bg-blue-700 shadow-sm w-full sm:w-auto order-1 sm:order-3" | |
| size="sm" | |
| > | |
| <Plus className="mr-2 h-4 w-4" /> | |
| <span className="sm:hidden">New Bug</span> | |
| <span className="hidden sm:inline">New Bug</span> | |
| </Button> | |
| {/* Export dropdown - hidden on mobile, shown on larger screens */} | |
| <div className="relative hidden sm:block order-2 sm:order-4"> | |
| <ExportDropdown /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* View mode and scope filters - stacked on mobile */} | |
| <div className="flex flex-col sm:flex-row gap-3 sm:gap-4"> | |
| {/* View mode toggle */} | |
| <div className="bg-background/80 backdrop-blur-sm border shadow-sm p-1 rounded-lg flex w-full sm:w-auto"> | |
| <Button | |
| variant={viewMode === "dashboard" ? "default" : "ghost"} | |
| size="sm" | |
| onClick={() => setViewMode("dashboard")} | |
| className={cn( | |
| "flex-1 sm:flex-initial text-xs sm:text-sm", | |
| viewMode === "dashboard" ? "bg-white dark:bg-gray-800 shadow-sm text-primary" : "" | |
| )} | |
| > | |
| <LayoutDashboard className="h-3 w-3 sm:h-4 sm:w-4 mr-1" /> | |
| Dashboard | |
| </Button> | |
| <Button | |
| variant={viewMode === "list" ? "default" : "ghost"} | |
| size="sm" | |
| onClick={() => setViewMode("list")} | |
| className={cn( | |
| "flex-1 sm:flex-initial text-xs sm:text-sm", | |
| viewMode === "list" ? "bg-white dark:bg-gray-800 shadow-sm text-primary" : "" | |
| )} | |
| > | |
| <LayoutList className="h-3 w-3 sm:h-4 sm:w-4 mr-1" /> | |
| List | |
| </Button> | |
| </div> | |
| {/* Scope filter buttons */} | |
| <div className="bg-background/80 backdrop-blur-sm border shadow-sm p-1 rounded-lg flex w-full sm:w-auto"> | |
| <Button | |
| variant={scopeFilter === "my" ? "default" : "ghost"} | |
| size="sm" | |
| onClick={() => setScopeFilter("my")} | |
| className={cn( | |
| "flex-1 sm:flex-initial text-xs sm:text-sm", | |
| scopeFilter === "my" ? "bg-white dark:bg-gray-800 shadow-sm text-primary" : "" | |
| )} | |
| > | |
| <User className="h-3 w-3 sm:h-4 sm:w-4 mr-1" /> | |
| My Bugs | |
| </Button> | |
| <Button | |
| variant={scopeFilter === "all" ? "default" : "ghost"} | |
| size="sm" | |
| onClick={() => setScopeFilter("all")} | |
| className={cn( | |
| "flex-1 sm:flex-initial text-xs sm:text-sm", | |
| scopeFilter === "all" ? "bg-white dark:bg-gray-800 shadow-sm text-primary" : "" | |
| )} | |
| > | |
| <ListFilter className="h-3 w-3 sm:h-4 sm:w-4 mr-1" /> | |
| All Bugs | |
| </Button> | |
| </div> | |
| {/* Export dropdown for mobile - shown only on small screens */} | |
| <div className="relative sm:hidden"> | |
| <ExportDropdown /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Dashboard View */} | |
| {viewMode === "dashboard" && ( | |
| <div className="px-4 md:px-6 pb-4"> | |
| <DashboardBugs bugs={bugs} employees={employees} /> | |
| </div> | |
| )} | |
| {/* List View */} | |
| {viewMode === "list" && ( | |
| <div className="flex flex-col space-y-4 px-4 md:px-6 pt-4 overflow-visible"> | |
| {/* Scope header - mobile responsive */} | |
| <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 py-2"> | |
| <h2 className="text-lg sm:text-xl font-semibold flex items-center gap-2"> | |
| {scopeFilter === "my" ? ( | |
| <> | |
| <User className="h-4 w-4 sm:h-5 sm:w-5 text-indigo-600" /> | |
| My Bugs | |
| </> | |
| ) : ( | |
| <> | |
| <ListFilter className="h-4 w-4 sm:h-5 sm:w-5 text-indigo-600" /> | |
| All Bugs | |
| </> | |
| )} | |
| </h2> | |
| <div className="text-sm text-muted-foreground"> | |
| {filteredAndSortedBugs().length} {filteredAndSortedBugs().length === 1 ? 'bug' : 'bugs'} | |
| </div> | |
| </div> | |
| <Card className="shadow-sm"> | |
| <CardHeader className="pb-3 border-b"> | |
| <div className="flex flex-col gap-3"> | |
| <div className="flex items-center justify-between"> | |
| <CardTitle | |
| className="flex items-center gap-2 cursor-pointer text-base sm:text-lg" | |
| onClick={() => setFiltersExpanded(!filtersExpanded)} | |
| > | |
| <Filter className="h-4 w-4 sm:h-5 sm:w-5 text-indigo-500" /> | |
| Filter & Search | |
| <ChevronRight className={`h-3 w-3 sm:h-4 sm:w-4 transition-transform ${filtersExpanded ? 'rotate-90' : ''}`} /> | |
| </CardTitle> | |
| {/* Refresh button - always visible */} | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={handleRefreshBugs} | |
| disabled={isLoading} | |
| title="Refresh bug list" | |
| className="h-8 w-8 p-0 sm:h-9 sm:w-9" | |
| > | |
| {isLoading ? ( | |
| <div className="h-3 w-3 sm:h-4 sm:w-4 border-2 border-current border-t-transparent rounded-full animate-spin" /> | |
| ) : ( | |
| <RefreshCw className="h-3 w-3 sm:h-4 sm:w-4" /> | |
| )} | |
| </Button> | |
| </div> | |
| {/* Search input - full width on mobile */} | |
| <div className="relative w-full"> | |
| <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> | |
| <Input | |
| type="search" | |
| placeholder="Search bugs..." | |
| className="pl-8 bg-background/50 border-muted text-sm sm:text-base" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| /> | |
| </div> | |
| </div> | |
| </CardHeader> | |
| {/* Conditionally render card content based on expanded state */} | |
| {filtersExpanded && ( | |
| <CardContent className="pt-4"> | |
| <div className="space-y-4"> | |
| {/* Filter dropdowns - mobile-first responsive grid */} | |
| <div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 sm:gap-4"> | |
| <div className="space-y-2"> | |
| <label className="text-xs sm:text-sm font-medium pl-1 text-muted-foreground">Project</label> | |
| <Dropdown | |
| options={[ | |
| { label: "All Projects", value: "all" }, | |
| // Get distinct project names from both projects object and bugs | |
| ...Array.from( | |
| new Set([ | |
| ...Object.values(projects), | |
| ...bugs | |
| .filter(bug => bug.projectName) | |
| .map(bug => bug.projectName as string) | |
| ]) | |
| ).sort().map(name => ({ | |
| label: name, | |
| value: name | |
| })) | |
| ]} | |
| value={projectFilter} | |
| onValueChange={handleProjectFilterChange} | |
| placeholder="Filter by Project" | |
| className="w-full text-sm" | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-xs sm:text-sm font-medium pl-1 text-muted-foreground">Status</label> | |
| <Dropdown | |
| options={[ | |
| { label: "All Statuses", value: "all" }, | |
| { label: "Open", value: "Open" }, | |
| { label: "In Progress", value: "In Progress" }, | |
| { label: "In Review", value: "In Review" }, | |
| { label: "Ready for QA", value: "Ready for QA" }, | |
| { label: "Resolved", value: "Resolved" }, | |
| { label: "Closed", value: "Closed" } | |
| ]} | |
| value={statusFilter} | |
| onValueChange={handleStatusFilterChange} | |
| placeholder="Status" | |
| className="w-full text-sm" | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-xs sm:text-sm font-medium pl-1 text-muted-foreground">Severity</label> | |
| <Dropdown | |
| options={[ | |
| { label: "All Severities", value: "all" }, | |
| { label: "Critical", value: "Critical" }, | |
| { label: "High", value: "High" }, | |
| { label: "Medium", value: "Medium" }, | |
| { label: "Low", value: "Low" } | |
| ]} | |
| value={severityFilter} | |
| onValueChange={handleSeverityFilterChange} | |
| placeholder="Severity" | |
| className="w-full text-sm" | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <label className="text-xs sm:text-sm font-medium pl-1 text-muted-foreground">Assignee</label> | |
| <Dropdown | |
| options={[ | |
| { label: "All Assignees", value: "all" }, | |
| ...employees.map(employee => ({ // Use all employees instead of team members | |
| label: `${employee.firstName} ${employee.lastName}`, | |
| value: employee.id.toString() | |
| })) | |
| ]} | |
| value={assigneeFilter} | |
| onValueChange={handleAssigneeFilterChange} | |
| placeholder="Assignee" | |
| className="w-full text-sm" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Active Filters - mobile-friendly layout */} | |
| {(statusFilter !== "all" || severityFilter !== "all" || assigneeFilter !== "all" || projectFilter !== "all" || searchQuery || scopeFilter !== "all") && ( | |
| <div className="mt-4 border-t pt-4 space-y-3"> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-xs sm:text-sm font-medium text-muted-foreground">Active filters:</span> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="text-xs sm:text-sm h-7 px-2" | |
| onClick={() => { | |
| setSearchQuery(""); | |
| setStatusFilter("all"); | |
| setSeverityFilter("all"); | |
| setAssigneeFilter("all"); | |
| setProjectFilter("all"); | |
| }} | |
| > | |
| <X className="h-3 w-3 mr-1" /> | |
| Clear All | |
| </Button> | |
| </div> | |
| <div className="flex flex-wrap gap-1.5 sm:gap-2"> | |
| {scopeFilter !== "all" && ( | |
| <Badge variant="outline" className="flex items-center gap-1 bg-indigo-50 text-indigo-700 hover:bg-indigo-100 px-2 py-1 border-indigo-200 text-xs"> | |
| <span className="font-medium">Scope:</span> My Bugs | |
| <X | |
| className="h-3 w-3 ml-1 cursor-pointer" | |
| onClick={() => setScopeFilter("all")} | |
| /> | |
| </Badge> | |
| )} | |
| {statusFilter !== "all" && ( | |
| <Badge variant="outline" className="flex items-center gap-1 bg-blue-50 text-blue-700 hover:bg-blue-100 px-2 py-1 border-blue-200 text-xs"> | |
| <span className="font-medium">Status:</span> {statusFilter} | |
| <X | |
| className="h-3 w-3 ml-1 cursor-pointer" | |
| onClick={() => setStatusFilter("all")} | |
| /> | |
| </Badge> | |
| )} | |
| {severityFilter !== "all" && ( | |
| <Badge variant="outline" className="flex items-center gap-1 bg-orange-50 text-orange-700 hover:bg-orange-100 px-2 py-1 border-orange-200 text-xs"> | |
| <span className="font-medium">Severity:</span> {severityFilter} | |
| <X | |
| className="h-3 w-3 ml-1 cursor-pointer" | |
| onClick={() => setSeverityFilter("all")} | |
| /> | |
| </Badge> | |
| )} | |
| {projectFilter !== "all" && ( | |
| <Badge variant="outline" className="flex items-center gap-1 bg-green-50 text-green-700 hover:bg-green-100 px-2 py-1 border-green-200 text-xs"> | |
| <span className="font-medium">Project:</span> | |
| <span className="truncate max-w-[100px]">{projectFilter}</span> | |
| <X | |
| className="h-3 w-3 ml-1 cursor-pointer flex-shrink-0" | |
| onClick={() => setProjectFilter("all")} | |
| /> | |
| </Badge> | |
| )} | |
| {assigneeFilter !== "all" && ( | |
| <Badge variant="outline" className="flex items-center gap-1 bg-purple-50 text-purple-700 hover:bg-purple-100 px-2 py-1 border-purple-200 text-xs"> | |
| <span className="font-medium">Assignee:</span> | |
| <span className="truncate max-w-[80px]"> | |
| {employees.find(emp => emp.id.toString() === assigneeFilter)?.firstName || `ID: ${assigneeFilter}`} | |
| </span> | |
| <X | |
| className="h-3 w-3 ml-1 cursor-pointer flex-shrink-0" | |
| onClick={() => setAssigneeFilter("all")} | |
| /> | |
| </Badge> | |
| )} | |
| {searchQuery && ( | |
| <Badge variant="outline" className="flex items-center gap-1 bg-gray-50 text-gray-700 hover:bg-gray-100 px-2 py-1 border-gray-200 text-xs"> | |
| <span className="font-medium">Search:</span> | |
| <span className="truncate max-w-[100px]">{searchQuery}</span> | |
| <X | |
| className="h-3 w-3 ml-1 cursor-pointer flex-shrink-0" | |
| onClick={() => setSearchQuery("")} | |
| /> | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| )} | |
| </Card> | |
| {isLoading ? ( | |
| <div className="flex items-center justify-center py-16"> | |
| <div className="flex flex-col items-center gap-3"> | |
| <div className="h-10 w-10 rounded-full border-t-2 border-indigo-600 animate-spin"></div> | |
| <p className="text-sm text-muted-foreground">Loading Bugs...</p> | |
| </div> | |
| </div> | |
| ) : filteredAndSortedBugs().length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-16 text-center"> | |
| <div className="h-16 w-16 rounded-full bg-indigo-50 flex items-center justify-center mb-4"> | |
| <Bug className="h-8 w-8 text-indigo-600" /> | |
| </div> | |
| <h3 className="text-xl font-medium mb-2">No Bugs found</h3> | |
| <p className="text-muted-foreground mb-6 max-w-md"> | |
| {searchQuery || statusFilter !== "all" || severityFilter !== "all" || assigneeFilter !== "all" || projectFilter !== "all" | |
| ? "Try adjusting your filters to see more results." | |
| : "Create a new Bug to start tracking bugs and problems in your project."} | |
| </p> | |
| {searchQuery || statusFilter !== "all" || severityFilter !== "all" || assigneeFilter !== "all" || projectFilter !== "all" ? ( | |
| <Button | |
| variant="outline" | |
| className="bg-background" | |
| onClick={() => { | |
| setSearchQuery(""); | |
| setStatusFilter("all"); | |
| setSeverityFilter("all"); | |
| setAssigneeFilter("all"); | |
| setProjectFilter("all"); | |
| }} | |
| > | |
| <X className="mr-2 h-4 w-4" /> | |
| Clear All Filters | |
| </Button> | |
| ) : ( | |
| <Button onClick={handleCreate} className="bg-indigo-600 hover:bg-indigo-700"> | |
| <Plus className="mr-2 h-4 w-4" /> | |
| Create New Bug | |
| </Button> | |
| )} | |
| </div> | |
| ) : ( | |
| <> | |
| {/* Mobile summary cards - optimized for touch */} | |
| <div className="md:hidden mt-4 space-y-3"> | |
| {filteredAndSortedBugs() | |
| .filter(shouldShowBug) | |
| .map((bug, index) => ( | |
| <Card | |
| key={bug.id} | |
| className={`shadow-sm overflow-hidden transition-all duration-200 active:scale-[0.98] ${index % 4 === 0 ? 'border-l-4 border-l-blue-500' : | |
| index % 4 === 1 ? 'border-l-4 border-l-purple-500' : | |
| index % 4 === 2 ? 'border-l-4 border-l-amber-500' : | |
| 'border-l-4 border-l-green-500' | |
| }`} | |
| onClick={() => handleViewDetails(bug)} | |
| > | |
| <div className="p-3 sm:p-4 cursor-pointer"> | |
| {/* Header with title and status */} | |
| <div className="flex justify-between items-start mb-3 gap-2"> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-center gap-2 mb-1"> | |
| <span className="text-xs font-bold text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | |
| #{index + 1} | |
| </span> | |
| <span className="text-xs font-mono bg-muted px-1.5 py-0.5 rounded"> | |
| {bug.taskCode || `BUG-${bug.id}`} | |
| </span> | |
| </div> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <h3 className="font-medium text-sm leading-tight line-clamp-2 text-foreground cursor-pointer"> | |
| {bug.title} | |
| </h3> | |
| </TooltipTrigger> | |
| <TooltipContent side="top" className="max-w-sm z-50"> | |
| <p className="text-sm whitespace-normal">{bug.title}</p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| {/* Status dropdown - more touch-friendly */} | |
| <div onClick={(e) => e.stopPropagation()} className="flex-shrink-0"> | |
| <div className="min-w-[100px]"> | |
| <CustomDropdown | |
| id={`status-dropdown-mobile-${bug.id}`} | |
| value={bug.status} | |
| onChange={(newStatus) => handleStatusChange(bug, newStatus)} | |
| options={statusOptions.map(status => ({ value: status, label: status }))} | |
| placeholder="Select status" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Bug details grid */} | |
| <div className="space-y-2 mb-3"> | |
| <div className="flex items-center justify-between"> | |
| <div className="flex items-center gap-1"> | |
| <span className="text-xs font-medium text-muted-foreground">Severity:</span> | |
| {getSeverityBadge(getBugSeverity(bug))} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| {bug.createdAt ? format(new Date(bug.createdAt), "MMM d") : "N/A"} | |
| </div> | |
| </div> | |
| {(bug as Task).projectName && ( | |
| <div className="flex items-center gap-1"> | |
| <span className="text-xs font-medium text-muted-foreground">Project:</span> | |
| <span className="text-xs truncate flex-1 text-foreground">{(bug as Task).projectName}</span> | |
| </div> | |
| )} | |
| {(bug as Task).issueName && ( | |
| <div className="flex items-center gap-1"> | |
| <span className="text-xs font-medium text-muted-foreground">Issue:</span> | |
| <span className="text-xs truncate flex-1 text-foreground">{(bug as Task).issueName}</span> | |
| </div> | |
| )} | |
| <div className="flex items-center gap-1"> | |
| <span className="text-xs font-medium text-muted-foreground">Assignee:</span> | |
| {bug.assignedTo ? ( | |
| <div className="flex items-center gap-1.5"> | |
| <Avatar className="h-4 w-4"> | |
| <AvatarFallback className="text-[8px] bg-indigo-500 text-white"> | |
| {getEmployeeInitials(bug.assignedTo)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="text-xs truncate max-w-[120px] text-foreground"> | |
| {getEmployeeName(bug.assignedTo)} | |
| </span> | |
| </div> | |
| ) : ( | |
| <span className="text-xs text-muted-foreground">Unassigned</span> | |
| )} | |
| </div> | |
| </div> | |
| {/* Action buttons - larger touch targets */} | |
| <div className="flex justify-between items-center pt-2 border-t"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 px-2 text-xs" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleQuickView(bug, e); | |
| }} | |
| > | |
| <Eye className="h-3.5 w-3.5 mr-1 text-indigo-500" /> | |
| View | |
| </Button> | |
| <div className="flex gap-1"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleEdit(bug); | |
| }} | |
| > | |
| <Edit2 className="h-3.5 w-3.5 text-amber-500" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setSelectedBugForTimeLog(bug); | |
| setIsLoggingTime(true); | |
| }} | |
| > | |
| <Timer className="h-3.5 w-3.5 text-blue-500" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-8 w-8 p-0" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleDelete(bug.id); | |
| }} | |
| disabled={isDeleting && deleteId === bug.id} | |
| > | |
| {isDeleting && deleteId === bug.id ? ( | |
| <div className="h-3.5 w-3.5 rounded-full border-b-2 border-red-600 animate-spin"></div> | |
| ) : ( | |
| <Trash2 className="h-3.5 w-3.5 text-red-500" /> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </Card> | |
| ))} | |
| </div> | |
| {/* Collapse completed bugs toggle - mobile responsive */} | |
| <div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-2 sm:gap-4 mt-4"> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setCollapseCompletedBugs(!collapseCompletedBugs)} | |
| className="flex items-center gap-1 text-xs sm:text-sm h-8" | |
| > | |
| <ChevronRight | |
| className={`h-3 w-3 sm:h-4 sm:w-4 transition-transform ${!collapseCompletedBugs ? 'rotate-90' : ''}`} | |
| /> | |
| {collapseCompletedBugs ? 'Show Completed' : 'Hide Completed'} | |
| </Button> | |
| {collapseCompletedBugs && getCompletedBugsCount() > 0 && ( | |
| <span className="text-xs text-muted-foreground"> | |
| ({getCompletedBugsCount()} completed {getCompletedBugsCount() === 1 ? 'bug' : 'bugs'} hidden) | |
| </span> | |
| )} | |
| </div> | |
| <div className="text-xs text-muted-foreground"> | |
| Total: {filteredAndSortedBugs().length} {filteredAndSortedBugs().length === 1 ? 'bug' : 'bugs'} | |
| </div> | |
| </div> | |
| {/* Desktop table view - hidden on small screens */} | |
| <div className="hidden md:block overflow-hidden rounded-md border mt-4 table-container"> | |
| <table className="w-full text-sm table-fixed"> | |
| <thead> | |
| <tr className="bg-gradient-to-r from-indigo-50 via-blue-50 to-indigo-50 border-b"> | |
| <th className="px-2 py-3 text-left font-semibold text-gray-700 w-[3%]">#</th> | |
| <th | |
| className="px-4 py-3 text-left font-semibold cursor-pointer text-gray-700 w-[20%]" | |
| onClick={() => handleSortChange("title")} | |
| > | |
| <div className="flex items-center"> | |
| Bug Title | |
| {renderSortIcon("title")} | |
| </div> | |
| </th> | |
| <th | |
| className="px-4 py-3 text-left font-semibold cursor-pointer hidden md:table-cell text-gray-700 w-[11%]" | |
| onClick={() => handleSortChange("status")} | |
| > | |
| <div className="flex items-center"> | |
| Status | |
| {renderSortIcon("status")} | |
| </div> | |
| </th> | |
| <th | |
| className="px-4 py-3 text-left font-semibold cursor-pointer hidden lg:table-cell text-gray-700 w-[10%]" | |
| onClick={() => handleSortChange("severity")} | |
| > | |
| <div className="flex items-center"> | |
| Severity | |
| {renderSortIcon("severity")} | |
| </div> | |
| </th> | |
| <th className="px-4 py-3 text-left font-semibold hidden sm:table-cell text-gray-700 w-[5%]"> | |
| ID | |
| </th> | |
| <th className="px-4 py-3 text-left font-semibold hidden md:table-cell text-gray-700 w-[11%]"> | |
| Project | |
| </th> | |
| <th | |
| className="px-4 py-3 text-left font-semibold cursor-pointer hidden lg:table-cell text-gray-700 w-[12%]" | |
| onClick={() => handleSortChange("assignedTo")} | |
| > | |
| <div className="flex items-center"> | |
| Assignee | |
| {renderSortIcon("assignedTo")} | |
| </div> | |
| </th> | |
| <th | |
| className="px-4 py-3 text-left font-semibold cursor-pointer hidden md:table-cell text-gray-700 w-[12%]" | |
| onClick={() => handleSortChange("createdAt")} | |
| > | |
| <div className="flex items-center"> | |
| Created By | |
| {renderSortIcon("createdAt")} | |
| </div> | |
| </th> | |
| <th className="px-4 py-3 text-right font-semibold text-gray-700 w-[16%]"> | |
| Actions | |
| </th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredAndSortedBugs() | |
| .filter(shouldShowBug) | |
| .map((bug, index) => ( | |
| <tr key={bug.id} className={`border-t hover:bg-accent/5 group ${index % 2 === 0 ? 'bg-muted/20' : ''}`}> | |
| <td className="px-2 py-3 text-center text-muted-foreground w-[3%]"> | |
| {index + 1} | |
| </td> | |
| <td className="px-4 py-3 font-medium w-[20%]"> | |
| <div | |
| className="flex items-center cursor-pointer" | |
| onClick={() => handleViewDetails(bug)} | |
| > | |
| <div className={`w-2 h-2 rounded-full mr-2 flex-shrink-0 ${bug.status === 'Completed' || bug.status === 'Closed' ? 'bg-green-500' : | |
| bug.status === 'In Progress' ? 'bg-blue-500' : | |
| bug.status === 'In Review' ? 'bg-purple-500' : | |
| bug.status === 'Reopened' ? 'bg-orange-500' : 'bg-gray-500' | |
| }`}></div> | |
| <Tooltip> | |
| <TooltipTrigger asChild> | |
| <span className="truncate underline-offset-2 group-hover:underline block"> | |
| {bug.title && bug.title.length > 10 ? `${bug.title.substring(0, 35)}...` : bug.title} | |
| </span> | |
| </TooltipTrigger> | |
| <TooltipContent side="top" className="max-w-md z-50"> | |
| <p className="text-sm whitespace-normal">{bug.title}</p> | |
| </TooltipContent> | |
| </Tooltip> | |
| </div> | |
| </td> | |
| <td className="px-4 py-3 hidden md:table-cell w-[11%]"> | |
| {/* Status badge with dropdown for desktop */} | |
| <CustomDropdown | |
| id={`status-dropdown-desktop-${bug.id}`} | |
| value={bug.status} | |
| onChange={(newStatus) => handleStatusChange(bug, newStatus)} | |
| options={statusOptions.map(status => ({ value: status, label: status }))} | |
| placeholder="Select status" | |
| /> | |
| </td> | |
| <td className="px-4 py-3 hidden lg:table-cell w-[10%]"> | |
| {getSeverityBadge(getBugSeverity(bug))} | |
| </td> | |
| <td className="px-4 py-3 hidden sm:table-cell w-[5%]"> | |
| <span className="text-xs font-mono bg-muted px-2 py-1 rounded"> | |
| {bug.taskCode || `BUG-${bug.id}`} | |
| </span> | |
| </td> | |
| <td className="px-4 py-3 hidden md:table-cell w-[11%]"> | |
| <span className="truncate inline-block"> | |
| {(bug as Task).projectName || 'N/A'} | |
| </span> | |
| </td> | |
| <td className="px-4 py-3 hidden lg:table-cell w-[12%]"> | |
| {bug.assignedTo ? ( | |
| <div className="flex items-center gap-2"> | |
| <Avatar className="h-6 w-6 bg-indigo-100 flex-shrink-0"> | |
| <AvatarFallback className="text-xs bg-indigo-500 text-white"> | |
| {getEmployeeInitials(bug.assignedTo)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="truncate"> | |
| {getEmployeeName(bug.assignedTo)} | |
| </span> | |
| </div> | |
| ) : ( | |
| <span className="text-muted-foreground">Unassigned</span> | |
| )} | |
| </td> | |
| <td className="px-4 py-3 text-muted-foreground hidden md:table-cell w-[12%]"> | |
| <div className="flex flex-col"> | |
| <span className="text-gray-900 font-medium truncate"> | |
| {(bug as Task).createdByName || 'N/A'} | |
| </span> | |
| <span className="text-xs"> | |
| {bug.createdAt ? format(new Date(bug.createdAt), "MMM d, yyyy") : "N/A"} | |
| </span> | |
| </div> | |
| </td> | |
| <td className="px-4 py-3 text-right w-[16%]"> | |
| <div className="flex items-center justify-end gap-1"> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-8 w-8 text-indigo-500 hover:text-indigo-600 hover:bg-indigo-50" | |
| onClick={(e) => handleQuickView(bug, e)} | |
| > | |
| <Eye className="h-4 w-4" /> | |
| <span className="sr-only">View details</span> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-8 w-8 text-amber-500 hover:text-amber-600 hover:bg-amber-50" | |
| onClick={() => handleEdit(bug)} | |
| > | |
| <Edit2 className="h-4 w-4" /> | |
| <span className="sr-only">Edit</span> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| setSelectedBugForTimeLog(bug); | |
| setIsLoggingTime(true); | |
| }} | |
| > | |
| <Timer className="h-4 w-4" /> | |
| <span className="sr-only">Log time</span> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50" | |
| onClick={() => handleDelete(bug.id)} | |
| disabled={isDeleting && deleteId === bug.id} | |
| > | |
| {isDeleting && deleteId === bug.id ? ( | |
| <div className="h-4 w-4 rounded-full border-b-2 border-red-600"></div> | |
| ) : ( | |
| <Trash2 className="h-4 w-4" /> | |
| )} | |
| <span className="sr-only">Delete</span> | |
| </Button> | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </> | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </DropdownProvider> | |
| </TooltipProvider> | |
| </Layout> | |
| </> | |
| ); | |
| }; | |
| export default Bugs; |