Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from "react"; | |
| import { cn } from "@/lib/utils"; | |
| import { | |
| ArrowRight, | |
| CheckCircle, | |
| Circle, | |
| Clock, | |
| Filter, | |
| MoreHorizontal, | |
| Plus, | |
| RefreshCw, | |
| Search, | |
| Pencil, | |
| Trash2, | |
| Triangle, | |
| Eye, | |
| XCircle, | |
| AlertCircle, | |
| FileCheck, | |
| User, | |
| Calendar | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Input } from "@/components/ui/input"; | |
| import { | |
| DropdownMenu, | |
| DropdownMenuContent, | |
| DropdownMenuItem, | |
| DropdownMenuSeparator, | |
| DropdownMenuTrigger, | |
| } from "@/components/ui/dropdown-menu"; | |
| import { | |
| Card, | |
| CardContent, | |
| CardHeader, | |
| CardTitle, | |
| } from "@/components/ui/card"; | |
| import { Dropdown, DropdownOption } from "@/components/ui/dropdown"; | |
| import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import Layout from "@/components/layout/layout"; | |
| import { issuesApi, projectService, deliverablesApi, employeeService, userSessionService } from "@/lib/api"; | |
| import { IssueApi, ProjectApi, Deliverable, Employee } from "@/types"; | |
| import { useNavigate } from "react-router-dom"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import { format } from "date-fns"; | |
| import ImportIssuesDialog from "@/components/issue/import-issues-dialog"; | |
| import { Switch } from "@/components/ui/switch"; | |
| import { Label } from "@/components/ui/label"; | |
| const Issues = () => { | |
| const navigate = useNavigate(); | |
| const [searchQuery, setSearchQuery] = useState(""); | |
| const [projectFilter, setProjectFilter] = useState<number | "all">("all"); | |
| const [statusFilter, setStatusFilter] = useState<string | "all">("all"); | |
| const [typeFilter, setTypeFilter] = useState<string | "all">("all"); | |
| const [priorityFilter, setPriorityFilter] = useState<string | "all">("all"); | |
| const [displayMode, setDisplayMode] = useState<"list" | "board">("list"); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [issues, setIssues] = useState<IssueApi[]>([]); | |
| const [allIssues, setAllIssues] = useState<IssueApi[]>([]); | |
| const [projects, setProjects] = useState<ProjectApi[]>([]); | |
| const [userProjects, setUserProjects] = useState<ProjectApi[]>([]); | |
| const [deliverables, setDeliverables] = useState<Deliverable[]>([]); | |
| const [employees, setEmployees] = useState<Employee[]>([]); | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| const [deleteId, setDeleteId] = useState<number | null>(null); | |
| // Get current user info | |
| const currentUser = userSessionService.getSession(); | |
| // Check if user has permission to view all projects | |
| // Only CEO, COO, admin, and Administrator roles can see all projects | |
| const canViewAllProjects = currentUser?.roleName && | |
| ['CEO', 'COO', 'Admin', 'Administrator'].includes(currentUser.roleName); | |
| // Check if user has permission to add/edit/delete issues | |
| // Only Project Manager and Business Analyst can add, edit or delete | |
| const canManageIssues = currentUser?.roleName && | |
| ['Project Manager', 'CEO', 'COO', 'Admin', 'Administrator', 'Sr. Project Manager','Business Analyst'].includes(currentUser.roleName); | |
| // By default, we only show user's projects | |
| // Admin users can toggle to see all projects | |
| const [showAllProjects, setShowAllProjects] = useState(false); | |
| const statusOptions = ["Open", "In Progress", "In Review", "Completed", "Closed"]; | |
| const priorityOptions = ["Low", "Medium", "High", "Critical"]; | |
| const typeOptions = ["User Story", "Bug", "Task", "Documentation", "Enhancement"]; | |
| // Load saved filters from localStorage when component mounts | |
| useEffect(() => { | |
| const savedFilters = localStorage.getItem('issuesPageFilters'); | |
| if (savedFilters) { | |
| try { | |
| const filters = JSON.parse(savedFilters); | |
| setSearchQuery(filters.searchQuery || ""); | |
| setProjectFilter(filters.projectFilter || "all"); | |
| setStatusFilter(filters.statusFilter || "all"); | |
| setTypeFilter(filters.typeFilter || "all"); | |
| setPriorityFilter(filters.priorityFilter || "all"); | |
| setDisplayMode(filters.displayMode || "list"); | |
| setShowAllProjects(filters.showAllProjects || false); | |
| } catch (error) { | |
| console.error("Error parsing saved filters:", error); | |
| // Clear corrupted filter data | |
| localStorage.removeItem('issuesPageFilters'); | |
| } | |
| } | |
| }, []); | |
| // Save filters to localStorage whenever they change | |
| useEffect(() => { | |
| const filtersToSave = { | |
| searchQuery, | |
| projectFilter, | |
| statusFilter, | |
| typeFilter, | |
| priorityFilter, | |
| displayMode, | |
| showAllProjects | |
| }; | |
| localStorage.setItem('issuesPageFilters', JSON.stringify(filtersToSave)); | |
| }, [ | |
| searchQuery, | |
| projectFilter, | |
| statusFilter, | |
| typeFilter, | |
| priorityFilter, | |
| displayMode, | |
| showAllProjects | |
| ]); | |
| useEffect(() => { | |
| fetchData(); | |
| }, [showAllProjects, canViewAllProjects]); | |
| // Set default view mode based on screen size | |
| useEffect(() => { | |
| const handleResize = () => { | |
| if (window.innerWidth < 768) { | |
| setDisplayMode("board"); | |
| } else { | |
| setDisplayMode("list"); | |
| } | |
| }; | |
| // Set initial value | |
| handleResize(); | |
| // Add event listener for window resize | |
| window.addEventListener("resize", handleResize); | |
| // Clean up event listener | |
| return () => window.removeEventListener("resize", handleResize); | |
| }, []); | |
| const fetchData = async () => { | |
| setIsLoading(true); | |
| try { | |
| // Get all issues first | |
| const issuesData = await issuesApi.getAll(); | |
| setAllIssues(issuesData); | |
| // Get all projects for reference | |
| const allProjectsData = await projectService.getAll(); | |
| setProjects(allProjectsData); | |
| // If user can view all projects and has toggled that option on | |
| if (canViewAllProjects && showAllProjects) { | |
| setIssues(issuesData); | |
| setUserProjects(allProjectsData); | |
| } | |
| // Otherwise show only user's projects (default for all users) | |
| else if (currentUser?.employeeId) { | |
| const userProjectsData = await projectService.getByEmployeeId(currentUser.employeeId); | |
| setUserProjects(userProjectsData); | |
| // Filter issues to only show those from user's projects | |
| const filteredIssues = issuesData.filter(issue => | |
| userProjectsData.some(project => project.id === issue.projectId) | |
| ); | |
| setIssues(filteredIssues); | |
| } else { | |
| // Fallback if somehow no employee ID | |
| setIssues(issuesData); | |
| setUserProjects(allProjectsData); | |
| } | |
| const [deliverablesData, employeesData] = await Promise.all([ | |
| deliverablesApi.getAll(), | |
| employeeService.getAll() | |
| ]); | |
| setDeliverables(deliverablesData); | |
| setEmployees(employeesData); | |
| } catch (error) { | |
| console.error("Error fetching data:", error); | |
| toast.error("Failed to load data. Please try again."); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const filteredIssues = issues.filter((issue) => { | |
| const matchesSearch = | |
| issue.title?.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| issue.description?.toLowerCase().includes(searchQuery.toLowerCase()) || | |
| issue.biCode?.toLowerCase().includes(searchQuery.toLowerCase()); | |
| const matchesProject = projectFilter === "all" || issue.projectId === projectFilter; | |
| const matchesType = typeFilter === "all" || issue.type === typeFilter; | |
| const matchesPriority = priorityFilter === "all" || issue.priority === priorityFilter; | |
| const matchesStatus = statusFilter === "all" || issue.status === statusFilter; | |
| return matchesSearch && matchesProject && matchesType && matchesPriority && matchesStatus; | |
| }); | |
| const handleDelete = async (id: number) => { | |
| if (isDeleting) return; | |
| setIsDeleting(true); | |
| setDeleteId(id); | |
| try { | |
| await issuesApi.delete(id); | |
| setIssues(issues.filter(issue => issue.id !== id)); | |
| toast.success("Features deleted successfully"); | |
| } catch (error) { | |
| console.error("Error deleting issue:", error); | |
| toast.error("Failed to delete issue. Please try again."); | |
| } finally { | |
| setIsDeleting(false); | |
| setDeleteId(null); | |
| } | |
| }; | |
| const getEmployeeName = (id: number) => { | |
| const employee = employees.find(emp => emp.id === id); | |
| if (!employee) return "Unknown"; | |
| return `${employee.firstName} ${employee.lastName}`; | |
| }; | |
| const getEmployeeInitials = (id: number) => { | |
| const employee = employees.find(emp => emp.id === id); | |
| if (!employee) return "?"; | |
| return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`; | |
| }; | |
| const getProjectName = (id: number) => { | |
| const project = projects.find(proj => proj.id === id); | |
| return project ? project.projectName : "Unknown Project"; | |
| }; | |
| const getDeliverableName = (id: number) => { | |
| const deliverable = deliverables.find(del => del.id === id); | |
| return deliverable ? deliverable.name : "Unknown Deliverable"; | |
| }; | |
| const getPriorityBadge = (priority: string) => { | |
| let color = ""; | |
| let bgColor = ""; | |
| let icon = null; | |
| switch (priority.toLowerCase()) { | |
| case "low": | |
| color = "text-blue-700"; | |
| bgColor = "bg-blue-50"; | |
| icon = <ArrowRight className="h-3 w-3 mr-1" />; | |
| break; | |
| case "medium": | |
| color = "text-yellow-700"; | |
| bgColor = "bg-yellow-50"; | |
| icon = <Clock className="h-3 w-3 mr-1" />; | |
| break; | |
| case "high": | |
| color = "text-orange-700"; | |
| bgColor = "bg-orange-50"; | |
| icon = <Triangle className="h-3 w-3 mr-1" />; | |
| break; | |
| case "critical": | |
| color = "text-red-700"; | |
| bgColor = "bg-red-50"; | |
| icon = <Triangle className="h-3 w-3 mr-1" fill="currentColor" />; | |
| break; | |
| default: | |
| color = "text-gray-700"; | |
| bgColor = "bg-gray-50"; | |
| icon = <Circle className="h-3 w-3 mr-1" />; | |
| } | |
| return ( | |
| <Badge variant="outline" className={cn("font-medium flex items-center gap-1 px-2 border-0", bgColor, color)}> | |
| {icon} | |
| {priority} | |
| </Badge> | |
| ); | |
| }; | |
| const getTypeBadge = (type: string) => { | |
| let color = ""; | |
| let bgColor = ""; | |
| let icon = null; | |
| switch (type.toLowerCase()) { | |
| case "bug": | |
| color = "text-red-700"; | |
| bgColor = "bg-red-50"; | |
| icon = <Triangle className="h-3 w-3 mr-1" fill="currentColor" />; | |
| break; | |
| case "feature": | |
| case "user story": | |
| color = "text-emerald-700"; | |
| bgColor = "bg-emerald-50"; | |
| icon = <CheckCircle className="h-3 w-3 mr-1" />; | |
| break; | |
| case "task": | |
| color = "text-blue-700"; | |
| bgColor = "bg-blue-50"; | |
| icon = <Circle className="h-3 w-3 mr-1" />; | |
| break; | |
| case "enhancement": | |
| color = "text-purple-700"; | |
| bgColor = "bg-purple-50"; | |
| icon = <ArrowRight className="h-3 w-3 mr-1" />; | |
| break; | |
| case "documentation": | |
| color = "text-gray-700"; | |
| bgColor = "bg-gray-50"; | |
| icon = <Circle className="h-3 w-3 mr-1" />; | |
| break; | |
| default: | |
| color = "text-gray-700"; | |
| bgColor = "bg-gray-50"; | |
| icon = <Circle className="h-3 w-3 mr-1" />; | |
| } | |
| return ( | |
| <Badge variant="outline" className={cn("font-medium flex items-center gap-1 px-2 border-0", bgColor, color)}> | |
| {icon} | |
| {type} | |
| </Badge> | |
| ); | |
| }; | |
| const getStatusBadge = (status: string) => { | |
| let color = ""; | |
| let bgColor = ""; | |
| let icon = null; | |
| switch (status?.toLowerCase()) { | |
| case "open": | |
| color = "text-blue-700"; | |
| bgColor = "bg-blue-50"; | |
| icon = <AlertCircle className="h-3 w-3 mr-1" />; | |
| break; | |
| case "in progress": | |
| color = "text-yellow-700"; | |
| bgColor = "bg-yellow-50"; | |
| icon = <Clock className="h-3 w-3 mr-1" />; | |
| break; | |
| case "in review": | |
| color = "text-purple-700"; | |
| bgColor = "bg-purple-50"; | |
| icon = <Eye className="h-3 w-3 mr-1" />; | |
| break; | |
| case "completed": | |
| color = "text-emerald-700"; | |
| bgColor = "bg-emerald-50"; | |
| icon = <CheckCircle className="h-3 w-3 mr-1" />; | |
| break; | |
| case "closed": | |
| color = "text-gray-700"; | |
| bgColor = "bg-gray-50"; | |
| icon = <XCircle className="h-3 w-3 mr-1" />; | |
| break; | |
| default: | |
| color = "text-gray-700"; | |
| bgColor = "bg-gray-50"; | |
| icon = <Circle className="h-3 w-3 mr-1" />; | |
| } | |
| return ( | |
| <Badge variant="outline" className={cn("font-medium flex items-center gap-1 px-2 border-0", bgColor, color)}> | |
| {icon} | |
| {status || "Unknown"} | |
| </Badge> | |
| ); | |
| }; | |
| return ( | |
| <Layout> | |
| <div className="container mx-auto py-4 md:py-6 px-3 md:px-6 max-w-7xl"> | |
| <div className="flex flex-col space-y-4 md:space-y-6"> | |
| <div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3 sm:gap-0"> | |
| <h1 className="text-xl md:text-2xl font-bold">Feature Management</h1> | |
| <div className="flex items-center space-x-2 w-full sm:w-auto"> | |
| {canManageIssues && ( | |
| <> | |
| <ImportIssuesDialog | |
| projectId={projectFilter !== "all" ? Number(projectFilter) : undefined} | |
| onImportComplete={fetchData} | |
| /> | |
| <Button | |
| onClick={() => navigate('/new-issue')} | |
| className="flex items-center flex-1 sm:flex-none justify-center" | |
| size="sm" | |
| aria-label="Create new Feature" | |
| > | |
| <Plus className="h-4 w-4 mr-2" /> | |
| <span className="sm:block">New Feature</span> | |
| </Button> | |
| </> | |
| )} | |
| <Button | |
| variant="outline" | |
| size="icon" | |
| onClick={fetchData} | |
| disabled={isLoading} | |
| className="h-9 w-9" | |
| aria-label="Refresh Features" | |
| > | |
| <RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} /> | |
| </Button> | |
| </div> | |
| </div> | |
| <div className="bg-card border rounded-lg p-3 md:p-4 shadow-sm"> | |
| <div className="flex flex-col space-y-3 md:space-y-4"> | |
| <div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-3 md:gap-4"> | |
| <div className="relative w-full max-w-full md:max-w-md"> | |
| <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" /> | |
| <Input | |
| placeholder="Search Features..." | |
| className="pl-9 w-full md:w-96" | |
| value={searchQuery} | |
| onChange={(e) => setSearchQuery(e.target.value)} | |
| /> | |
| </div> | |
| <div className="flex items-center gap-2 w-full md:w-auto self-end md:self-auto"> | |
| {canViewAllProjects && ( | |
| <div className="flex items-center space-x-2 mr-4"> | |
| <div className="flex items-center space-x-2"> | |
| <Switch | |
| id="all-projects" | |
| checked={showAllProjects} | |
| onCheckedChange={setShowAllProjects} | |
| /> | |
| <Label htmlFor="all-projects" className="cursor-pointer flex items-center"> | |
| <User className="h-3.5 w-3.5 mr-1" /> | |
| <span className="text-sm">All Projects</span> | |
| </Label> | |
| </div> | |
| </div> | |
| )} | |
| <div className="hidden md:flex bg-muted rounded-md p-1 w-full md:w-auto"> | |
| <Button | |
| variant={displayMode === "list" ? "secondary" : "ghost"} | |
| size="sm" | |
| onClick={() => setDisplayMode("list")} | |
| className="rounded-sm flex-1 md:flex-none" | |
| aria-label="List view" | |
| > | |
| List | |
| </Button> | |
| <Button | |
| variant={displayMode === "board" ? "secondary" : "ghost"} | |
| size="sm" | |
| onClick={() => setDisplayMode("board")} | |
| className="rounded-sm flex-1 md:flex-none" | |
| aria-label="Board view" | |
| > | |
| Board | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-2 md:grid-cols-5 gap-2 md:gap-3"> | |
| <Dropdown | |
| options={[ | |
| { label: "All Projects", value: "all" }, | |
| ...projects.map(project => ({ | |
| label: project.projectName, | |
| value: project.id.toString() | |
| })) | |
| ]} | |
| value={projectFilter.toString()} | |
| onValueChange={(value) => setProjectFilter(value === "all" ? "all" : parseInt(value))} | |
| placeholder="Project" | |
| className="w-full" | |
| /> | |
| <Dropdown | |
| options={[ | |
| { label: "All Types", value: "all" }, | |
| ...typeOptions.map(type => ({ | |
| label: type, | |
| value: type | |
| })) | |
| ]} | |
| value={typeFilter.toString()} | |
| onValueChange={(value) => setTypeFilter(value)} | |
| placeholder="Type" | |
| className="w-full" | |
| /> | |
| <Dropdown | |
| options={[ | |
| { label: "All Priorities", value: "all" }, | |
| ...priorityOptions.map(priority => ({ | |
| label: priority, | |
| value: priority | |
| })) | |
| ]} | |
| value={priorityFilter.toString()} | |
| onValueChange={(value) => setPriorityFilter(value)} | |
| placeholder="Priority" | |
| className="w-full" | |
| /> | |
| <Dropdown | |
| options={[ | |
| { label: "All Statuses", value: "all" }, | |
| ...statusOptions.map(status => ({ | |
| label: status, | |
| value: status | |
| })) | |
| ]} | |
| value={statusFilter.toString()} | |
| onValueChange={(value) => setStatusFilter(value)} | |
| placeholder="Status" | |
| className="w-full" | |
| /> | |
| <Button | |
| variant="outline" | |
| className="w-full col-span-2 md:col-span-1" | |
| size="sm" | |
| onClick={() => { | |
| setSearchQuery(""); | |
| setProjectFilter("all"); | |
| setTypeFilter("all"); | |
| setPriorityFilter("all"); | |
| setStatusFilter("all"); | |
| // Also clear the saved filters in localStorage | |
| localStorage.removeItem('issuesPageFilters'); | |
| }} | |
| > | |
| Clear Filters | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| {isLoading ? ( | |
| <div className="flex flex-col justify-center items-center h-60 md:h-80"> | |
| <div className="animate-spin rounded-full h-10 w-10 md:h-12 md:w-12 border-4 border-primary border-t-transparent mb-3 md:mb-4"></div> | |
| <p className="text-sm md:text-base text-muted-foreground">Loading Features...</p> | |
| </div> | |
| ) : filteredIssues.length === 0 ? ( | |
| <Card className="border shadow-sm"> | |
| <CardContent className="flex flex-col items-center justify-center py-10 md:py-16"> | |
| <div className="rounded-full bg-muted p-3 md:p-4 mb-3 md:mb-4"> | |
| <Search className="h-6 w-6 md:h-8 md:w-8 text-muted-foreground" /> | |
| </div> | |
| <h3 className="text-lg md:text-xl font-medium mb-1 md:mb-2">No Features found</h3> | |
| <p className="text-sm text-muted-foreground text-center max-w-md mb-4 md:mb-6 px-2"> | |
| We couldn't find any Features matching your current filters or search criteria. | |
| </p> | |
| <Button | |
| onClick={() => { | |
| setSearchQuery(""); | |
| setProjectFilter("all"); | |
| setTypeFilter("all"); | |
| setPriorityFilter("all"); | |
| setStatusFilter("all"); | |
| }} | |
| size="sm" | |
| className="px-3 md:px-4" | |
| > | |
| Reset all filters | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| ) : displayMode === "list" ? ( | |
| <Card className="border shadow-sm overflow-hidden"> | |
| <CardHeader className="py-3 md:py-4 bg-muted/50"> | |
| <CardTitle className="text-base md:text-lg font-medium flex flex-col sm:flex-row sm:justify-between sm:items-center gap-1"> | |
| <span>Features ({filteredIssues.length})</span> | |
| <span className="text-xs sm:text-sm font-normal text-muted-foreground"> | |
| {canViewAllProjects && showAllProjects | |
| ? "Showing all Features" | |
| : "Showing only your project Features"} ({filteredIssues.length} of {allIssues.length}) | |
| </span> | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="p-0"> | |
| <div className="overflow-x-auto -mx-3 sm:mx-0"> | |
| <table className="w-full border-collapse min-w-[640px]"> | |
| <thead> | |
| <tr className="border-b bg-muted/30 text-left"> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground whitespace-nowrap">#</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">Title</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">Project</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">Type</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">Priority</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">Status</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">Assigned To</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">Start Date</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground">End Date</th> | |
| <th className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm font-medium text-muted-foreground text-right">Actions</th> | |
| </tr> | |
| </thead> | |
| <tbody> | |
| {filteredIssues.map((issue, index) => ( | |
| <tr key={issue.id} | |
| className="border-b hover:bg-muted/30 cursor-pointer transition-colors" | |
| onClick={() => navigate(`/issues/${issue.id}`)} | |
| > | |
| <td className="py-2 md:py-3 px-3 md:px-4 text-xs md:text-sm text-muted-foreground font-mono">{index + 1}</td> | |
| <td className="py-2 md:py-3 px-3 md:px-4"> | |
| <div className="text-sm md:font-medium">{issue.title}</div> | |
| <div className="text-xs text-muted-foreground mt-0.5 md:mt-1 line-clamp-1"> | |
| {issue.description} | |
| </div> | |
| </td> | |
| <td className="py-2 md:py-3 px-3 md:px-4"> | |
| <div className="flex flex-col"> | |
| <span className="text-xs md:text-sm">{getProjectName(issue.projectId)}</span> | |
| <span className="text-xs text-muted-foreground">{getDeliverableName(issue.deliverablesId)}</span> | |
| </div> | |
| </td> | |
| <td className="py-2 md:py-3 px-3 md:px-4">{getTypeBadge(issue.type)}</td> | |
| <td className="py-2 md:py-3 px-3 md:px-4">{getPriorityBadge(issue.priority)}</td> | |
| <td className="py-2 md:py-3 px-3 md:px-4">{getStatusBadge(issue.status)}</td> | |
| <td className="py-2 md:py-3 px-3 md:px-4"> | |
| <div className="flex items-center"> | |
| <Avatar className="h-6 w-6 md:h-7 md:w-7 mr-2 border"> | |
| <AvatarFallback>{getEmployeeInitials(issue.assignedTo)}</AvatarFallback> | |
| </Avatar> | |
| <span className="text-xs md:text-sm">{getEmployeeName(issue.assignedTo)}</span> | |
| </div> | |
| </td> | |
| <td className="py-2 md:py-3 px-3 md:px-4"> | |
| <span className="text-xs md:text-sm text-muted-foreground"> | |
| {issue.startDate ? format(new Date(issue.startDate), "MMM d, yyyy") : "-"} | |
| </span> | |
| </td> | |
| <td className="py-2 md:py-3 px-3 md:px-4"> | |
| <span className="text-xs md:text-sm text-muted-foreground"> | |
| {issue.endDate ? format(new Date(issue.endDate), "MMM d, yyyy") : "-"} | |
| </span> | |
| </td> | |
| <td className="py-2 md:py-3 px-3 md:px-4"> | |
| <div className="flex items-center justify-end space-x-1" onClick={(e) => e.stopPropagation()}> | |
| {canManageIssues ? ( | |
| <> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| navigate(`/issues/edit/${issue.id}`); | |
| }} | |
| className="h-7 w-7 md:h-8 md:w-8" | |
| > | |
| <Pencil className="h-3 w-3 md:h-4 md:w-4" /> | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleDelete(issue.id); | |
| }} | |
| disabled={isDeleting && deleteId === issue.id} | |
| className="h-7 w-7 md:h-8 md:w-8" | |
| > | |
| {isDeleting && deleteId === issue.id ? ( | |
| <div className="h-3 w-3 md:h-4 md:w-4 rounded-full border-2 border-b-transparent border-primary animate-spin"></div> | |
| ) : ( | |
| <Trash2 className="h-3 w-3 md:h-4 md:w-4 text-destructive" /> | |
| )} | |
| </Button> | |
| </> | |
| ) : ( | |
| <span className="text-xs text-muted-foreground">No actions</span> | |
| )} | |
| </div> | |
| </td> | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-3 md:gap-5"> | |
| {filteredIssues.map((issue) => ( | |
| <Card | |
| key={issue.id} | |
| className="overflow-hidden border hover:shadow-md transition-all hover:-translate-y-1 cursor-pointer group" | |
| onClick={() => navigate(`/issues/${issue.id}`)} | |
| > | |
| <div className="h-1.5" style={{ | |
| backgroundColor: issue.priority.toLowerCase() === 'critical' ? 'rgb(239, 68, 68)' : | |
| issue.priority.toLowerCase() === 'high' ? 'rgb(249, 115, 22)' : | |
| issue.priority.toLowerCase() === 'medium' ? 'rgb(234, 179, 8)' : | |
| 'rgb(59, 130, 246)' | |
| }}></div> | |
| <CardHeader className="p-3 pb-1.5 md:p-4 md:pb-2"> | |
| <div className="flex justify-between items-start"> | |
| <div className="flex flex-col"> | |
| <div className="flex flex-wrap gap-1.5 md:gap-2 mb-1.5 items-center"> | |
| {getTypeBadge(issue.type)} | |
| {getPriorityBadge(issue.priority)} | |
| </div> | |
| <CardTitle className="text-sm md:text-base font-semibold mb-1 group-hover:text-primary transition-colors"> | |
| {issue.title} | |
| </CardTitle> | |
| <div className="flex items-center text-xs text-muted-foreground gap-1.5 md:gap-2"> | |
| <span className="font-mono">#{issue.id}</span> | |
| <span>•</span> | |
| <span>{getProjectName(issue.projectId)}</span> | |
| </div> | |
| </div> | |
| <DropdownMenu> | |
| <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> | |
| <Button variant="ghost" size="icon" className="h-7 w-7 md:h-8 md:w-8 opacity-0 group-hover:opacity-100 transition-opacity"> | |
| <MoreHorizontal className="h-3.5 w-3.5 md:h-4 md:w-4" /> | |
| </Button> | |
| </DropdownMenuTrigger> | |
| <DropdownMenuContent align="end"> | |
| <DropdownMenuItem | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| navigate(`/issues/${issue.id}`); | |
| }} | |
| > | |
| <Eye className="mr-2 h-4 w-4" /> View Details | |
| </DropdownMenuItem> | |
| {canManageIssues && ( | |
| <> | |
| <DropdownMenuItem | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| navigate(`/issues/edit/${issue.id}`); | |
| }} | |
| > | |
| <Pencil className="mr-2 h-4 w-4" /> Edit | |
| </DropdownMenuItem> | |
| <DropdownMenuSeparator /> | |
| <DropdownMenuItem | |
| className="text-destructive" | |
| onClick={(e) => { | |
| e.stopPropagation(); | |
| handleDelete(issue.id); | |
| }} | |
| disabled={isDeleting && deleteId === issue.id} | |
| > | |
| <Trash2 className="mr-2 h-4 w-4" /> | |
| {isDeleting && deleteId === issue.id ? "Deleting..." : "Delete"} | |
| </DropdownMenuItem> | |
| </> | |
| )} | |
| </DropdownMenuContent> | |
| </DropdownMenu> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="p-3 pt-1.5 md:p-4 md:pt-2"> | |
| <div className="mb-2 md:mb-3">{getStatusBadge(issue.status)}</div> | |
| <p className="text-xs md:text-sm text-muted-foreground line-clamp-2 mb-4 md:mb-6 min-h-[32px] md:min-h-[40px]"> | |
| {issue.description || "No description provided"} | |
| </p> | |
| {(issue.startDate || issue.endDate) && ( | |
| <div className="flex items-center gap-3 mb-3 text-xs text-muted-foreground"> | |
| {issue.startDate && ( | |
| <div className="flex items-center gap-1"> | |
| <Calendar className="h-3 w-3" /> | |
| <span>Start: {format(new Date(issue.startDate), "MMM d, yyyy")}</span> | |
| </div> | |
| )} | |
| {issue.endDate && ( | |
| <div className="flex items-center gap-1"> | |
| <Calendar className="h-3 w-3" /> | |
| <span>End: {format(new Date(issue.endDate), "MMM d, yyyy")}</span> | |
| </div> | |
| )} | |
| </div> | |
| )} | |
| <div className="flex justify-between items-center mt-auto border-t pt-2 md:pt-3"> | |
| <div className="flex items-center"> | |
| <Avatar className="h-6 w-6 md:h-7 md:w-7 border"> | |
| <AvatarFallback>{getEmployeeInitials(issue.assignedTo)}</AvatarFallback> | |
| </Avatar> | |
| <div className="ml-2"> | |
| <div className="text-[10px] md:text-xs text-muted-foreground">Assigned to</div> | |
| <div className="text-xs md:text-sm font-medium">{getEmployeeName(issue.assignedTo)}</div> | |
| </div> | |
| </div> | |
| <div> | |
| <div className="text-[10px] md:text-xs text-muted-foreground text-right">Reported by</div> | |
| <div className="text-xs md:text-sm text-right">{getEmployeeName(issue.reportedBy)}</div> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| </Layout> | |
| ); | |
| }; | |
| export default Issues; | |