Spaces:
Sleeping
Sleeping
| import React, { useState, useEffect } from "react"; | |
| import { useNavigate, useParams, Link } from "react-router-dom"; | |
| import { tasksApi, Task, TaskCreateRequest } from "@/services/tasksApi"; | |
| import { issuesApi } from "@/lib/api"; | |
| import { projectService } from "@/lib/api"; | |
| import { IssueApi } from "@/types"; | |
| import { ProjectApi } from "@/types"; | |
| import { toast } from "sonner"; | |
| import { | |
| ChevronRight, | |
| Home, | |
| Save, | |
| Bug, | |
| ArrowLeft, | |
| AlertTriangle, | |
| Check, | |
| ChevronDown, | |
| User, | |
| Lock | |
| } from "lucide-react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | |
| import { Input } from "@/components/ui/input"; | |
| import { Label } from "@/components/ui/label"; | |
| import { Textarea } from "@/components/ui/textarea"; | |
| import { | |
| Select, | |
| SelectContent, | |
| SelectItem, | |
| SelectTrigger, | |
| SelectValue, | |
| } from "@/components/ui/select"; | |
| import { | |
| Tabs, | |
| TabsContent, | |
| TabsList, | |
| TabsTrigger, | |
| } from "@/components/ui/tabs"; | |
| import Layout from "../components/layout/layout"; | |
| import { employeeService } from "@/lib/api"; | |
| import { Employee } from "@/types"; | |
| import { Separator } from "@/components/ui/separator"; | |
| import { format } from "date-fns"; | |
| import { Calendar } from "@/components/ui/calendar"; | |
| import { | |
| Popover, | |
| PopoverContent, | |
| PopoverTrigger, | |
| } from "@/components/ui/popover"; | |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; | |
| import { cn } from "@/lib/utils"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { useAuth } from "@/lib/auth-context"; | |
| // Custom dropdown component | |
| interface CustomDropdownProps { | |
| value: string; | |
| onChange: (value: string) => void; | |
| options: { value: string; label: string }[]; | |
| placeholder: string; | |
| } | |
| function CustomDropdown({ value, onChange, options, placeholder }: CustomDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| console.log("CustomDropdown render with value:", value, typeof value); | |
| const getLabel = () => { | |
| const option = options.find(option => option.value === value); | |
| console.log("Finding option for value:", value, "Found:", option); | |
| return option?.label || placeholder; | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (optionValue: string) => { | |
| console.log("Option clicked:", optionValue); | |
| onChange(optionValue); | |
| setOpen(false); | |
| }; | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| // @ts-ignore | |
| if (e.target && !e.target.closest('.dropdown-container')) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [open]); | |
| return ( | |
| <div className="relative w-full dropdown-container"> | |
| <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", | |
| open && "ring-2 ring-ring ring-offset-2" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <span className={value ? "" : "text-muted-foreground"}>{getLabel()}</span> | |
| <ChevronDown className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| {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", | |
| value === option.value && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleOptionClick(option.value); | |
| }} | |
| > | |
| {option.label} | |
| {value === option.value && <Check className="h-4 w-4" />} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Employee Dropdown component | |
| interface CustomEmployeeDropdownProps { | |
| value: string | null; | |
| onChange: (value: string) => void; | |
| employees: Employee[]; | |
| placeholder: string; | |
| disabled?: boolean; | |
| } | |
| function CustomEmployeeDropdown({ value, onChange, employees, placeholder, disabled = false }: CustomEmployeeDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| console.log("CustomEmployeeDropdown render with value:", value, typeof value); | |
| const getEmployeeName = (id: string | null) => { | |
| console.log("Getting employee name for ID:", id, typeof id); | |
| if (!id || id === "unassigned") return placeholder; | |
| const employee = employees.find(emp => emp.id.toString() === id); | |
| return employee ? `${employee.firstName} ${employee.lastName}` : `Employee ${id}`; | |
| }; | |
| const getInitials = (id: string) => { | |
| const employee = employees.find(emp => emp.id.toString() === id); | |
| if (!employee) return "??"; | |
| return (employee.firstName.charAt(0) + employee.lastName.charAt(0)).toUpperCase(); | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| // Don't open if disabled | |
| if (disabled) return; | |
| setOpen(!open); | |
| if (!open) { | |
| setSearchTerm(""); // Reset search when opening | |
| } | |
| }; | |
| const handleOptionClick = (employeeId: string) => { | |
| console.log("Employee option clicked:", employeeId); | |
| onChange(employeeId); | |
| setOpen(false); | |
| setSearchTerm(""); // Reset search when selecting | |
| }; | |
| // Helper functions for checking selection state | |
| const isUnassigned = () => { | |
| return value === null || value === "unassigned" || value === undefined; | |
| }; | |
| const isEmployeeSelected = (empId: string) => { | |
| return value === empId; | |
| }; | |
| // Filter employees based on search term | |
| const filteredEmployees = employees.filter(employee => { | |
| if (!searchTerm) return true; | |
| const fullName = `${employee.firstName} ${employee.lastName}`.toLowerCase(); | |
| return fullName.includes(searchTerm.toLowerCase()); | |
| }); | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| // @ts-ignore | |
| if (e.target && !e.target.closest('.employee-dropdown-container')) { | |
| setOpen(false); | |
| setSearchTerm(""); // Reset search when closing | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [open]); | |
| return ( | |
| <div className="relative w-full employee-dropdown-container"> | |
| <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", | |
| disabled ? "opacity-70 cursor-not-allowed bg-muted" : "cursor-pointer", | |
| open && "ring-2 ring-ring ring-offset-2" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| {value && value !== "unassigned" ? ( | |
| <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={isUnassigned() ? "text-muted-foreground" : ""}> | |
| {getEmployeeName(value)} | |
| </span> | |
| </div> | |
| {!disabled && <ChevronDown className="h-4 w-4 shrink-0 opacity-50" />} | |
| {disabled && <Lock className="h-4 w-4 shrink-0 opacity-50" />} | |
| </div> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 max-h-60 w-full overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md"> | |
| {/* Search Input */} | |
| <div className="p-2 border-b"> | |
| <Input | |
| placeholder="Search employees..." | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| className="h-8 text-sm" | |
| autoFocus | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| {/* Options Container */} | |
| <div className="max-h-48 overflow-auto p-1"> | |
| {/* Unassigned Option */} | |
| {(!searchTerm || "unassigned".includes(searchTerm.toLowerCase())) && ( | |
| <div | |
| key="unassigned" | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground bg-muted/30", | |
| isUnassigned() && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleOptionClick("unassigned"); | |
| }} | |
| > | |
| <User className="h-4 w-4 mr-2 opacity-50" /> | |
| <span className="font-semibold text-neutral-500">Unassigned</span> | |
| {isUnassigned() && ( | |
| <Check className="ml-auto h-4 w-4" /> | |
| )} | |
| </div> | |
| )} | |
| {/* Employee Options */} | |
| {filteredEmployees.length > 0 ? ( | |
| filteredEmployees.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", | |
| isEmployeeSelected(employee.id.toString()) && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleOptionClick(employee.id.toString()); | |
| }} | |
| > | |
| <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> | |
| {isEmployeeSelected(employee.id.toString()) && ( | |
| <Check className="ml-auto h-4 w-4" /> | |
| )} | |
| </div> | |
| )) | |
| ) : ( | |
| searchTerm && ( | |
| <div className="px-2 py-1.5 text-sm text-muted-foreground"> | |
| No employees found matching "{searchTerm}" | |
| </div> | |
| ) | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Add ProjectDropdown component | |
| interface CustomProjectDropdownProps { | |
| value: number | null; | |
| onChange: (value: string) => void; | |
| projects: ProjectApi[]; | |
| placeholder: string; | |
| } | |
| function CustomProjectDropdown({ value, onChange, projects, placeholder }: CustomProjectDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| const getProjectName = (id: number | null) => { | |
| if (id === null) return placeholder; | |
| const project = projects.find(project => project.id === id); | |
| return project ? project.projectName : `Project #${id}`; | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (projectId: string) => { | |
| if (projectId === "all") { | |
| onChange("all"); | |
| } else { | |
| onChange(projectId); | |
| } | |
| setOpen(false); | |
| }; | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| // @ts-ignore | |
| if (e.target && !e.target.closest('.project-dropdown-container')) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [open]); | |
| // Helper functions | |
| const isProjectSelected = (projectId: number) => { | |
| return value === projectId; | |
| }; | |
| const isAllProjectsSelected = () => { | |
| return value === null; | |
| }; | |
| return ( | |
| <div className="relative w-full project-dropdown-container"> | |
| <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", | |
| open && "ring-2 ring-ring ring-offset-2" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <span className={value === null ? "text-muted-foreground" : ""}> | |
| {value !== null ? getProjectName(value) : placeholder} | |
| </span> | |
| <ChevronDown className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {open && ( | |
| <div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| <div | |
| key="all" | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground bg-muted/30", | |
| isAllProjectsSelected() && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleOptionClick("all"); | |
| }} | |
| > | |
| <span className="font-semibold text-neutral-500">All Projects</span> | |
| {isAllProjectsSelected() && <Check className="ml-auto h-4 w-4" />} | |
| </div> | |
| {projects.map((project) => ( | |
| <div | |
| key={project.id} | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| isProjectSelected(project.id) && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleOptionClick(project.id.toString()); | |
| }} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">{project.projectName}</span> | |
| <span className="text-xs text-muted-foreground">Code: {project.projectCode}</span> | |
| </div> | |
| {isProjectSelected(project.id) && <Check className="ml-auto h-4 w-4" />} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| // Custom Issue Dropdown component | |
| interface CustomIssueDropdownProps { | |
| value: number | null; | |
| onChange: (value: string) => void; | |
| issues: IssueApi[]; | |
| placeholder: string; | |
| required?: boolean; | |
| isError?: boolean; | |
| } | |
| function CustomIssueDropdown({ value, onChange, issues, placeholder, required, isError = false }: CustomIssueDropdownProps) { | |
| const [open, setOpen] = useState(false); | |
| const [searchTerm, setSearchTerm] = useState(""); | |
| console.log("CustomIssueDropdown render with value:", value, typeof value); | |
| const getIssueName = (id: number | null) => { | |
| console.log("Getting issue name for ID:", id, typeof id); | |
| if (id === null || id === undefined) { | |
| return placeholder; | |
| } | |
| const issue = issues.find(issue => issue.id === id); | |
| return issue ? issue.title : `Issue #${id}`; | |
| }; | |
| const handleButtonClick = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setOpen(!open); | |
| }; | |
| const handleOptionClick = (issueId: string) => { | |
| console.log("Issue option clicked:", issueId, typeof issueId); | |
| if (issueId === "null") { | |
| console.log("Setting issue to null"); | |
| onChange("null"); | |
| } else { | |
| console.log("Setting issue to:", parseInt(issueId, 10)); | |
| onChange(issueId); | |
| } | |
| setOpen(false); | |
| setSearchTerm(""); | |
| }; | |
| // Filter issues based on search term | |
| const filteredIssues = searchTerm | |
| ? issues.filter(issue => | |
| issue.title.toLowerCase().includes(searchTerm.toLowerCase()) || | |
| issue.id.toString().includes(searchTerm) | |
| ) | |
| : issues; | |
| // Close dropdown when clicking outside | |
| useEffect(() => { | |
| if (!open) return; | |
| const handleOutsideClick = (e: MouseEvent) => { | |
| // @ts-ignore | |
| if (e.target && !e.target.closest('.issue-dropdown-container')) { | |
| setOpen(false); | |
| } | |
| }; | |
| document.addEventListener('mousedown', handleOutsideClick); | |
| return () => { | |
| document.removeEventListener('mousedown', handleOutsideClick); | |
| }; | |
| }, [open]); | |
| // Helper function to check if an issue is selected | |
| const isIssueSelected = (issueId: number) => { | |
| return value === issueId; | |
| } | |
| // Helper to check if "No linked issue" is selected | |
| const isNoIssueSelected = () => { | |
| return value === null; | |
| } | |
| // Check if the field is invalid (required but not filled) | |
| const isInvalid = required && value === null; | |
| const showError = isInvalid || isError; | |
| return ( | |
| <div className="relative w-full issue-dropdown-container"> | |
| <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", | |
| open && "ring-2 ring-ring ring-offset-2", | |
| showError && "border-red-500" | |
| )} | |
| onClick={handleButtonClick} | |
| > | |
| <div className="flex items-center"> | |
| <span className={value === null ? "text-muted-foreground" : ""}> | |
| {value !== null ? getIssueName(value) : placeholder} | |
| </span> | |
| {showError && <span className="text-red-500 ml-1">*</span>} | |
| </div> | |
| <ChevronDown className="h-4 w-4 shrink-0 opacity-50" /> | |
| </div> | |
| {showError && !open && ( | |
| <p className="text-xs text-red-500 mt-1">This field is required</p> | |
| )} | |
| {open && ( | |
| <div className="absolute z-50 mt-1 max-h-60 w-full overflow-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md"> | |
| <div className="sticky top-0 bg-popover p-2 border-b"> | |
| <Input | |
| type="search" | |
| placeholder="Search issues..." | |
| className="h-8" | |
| value={searchTerm} | |
| onChange={(e) => setSearchTerm(e.target.value)} | |
| onClick={(e) => e.stopPropagation()} | |
| /> | |
| </div> | |
| <div | |
| key="none" | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground bg-muted/30", | |
| isNoIssueSelected() && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleOptionClick("null"); | |
| }} | |
| > | |
| <span className="font-semibold text-neutral-500">No linked issue</span> | |
| {isNoIssueSelected() && <Check className="ml-auto h-4 w-4" />} | |
| </div> | |
| {filteredIssues.length === 0 ? ( | |
| <div className="px-2 py-4 text-center text-sm text-muted-foreground"> | |
| No issues found in your assigned projects | |
| </div> | |
| ) : ( | |
| filteredIssues.map((issue) => ( | |
| <div | |
| key={issue.id} | |
| className={cn( | |
| "flex cursor-pointer items-center px-2 py-1.5 hover:bg-accent hover:text-accent-foreground", | |
| isIssueSelected(issue.id) && "bg-accent text-accent-foreground" | |
| )} | |
| onClick={(e) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| handleOptionClick(issue.id.toString()); | |
| }} | |
| > | |
| <div className="flex flex-col"> | |
| <span className="font-medium">{issue.title}</span> | |
| <span className="text-xs text-muted-foreground">#{issue.id} • {issue.status}</span> | |
| </div> | |
| {isIssueSelected(issue.id) && <Check className="ml-auto h-4 w-4" />} | |
| </div> | |
| )) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| type BugFormProps = { | |
| isEditMode?: boolean; | |
| defaultType?: string; | |
| }; | |
| const BugForm: React.FC<BugFormProps> = ({ isEditMode = false, defaultType }) => { | |
| const navigate = useNavigate(); | |
| const { id } = useParams(); | |
| const { userData } = useAuth(); | |
| const [isLoading, setIsLoading] = useState(false); | |
| const [isSaving, setIsSaving] = useState(false); | |
| const [employees, setEmployees] = useState<Employee[]>([]); | |
| const [issues, setIssues] = useState<IssueApi[]>([]); | |
| const [allIssues, setAllIssues] = useState<IssueApi[]>([]); | |
| const [projects, setProjects] = useState<ProjectApi[]>([]); | |
| const [selectedProject, setSelectedProject] = useState<number | null>(null); | |
| const [activeTab, setActiveTab] = useState("details"); | |
| const [validationErrors, setValidationErrors] = useState<string[]>([]); | |
| const [bug, setBug] = useState<TaskCreateRequest>({ | |
| id: 0, | |
| type: defaultType === "Production Bug" ? "Production Bug" : "Bug", | |
| priority: "Medium", | |
| severity: "Minor", | |
| title: "", | |
| description: "", | |
| sprintName: null, | |
| estimates: "", | |
| esUnit: 0, | |
| stateDate: new Date().toISOString(), | |
| endDate: null, | |
| assignedTo: null, | |
| remainingHr: 0, | |
| status: "Open", | |
| issuesId: null, | |
| taskCode: "", // Auto-generated by API, not shown in UI | |
| version: "", | |
| url: "", | |
| stepsToReproduce: "", | |
| inputs: "", | |
| environment: defaultType === "Production Bug" ? "Production" : "Staging", | |
| expectedBehavior: "", | |
| actualBehavior: "", | |
| resolution: "", | |
| rootCause: "" | |
| }); | |
| const [startDate, setStartDate] = useState<Date | undefined>(new Date()); | |
| const [endDate, setEndDate] = useState<Date | undefined>(undefined); | |
| useEffect(() => { | |
| fetchEmployees(); | |
| fetchProjects(); | |
| if (isEditMode && id) { | |
| fetchBug(Number(id)); | |
| } | |
| }, [isEditMode, id]); | |
| // Add a new effect to fetch issues after projects are loaded | |
| useEffect(() => { | |
| // Only fetch issues when projects change and we have at least one project | |
| if (projects.length > 0 && !isLoading) { | |
| // Check if we already have the issues for these projects to avoid unnecessary refreshes | |
| const projectIds = projects.map(project => project.id); | |
| const currentIssueProjectIds = new Set(issues.map(issue => issue.projectId)); | |
| // Only fetch if we have new projects or no issues loaded yet | |
| const needsFetch = issues.length === 0 || | |
| projectIds.some(id => !currentIssueProjectIds.has(id)); | |
| if (needsFetch) { | |
| console.log("Projects changed, refreshing issues"); | |
| fetchIssues(); | |
| } | |
| } | |
| }, [projects, issues, isLoading]); | |
| const fetchEmployees = async () => { | |
| try { | |
| const data = await employeeService.getAll(); | |
| setEmployees(data); | |
| } catch (error) { | |
| console.error("Error fetching employees:", error); | |
| toast.error("Error loading employee data"); | |
| } | |
| }; | |
| const fetchProjects = async () => { | |
| try { | |
| let projectsData: ProjectApi[] = []; | |
| // Fetch only user's projects if user data is available | |
| if (userData && userData.employeeId) { | |
| projectsData = await projectService.getByEmployeeId(userData.employeeId); | |
| } else { | |
| projectsData = await projectService.getAll(); | |
| } | |
| setProjects(projectsData); | |
| } catch (error) { | |
| console.error("Error fetching projects:", error); | |
| toast.error("Error loading projects data"); | |
| } | |
| }; | |
| const fetchIssues = async () => { | |
| try { | |
| console.log("Fetching issues..."); | |
| // Prevent duplicate API calls if already loading | |
| if (isLoading) { | |
| console.log("Already loading, skipping fetchIssues call"); | |
| return; | |
| } | |
| // Get all issues first | |
| const allIssuesData = await issuesApi.getAll(); | |
| console.log("All issues fetched:", allIssuesData); | |
| setAllIssues(allIssuesData); | |
| // If user has assigned projects, filter the issues to only show those projects | |
| if (projects.length > 0) { | |
| const projectIds = projects.map(project => project.id); | |
| console.log("Filtering issues for user's assigned projects:", projectIds); | |
| // Filter issues to only include those from user's assigned projects | |
| const filteredIssues = allIssuesData.filter(issue => | |
| projectIds.includes(issue.projectId) | |
| ); | |
| console.log("Filtered issues:", filteredIssues.length); | |
| setIssues(filteredIssues); | |
| } else { | |
| // If no projects assigned yet or still loading, show all issues | |
| setIssues(allIssuesData); | |
| } | |
| } catch (error) { | |
| console.error("Error fetching issues:", error); | |
| toast.error("Error loading issues data"); | |
| } | |
| }; | |
| const fetchBug = async (bugId: number) => { | |
| setIsLoading(true); | |
| try { | |
| const data = await tasksApi.getById(bugId); | |
| if (data) { | |
| // Ensure it's a bug type (regular Bug or Production Bug) | |
| if (data.type !== "Bug" && data.type !== "Production Bug") { | |
| toast.error("The requested item is not a bug"); | |
| // Redirect to the appropriate list based on the defaultType | |
| if (defaultType === "Production Bug") { | |
| navigate("/production-bugs"); | |
| } else { | |
| navigate("/bugs"); | |
| } | |
| return; | |
| } | |
| console.log("Bug data loaded from API:", { | |
| status: data.status, | |
| type: data.type, | |
| priority: data.priority, | |
| severity: data.severity, | |
| environment: data.environment | |
| }); | |
| // Convert dates to Date objects if they exist, handling timezone issues | |
| let startDateObj = data.stateDate ? new Date(data.stateDate) : new Date(); | |
| let endDateObj = data.endDate ? new Date(data.endDate) : undefined; | |
| // Adjust for timezone differences to show local date | |
| if (startDateObj) { | |
| startDateObj = new Date(startDateObj.getTime() + startDateObj.getTimezoneOffset() * 60000); | |
| } | |
| if (endDateObj) { | |
| endDateObj = new Date(endDateObj.getTime() + endDateObj.getTimezoneOffset() * 60000); | |
| } | |
| setStartDate(startDateObj); | |
| setEndDate(endDateObj); | |
| // Update the form state with the fetched data, preserving the original bug type | |
| setBug({ | |
| ...data, | |
| type: data.type, // Explicitly keep the original bug type | |
| stateDate: data.stateDate || new Date().toISOString(), | |
| endDate: data.endDate || null | |
| }); | |
| } else { | |
| toast.error("Bug not found"); | |
| // Check if we're editing a Production Bug and redirect accordingly | |
| if (defaultType === "Production Bug") { | |
| navigate("/production-bugs"); | |
| } else { | |
| navigate("/bugs"); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("Error fetching bug:", error); | |
| toast.error("Error loading bug data"); | |
| // Check if we're editing a Production Bug and redirect accordingly | |
| if (defaultType === "Production Bug") { | |
| navigate("/production-bugs"); | |
| } else { | |
| navigate("/bugs"); | |
| } | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { | |
| const { name, value } = e.target; | |
| // Clear validation error for this field when it's changed | |
| if (validationErrors.includes(name)) { | |
| setValidationErrors(prev => prev.filter(err => err !== name)); | |
| } | |
| setBug(prev => ({ ...prev, [name]: value })); | |
| }; | |
| const handleSelectChange = (field: string, value: string) => { | |
| console.log(`Changing field ${field} to value:`, value, typeof value); | |
| // Clear validation error for this field when it's changed | |
| if (validationErrors.includes(field)) { | |
| setValidationErrors(prev => prev.filter(err => err !== field)); | |
| } | |
| if (field === 'assignedTo') { | |
| const newValue = value === "unassigned" ? null : parseInt(value, 10); | |
| console.log(`Setting assignedTo from ${bug.assignedTo} to:`, newValue, typeof newValue); | |
| // Force a re-render by making a completely new object | |
| setBug({ | |
| ...bug, | |
| assignedTo: newValue | |
| }); | |
| } else if (field === 'project') { | |
| // Handle project selection | |
| const newValue = value === "all" ? null : parseInt(value, 10); | |
| setSelectedProject(newValue); | |
| // Filter issues based on selected project | |
| if (newValue === null) { | |
| setIssues(allIssues); | |
| } else { | |
| const filteredIssues = allIssues.filter(issue => issue.projectId === newValue); | |
| setIssues(filteredIssues); | |
| // Clear selected issue if it's not in the filtered list | |
| if (bug.issuesId !== null && !filteredIssues.some(issue => issue.id === bug.issuesId)) { | |
| setBug({ | |
| ...bug, | |
| issuesId: null | |
| }); | |
| } | |
| } | |
| } else if (field === 'issuesId') { | |
| const newValue = value === "null" ? null : parseInt(value, 10); | |
| console.log(`Setting issuesId from ${bug.issuesId} to:`, newValue, typeof newValue); | |
| // Force a re-render by making a completely new object | |
| setBug({ | |
| ...bug, | |
| issuesId: newValue | |
| }); | |
| // If issue is selected, also set the project to match | |
| if (newValue !== null) { | |
| const selectedIssue = allIssues.find(issue => issue.id === newValue); | |
| if (selectedIssue && selectedProject !== selectedIssue.projectId) { | |
| setSelectedProject(selectedIssue.projectId); | |
| } | |
| } | |
| } else if (['status', 'type', 'priority', 'severity', 'environment'].includes(field)) { | |
| // For dropdown fields, ensure we create a new object to trigger re-render | |
| console.log(`Setting ${field} from "${bug[field as keyof typeof bug]}" to "${value}"`); | |
| setBug({ | |
| ...bug, | |
| [field]: value | |
| }); | |
| } else { | |
| // For other fields | |
| setBug(prev => ({ ...prev, [field]: value })); | |
| } | |
| }; | |
| const formatDateForApi = (date: Date | undefined) => { | |
| if (!date) return null; | |
| // Ensure we're sending a valid ISO string | |
| try { | |
| return new Date(date.getTime() - (date.getTimezoneOffset() * 60000)).toISOString(); | |
| } catch (error) { | |
| console.error("Error formatting date:", error); | |
| return null; | |
| } | |
| }; | |
| const handleSubmit = async (e: React.FormEvent) => { | |
| e.preventDefault(); | |
| // Reset validation errors | |
| const errors: string[] = []; | |
| // Validate required fields | |
| if (bug.issuesId === null) { | |
| errors.push("issuesId"); | |
| toast.error("Linked Feature is required"); | |
| } | |
| if (!bug.title.trim()) { | |
| errors.push("title"); | |
| toast.error("Bug Title is required"); | |
| } | |
| if (!bug.description.trim()) { | |
| errors.push("description"); | |
| toast.error("Bug Description is required"); | |
| } | |
| if (errors.length > 0) { | |
| setValidationErrors(errors); | |
| setActiveTab("details"); // Switch to details tab where most required fields are | |
| return; | |
| } | |
| setIsSaving(true); | |
| try { | |
| // Ensure bug type is preserved - for production bugs this is critical | |
| const isProductionBug = bug.type === "Production Bug" || defaultType === "Production Bug"; | |
| // Format dates for API | |
| const formattedBug = { | |
| ...bug, | |
| // Ensure type is set correctly for production bugs | |
| type: isProductionBug ? "Production Bug" : bug.type, | |
| // For client users, always use current date for stateDate and clear resolution fields | |
| stateDate: userData?.roleName?.toLowerCase() === "client" | |
| ? new Date().toISOString() | |
| : (formatDateForApi(startDate) || new Date().toISOString()), | |
| endDate: formatDateForApi(endDate), | |
| // Clear resolution fields for client users | |
| rootCause: userData?.roleName?.toLowerCase() === "client" ? "" : bug.rootCause, | |
| resolution: userData?.roleName?.toLowerCase() === "client" ? "" : bug.resolution, | |
| // Ensure issuesId is properly set to null or an integer | |
| issuesId: bug.issuesId === null ? null : (typeof bug.issuesId === 'number' ? bug.issuesId : null), | |
| // Ensure assignedTo is properly set to null or an integer | |
| assignedTo: bug.assignedTo === null ? null : (typeof bug.assignedTo === 'number' ? bug.assignedTo : null) | |
| }; | |
| console.log("Submitting bug with data:", formattedBug); | |
| console.log("Issue ID value:", formattedBug.issuesId, typeof formattedBug.issuesId); | |
| console.log("Assigned To value:", formattedBug.assignedTo, typeof formattedBug.assignedTo); | |
| let result; | |
| if (isEditMode && id) { | |
| // Ensure id is properly set for update | |
| const updateData = { | |
| ...formattedBug, | |
| id: Number(id) | |
| }; | |
| console.log("Updating bug with data:", updateData); | |
| result = await tasksApi.update(Number(id), updateData); | |
| toast.success("Bug updated successfully"); | |
| } else { | |
| console.log("Creating new bug with data:", formattedBug); | |
| result = await tasksApi.create(formattedBug); | |
| toast.success("Bug created successfully"); | |
| } | |
| // Check if the bug is a Production Bug and redirect accordingly | |
| if (isProductionBug) { | |
| navigate(`/production-bugs/${result.id}`); | |
| } else { | |
| navigate(`/bugs/${result.id}`); | |
| } | |
| } catch (error) { | |
| console.error("Error saving bug:", error); | |
| toast.error("Failed to save bug. Please try again."); | |
| } finally { | |
| setIsSaving(false); | |
| } | |
| }; | |
| const handleCancel = () => { | |
| // Check if this is a production bug based on either current type or default type | |
| const isProductionBug = bug.type === "Production Bug" || defaultType === "Production Bug"; | |
| if (isEditMode && id) { | |
| if (isProductionBug) { | |
| navigate(`/production-bugs/${id}`); | |
| } else { | |
| navigate(`/bugs/${id}`); | |
| } | |
| } else { | |
| if (isProductionBug) { | |
| navigate("/production-bugs"); | |
| } else { | |
| navigate("/bugs"); | |
| } | |
| } | |
| }; | |
| useEffect(() => { | |
| console.log("Render: Issues available:", issues); | |
| }, [issues]); | |
| useEffect(() => { | |
| console.log("Bug issuesId changed:", bug.issuesId, typeof bug.issuesId); | |
| // If issuesId is not null and not a number, fix it | |
| if (bug.issuesId !== null && typeof bug.issuesId !== 'number') { | |
| console.log("Correcting issuesId type"); | |
| try { | |
| // Try to convert to a number if possible | |
| const numValue = Number(bug.issuesId); | |
| if (!isNaN(numValue)) { | |
| console.log(`Converting issuesId from ${typeof bug.issuesId} to number: ${numValue}`); | |
| setBug(prev => ({...prev, issuesId: numValue})); | |
| } else { | |
| // If conversion fails, set to null | |
| console.log("Failed to convert issuesId to number, setting to null"); | |
| setBug(prev => ({...prev, issuesId: null})); | |
| } | |
| } catch (error) { | |
| console.error("Error converting issuesId:", error); | |
| setBug(prev => ({...prev, issuesId: null})); | |
| } | |
| } | |
| }, [bug.issuesId]); | |
| useEffect(() => { | |
| console.log("Bug assignedTo changed:", bug.assignedTo, typeof bug.assignedTo); | |
| // If assignedTo is not null and not a number, fix it | |
| if (bug.assignedTo !== null && typeof bug.assignedTo !== 'number') { | |
| console.log("Correcting assignedTo type"); | |
| try { | |
| // Try to convert to a number if possible | |
| const numValue = Number(bug.assignedTo); | |
| if (!isNaN(numValue)) { | |
| console.log(`Converting assignedTo from ${typeof bug.assignedTo} to number: ${numValue}`); | |
| setBug(prev => ({...prev, assignedTo: numValue})); | |
| } else { | |
| // If conversion fails, set to null | |
| console.log("Failed to convert assignedTo to number, setting to null"); | |
| setBug(prev => ({...prev, assignedTo: null})); | |
| } | |
| } catch (error) { | |
| console.error("Error converting assignedTo:", error); | |
| setBug(prev => ({...prev, assignedTo: null})); | |
| } | |
| } | |
| }, [bug.assignedTo]); | |
| useEffect(() => { | |
| console.log("Bug state updated:", { | |
| status: bug.status, | |
| type: bug.type, | |
| priority: bug.priority, | |
| severity: bug.severity, | |
| environment: bug.environment | |
| }); | |
| }, [bug.status, bug.type, bug.priority, bug.severity, bug.environment]); | |
| // Set default assignee for client users when component initializes | |
| useEffect(() => { | |
| if (userData?.roleName?.toLowerCase() === "client" && !isEditMode && bug.assignedTo === null) { | |
| // For new bugs, if the user is a client, set assignedTo to null initially | |
| // This will be handled by the backend/admin assignment later | |
| setBug(prev => ({ | |
| ...prev, | |
| assignedTo: null | |
| })); | |
| } | |
| }, [userData, isEditMode]); // Run once when component mounts and userData is available | |
| return ( | |
| <Layout> | |
| <div className="pb-6 w-full animate-fadeIn"> | |
| {/* Breadcrumb Navigation */} | |
| <nav className="flex items-center space-x-2 text-sm text-muted-foreground overflow-x-auto pb-2 mb-4 px-4 md:px-6"> | |
| <Link to="/" className="flex items-center hover:text-foreground transition-colors flex-shrink-0"> | |
| <Home className="h-4 w-4 mr-1" /> | |
| Home | |
| </Link> | |
| <ChevronRight className="h-4 w-4 flex-shrink-0" /> | |
| {bug.type === "Production Bug" ? ( | |
| <Link to="/production-bugs" className="hover:text-foreground transition-colors flex-shrink-0"> | |
| Production Bugs | |
| </Link> | |
| ) : ( | |
| <Link to="/bugs" className="hover:text-foreground transition-colors flex-shrink-0"> | |
| Bugs | |
| </Link> | |
| )} | |
| <ChevronRight className="h-4 w-4 flex-shrink-0" /> | |
| <span className="text-foreground font-medium flex-shrink-0"> | |
| {isEditMode ? "Edit Bug" : "New Bug"} | |
| </span> | |
| </nav> | |
| <div className="px-4 md:px-6 space-y-6 max-w-5xl mx-auto"> | |
| <div className="flex justify-between items-center"> | |
| <div> | |
| <h1 className="text-2xl font-bold tracking-tight flex items-center gap-2"> | |
| <Bug className="h-6 w-6" /> | |
| {isEditMode ? | |
| `Edit ${bug.type === "Production Bug" ? "Production Bug" : "Bug"}` : | |
| `New ${bug.type === "Production Bug" ? "Production Bug" : "Bug"}`} | |
| </h1> | |
| <p className="text-muted-foreground"> | |
| {isEditMode | |
| ? `Update the details of an existing ${bug.type === "Production Bug" ? "production bug" : "bug"}` | |
| : `Create a new ${bug.type === "Production Bug" ? "production bug" : "bug or defect"} to track`} | |
| </p> | |
| </div> | |
| <Button variant="outline" onClick={handleCancel}> | |
| <ArrowLeft className="h-4 w-4 mr-2" /> | |
| Back | |
| </Button> | |
| </div> | |
| {isLoading ? ( | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <div className="flex justify-center py-8"> | |
| <div className="flex flex-col items-center gap-2"> | |
| <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div> | |
| <p className="text-sm text-muted-foreground">Loading bug data...</p> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| ) : ( | |
| <form onSubmit={handleSubmit}> | |
| <Tabs defaultValue="details" className="w-full" onValueChange={setActiveTab}> | |
| <TabsList className={`grid w-full ${userData?.roleName?.toLowerCase() === "client" ? "grid-cols-2" : "grid-cols-3"} mb-4`}> | |
| <TabsTrigger value="details"> | |
| Bug Details | |
| </TabsTrigger> | |
| <TabsTrigger value="steps"> | |
| Steps & Behavior | |
| </TabsTrigger> | |
| {userData?.roleName?.toLowerCase() !== "client" && ( | |
| <TabsTrigger value="resolution"> | |
| Resolution | |
| </TabsTrigger> | |
| )} | |
| </TabsList> | |
| <Card> | |
| <CardContent className="pt-6"> | |
| <div className="mb-4 text-sm text-muted-foreground"> | |
| Fields marked with <span className="text-red-500">*</span> are required | |
| </div> | |
| <TabsContent value="details" className="space-y-4 mt-0"> | |
| <div className="grid grid-cols-1 gap-4"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="project">Project</Label> | |
| <CustomProjectDropdown | |
| value={selectedProject} | |
| onChange={(value) => handleSelectChange("project", value)} | |
| projects={projects} | |
| placeholder="All Projects" | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| Filter issues by project | |
| </p> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="issuesId">Linked Feature <span className="text-red-500">*</span></Label> | |
| <CustomIssueDropdown | |
| value={bug.issuesId} | |
| onChange={(value) => handleSelectChange("issuesId", value)} | |
| issues={issues} | |
| placeholder="Link to a feature" | |
| required | |
| isError={validationErrors.includes("issuesId")} | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| Connect this bug to a related feature | |
| </p> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="title">Bug Title <span className="text-red-500">*</span></Label> | |
| <Input | |
| id="title" | |
| name="title" | |
| value={bug.title} | |
| onChange={handleInputChange} | |
| placeholder="Enter a descriptive title for the bug" | |
| required | |
| className={validationErrors.includes("title") ? "border-red-500" : ""} | |
| /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="description">Bug Description <span className="text-red-500">*</span></Label> | |
| <Textarea | |
| id="description" | |
| name="description" | |
| value={bug.description} | |
| onChange={handleInputChange} | |
| placeholder="Provide a detailed description of the bug" | |
| rows={4} | |
| required | |
| className={validationErrors.includes("description") ? "border-red-500" : ""} | |
| /> | |
| </div> | |
| <div className="grid gap-4 grid-cols-1 md:grid-cols-2"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="status">Status</Label> | |
| <CustomDropdown | |
| value={bug.status || "Open"} | |
| onChange={(value) => handleSelectChange("status", value)} | |
| options={[ | |
| { value: "New", label: "New" }, | |
| { value: "In Progress", label: "In Progress" }, | |
| { value: "In Review", label: "In Review" }, | |
| { value: "Completed", label: "Completed" }, | |
| { value: "Closed", label: "Closed" }, | |
| { value: "Open", label: "Open" }, | |
| { value: "Resolved", label: "Resolved" }, | |
| { value: "Reopened", label: "Reopened" }, | |
| { value: "Ready for QA", label: "Ready for QA" } | |
| ]} | |
| placeholder="Select status" | |
| /> | |
| </div> | |
| {defaultType !== "Production Bug" && bug.type !== "Production Bug" ? ( | |
| <div className="grid gap-2"> | |
| <Label htmlFor="type">Bug Type</Label> | |
| <CustomDropdown | |
| value={bug.type} | |
| onChange={(value) => handleSelectChange("type", value)} | |
| options={[ | |
| { value: "Bug", label: "Bug" }, | |
| { value: "Test Case", label: "Test Case" } | |
| ]} | |
| placeholder="Select bug type" | |
| /> | |
| </div> | |
| ) : ( | |
| <div className="grid gap-2"> | |
| <Label htmlFor="type">Bug Type</Label> | |
| <div className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-muted/30 px-3 py-2 text-sm"> | |
| <span>Production Bug</span> | |
| <Lock className="h-3.5 w-3.5 text-muted-foreground" /> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| <div className="grid gap-4 grid-cols-1 md:grid-cols-2"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="priority">Priority</Label> | |
| <CustomDropdown | |
| value={bug.priority || "Medium"} | |
| onChange={(value) => handleSelectChange("priority", value)} | |
| options={[ | |
| { value: "Critical", label: "Critical" }, | |
| { value: "High", label: "High" }, | |
| { value: "Medium", label: "Medium" }, | |
| { value: "Low", label: "Low" } | |
| ]} | |
| placeholder="Select priority" | |
| /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="severity">Severity</Label> | |
| <CustomDropdown | |
| value={bug.severity || "Minor"} | |
| onChange={(value) => handleSelectChange("severity", value)} | |
| options={[ | |
| { value: "Blocker", label: "Blocker" }, | |
| { value: "Critical", label: "Critical" }, | |
| { value: "Major", label: "Major" }, | |
| { value: "Minor", label: "Minor" }, | |
| { value: "Trivial", label: "Trivial" } | |
| ]} | |
| placeholder="Select severity" | |
| /> | |
| </div> | |
| </div> | |
| <div className="grid gap-4 grid-cols-1 md:grid-cols-2"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="version">Affected Version</Label> | |
| <Input | |
| id="version" | |
| name="version" | |
| value={bug.version || ""} | |
| onChange={handleInputChange} | |
| placeholder="e.g., 1.2.3" | |
| /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="url">Related URL</Label> | |
| <Input | |
| id="url" | |
| name="url" | |
| value={bug.url || ""} | |
| onChange={handleInputChange} | |
| placeholder="e.g., https://example.com/page-with-bug" | |
| /> | |
| </div> | |
| </div> | |
| <div className="grid gap-4 grid-cols-1 md:grid-cols-2"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="assignedTo"> | |
| Assigned To | |
| {userData?.roleName?.toLowerCase() === "client" && ( | |
| <span className="ml-2 text-xs text-muted-foreground inline-flex items-center"> | |
| <Lock className="h-3 w-3 mr-1" /> | |
| Clients cannot change assignee | |
| </span> | |
| )} | |
| </Label> | |
| <CustomEmployeeDropdown | |
| value={bug.assignedTo?.toString() || "unassigned"} | |
| onChange={(value) => { | |
| // Only allow changes if user is not a client | |
| if (userData?.roleName?.toLowerCase() !== "client") { | |
| handleSelectChange("assignedTo", value); | |
| } | |
| }} | |
| employees={employees} | |
| placeholder="Select assignee" | |
| disabled={userData?.roleName?.toLowerCase() === "client"} | |
| /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="environment">Environment</Label> | |
| <CustomDropdown | |
| value={bug.environment || "Staging"} | |
| onChange={(value) => handleSelectChange("environment", value)} | |
| options={[ | |
| { value: "Production", label: "Production" }, | |
| { value: "Staging", label: "Staging" }, | |
| { value: "Testing", label: "Testing" }, | |
| { value: "Development", label: "Development" }, | |
| { value: "QA", label: "QA" }, | |
| { value: "UAT", label: "UAT" }, | |
| { value: "Local", label: "Local" } | |
| ]} | |
| placeholder="Select environment" | |
| /> | |
| </div> | |
| </div> | |
| <div className="grid gap-4 grid-cols-1 md:grid-cols-2"> | |
| {userData?.roleName?.toLowerCase() !== "client" && ( | |
| <div className="grid gap-2"> | |
| <Label htmlFor="startDate">Start Date</Label> | |
| <div className="relative"> | |
| <Input | |
| id="startDate" | |
| type="date" | |
| value={startDate ? format(startDate, "yyyy-MM-dd") : ""} | |
| onChange={(e) => { | |
| const date = e.target.value ? new Date(e.target.value) : undefined; | |
| setStartDate(date); | |
| }} | |
| className="w-full" | |
| /> | |
| </div> | |
| </div> | |
| )} | |
| <div className="grid gap-2"> | |
| <Label htmlFor="endDate">Target Fix Date</Label> | |
| <div className="relative"> | |
| <Input | |
| id="endDate" | |
| type="date" | |
| value={endDate ? format(endDate, "yyyy-MM-dd") : ""} | |
| onChange={(e) => { | |
| const date = e.target.value ? new Date(e.target.value) : undefined; | |
| setEndDate(date); | |
| }} | |
| className="w-full" | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </TabsContent> | |
| <TabsContent value="steps" className="space-y-4 mt-0"> | |
| <div className="grid grid-cols-1 gap-4"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="stepsToReproduce">Steps to Reproduce</Label> | |
| <Textarea | |
| id="stepsToReproduce" | |
| name="stepsToReproduce" | |
| value={bug.stepsToReproduce || ""} | |
| onChange={handleInputChange} | |
| placeholder="List the steps to reproduce the bug" | |
| rows={4} | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| Provide a step-by-step guide to reproduce the bug consistently. | |
| </p> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="inputs">Test Data / Inputs</Label> | |
| <Textarea | |
| id="inputs" | |
| name="inputs" | |
| value={bug.inputs || ""} | |
| onChange={handleInputChange} | |
| placeholder="Test data or inputs needed to reproduce the bug" | |
| rows={3} | |
| /> | |
| </div> | |
| <Separator /> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="expectedBehavior">Expected Behavior</Label> | |
| <Textarea | |
| id="expectedBehavior" | |
| name="expectedBehavior" | |
| value={bug.expectedBehavior || ""} | |
| onChange={handleInputChange} | |
| placeholder="What should happen when the steps are followed" | |
| rows={3} | |
| /> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="actualBehavior">Actual Behavior</Label> | |
| <Textarea | |
| id="actualBehavior" | |
| name="actualBehavior" | |
| value={bug.actualBehavior || ""} | |
| onChange={handleInputChange} | |
| placeholder="What actually happens when the steps are followed" | |
| rows={3} | |
| /> | |
| </div> | |
| </div> | |
| </TabsContent> | |
| {userData?.roleName?.toLowerCase() !== "client" && ( | |
| <TabsContent value="resolution" className="space-y-4 mt-0"> | |
| <div className="grid grid-cols-1 gap-4"> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="rootCause">Root Cause</Label> | |
| <Textarea | |
| id="rootCause" | |
| name="rootCause" | |
| value={bug.rootCause || ""} | |
| onChange={handleInputChange} | |
| placeholder="What caused this bug" | |
| rows={3} | |
| /> | |
| <p className="text-xs text-muted-foreground"> | |
| Identify the underlying cause of the bug (to be filled after analysis). | |
| </p> | |
| </div> | |
| <div className="grid gap-2"> | |
| <Label htmlFor="resolution">Resolution</Label> | |
| <Textarea | |
| id="resolution" | |
| name="resolution" | |
| value={bug.resolution || ""} | |
| onChange={handleInputChange} | |
| placeholder="How the bug was fixed or will be fixed" | |
| rows={3} | |
| /> | |
| </div> | |
| <div className="mt-2 flex items-center p-3 bg-amber-50 dark:bg-amber-950/30 border border-amber-200 dark:border-amber-800 rounded-md"> | |
| <AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-500 mr-2 flex-shrink-0" /> | |
| <p className="text-sm text-amber-800 dark:text-amber-300"> | |
| The resolution details are typically filled after the bug has been analyzed and fixed. | |
| </p> | |
| </div> | |
| </div> | |
| </TabsContent> | |
| )} | |
| </CardContent> | |
| </Card> | |
| <div className="mt-6 flex justify-end"> | |
| <Button | |
| variant="outline" | |
| type="button" | |
| onClick={handleCancel} | |
| className="mr-2" | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| type="submit" | |
| disabled={isSaving} | |
| className="bg-blue-600 hover:bg-blue-700" | |
| > | |
| {isSaving ? ( | |
| <> | |
| <div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-b-transparent border-white"></div> | |
| Saving... | |
| </> | |
| ) : ( | |
| <> | |
| <Save className="mr-2 h-4 w-4" /> | |
| {isEditMode ? "Update Bug" : "Create Bug"} | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| </Tabs> | |
| </form> | |
| )} | |
| </div> | |
| </div> | |
| </Layout> | |
| ); | |
| }; | |
| export default BugForm; |