Spaces:
Running
Running
| import React, { useState, useEffect } from "react"; | |
| import { useNavigate, useParams } from "react-router-dom"; | |
| import { ArrowLeft, Pencil, Trash2, Loader2, Plus, CheckCircle, Clock, RefreshCw, AlertCircle, ClipboardList, FileText, Paperclip, MessageSquare, History, Calendar, Bug, CheckSquare, Sparkles, Zap, Save, ChevronDown, ChevronUp, XCircle, Upload, File, X } from "lucide-react"; | |
| import Layout from "@/components/layout/layout"; | |
| import { Button } from "@/components/ui/button"; | |
| import { | |
| Card, | |
| CardContent, | |
| CardDescription, | |
| CardHeader, | |
| CardTitle, | |
| CardFooter, | |
| } from "@/components/ui/card"; | |
| import { Avatar, AvatarFallback } from "@/components/ui/avatar"; | |
| import { Badge } from "@/components/ui/badge"; | |
| import { toast } from "@/lib/custom-toast"; | |
| import { issuesApi, projectService, deliverablesApi, employeeService } from "@/lib/api"; | |
| import { aiTestCaseApi, FrontendTestCase } from "@/services/aiTestCaseApi"; | |
| import { Checkbox } from "@/components/ui/checkbox"; | |
| import { IssueApi, ProjectApi, Deliverable, Employee } from "@/types"; | |
| import { tasksApi, Task } from "@/services/tasksApi"; | |
| import { | |
| Dialog, | |
| DialogContent, | |
| DialogDescription, | |
| DialogFooter, | |
| DialogHeader, | |
| DialogTitle, | |
| DialogTrigger, | |
| } from "@/components/ui/dialog"; | |
| import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; | |
| import TaskTimeLogCard from "@/components/tasks/TaskTimeLogCard"; | |
| import TaskCommentSection from "@/components/tasks/TaskCommentSection"; | |
| import TaskAttachmentsSection from "@/components/tasks/TaskAttachmentsSection"; | |
| import SimpleAddTaskDialog from "@/components/tasks/SimpleAddTaskDialog"; | |
| import { cn } from "@/lib/utils"; | |
| import { format } from "date-fns"; | |
| const IssueDetail = () => { | |
| const { id } = useParams<{ id: string }>(); | |
| const navigate = useNavigate(); | |
| const [issue, setIssue] = useState<IssueApi | null>(null); | |
| const [project, setProject] = useState<ProjectApi | null>(null); | |
| const [deliverable, setDeliverable] = useState<Deliverable | null>(null); | |
| const [reporter, setReporter] = useState<Employee | null>(null); | |
| const [assignee, setAssignee] = useState<Employee | null>(null); | |
| const [isLoading, setIsLoading] = useState(true); | |
| const [isDeleting, setIsDeleting] = useState(false); | |
| const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); | |
| const [relatedTasks, setRelatedTasks] = useState<Task[]>([]); | |
| const [relatedTestcases, setRelatedTestcases] = useState<Task[]>([]); | |
| const [relatedBugs, setRelatedBugs] = useState<Task[]>([]); | |
| const [isLoadingTasks, setIsLoadingTasks] = useState(false); | |
| const [isLoadingTestcases, setIsLoadingTestcases] = useState(false); | |
| const [isLoadingBugs, setIsLoadingBugs] = useState(false); | |
| const [employees, setEmployees] = useState<Employee[]>([]); | |
| const [activeTab, setActiveTab] = useState("tasks"); | |
| const [addTaskDialogOpen, setAddTaskDialogOpen] = useState(false); | |
| const [isGeneratingTestCases, setIsGeneratingTestCases] = useState(false); | |
| const [generatedTestCases, setGeneratedTestCases] = useState<FrontendTestCase[]>([]); | |
| const [selectedGenerated, setSelectedGenerated] = useState<Set<number>>(new Set()); | |
| const [savingGenerated, setSavingGenerated] = useState<Record<number, 'idle' | 'saving' | 'saved' | 'error'>>({}); | |
| const [expandedGenerated, setExpandedGenerated] = useState<Set<number>>(new Set()); | |
| const [editedGenerated, setEditedGenerated] = useState<Record<number, FrontendTestCase>>({}); | |
| const [showManualForm, setShowManualForm] = useState(false); | |
| const [manualTestCase, setManualTestCase] = useState({ | |
| title: '', | |
| description: '', | |
| stepsToReproduce: '', | |
| inputs: '', | |
| expectedBehavior: '', | |
| actualBehavior: '', | |
| priority: 'Medium', | |
| severity: 'Minor', | |
| environment: 'Staging', | |
| status: 'Draft', | |
| version: '', | |
| url: '', | |
| assignedTo: '', | |
| startDate: new Date().toISOString().split('T')[0], | |
| endDate: '' | |
| }); | |
| const [isSavingManual, setIsSavingManual] = useState(false); | |
| const [additionalContext, setAdditionalContext] = useState(''); | |
| const [contextFile, setContextFile] = useState<File | null>(null); | |
| const [isExtractingPdf, setIsExtractingPdf] = useState(false); | |
| useEffect(() => { | |
| if (!id) { | |
| navigate("/issues"); | |
| return; | |
| } | |
| fetchIssueData(parseInt(id)); | |
| }, [id, navigate]); | |
| // Add debug logging for dialog state changes | |
| useEffect(() => { | |
| console.log("AddTaskDialog open state changed:", addTaskDialogOpen); | |
| }, [addTaskDialogOpen]); | |
| const openAddTaskDialog = () => { | |
| console.log("Opening add task dialog"); | |
| setAddTaskDialogOpen(true); | |
| }; | |
| const handleDialogOpenChange = (open: boolean) => { | |
| console.log("Dialog open change called with:", open); | |
| setAddTaskDialogOpen(open); | |
| }; | |
| const fetchIssueData = async (issueId: number) => { | |
| setIsLoading(true); | |
| try { | |
| // Get issue details | |
| const issueData = await issuesApi.getById(issueId); | |
| setIssue(issueData); | |
| // Get project details | |
| if (issueData.projectId) { | |
| const projectData = await projectService.getById(issueData.projectId); | |
| setProject(projectData); | |
| } | |
| // Get deliverable details | |
| if (issueData.deliverablesId) { | |
| const deliverableData = await deliverablesApi.getById(issueData.deliverablesId); | |
| setDeliverable(deliverableData); | |
| } | |
| // Get employee data | |
| const employeesData = await employeeService.getAll(); | |
| // Find reporter and assignee | |
| const relatedReporter = employeesData.find(emp => emp.id === issueData.reportedBy) || null; | |
| const relatedAssignee = employeesData.find(emp => emp.id === issueData.assignedTo) || null; | |
| setReporter(relatedReporter); | |
| setAssignee(relatedAssignee); | |
| // Store all employees for task assignee lookup | |
| setEmployees(employeesData); | |
| // Also fetch related items | |
| fetchRelatedTasks(issueId); | |
| fetchRelatedTestcases(issueId); | |
| fetchRelatedBugs(issueId); | |
| } catch (error) { | |
| console.error("Error fetching Featuredata:", error); | |
| toast.error("Failed to load Featuredata"); | |
| } finally { | |
| setIsLoading(false); | |
| } | |
| }; | |
| const fetchRelatedTasks = async (issueId: number) => { | |
| setIsLoadingTasks(true); | |
| try { | |
| const allTasks = await tasksApi.getAll(); | |
| // Filter tasks related to this issue | |
| const filtered = allTasks.filter(task => task.issuesId === issueId && task.type !== "Test Case" && task.type !== "Bug"); | |
| setRelatedTasks(filtered); | |
| } catch (error) { | |
| console.error("Error fetching related tasks:", error); | |
| toast.error("Failed to load related tasks"); | |
| } finally { | |
| setIsLoadingTasks(false); | |
| } | |
| }; | |
| const fetchRelatedTestcases = async (issueId: number) => { | |
| setIsLoadingTestcases(true); | |
| try { | |
| const data = await tasksApi.getByType("Test Case"); | |
| const filtered = data.filter(tc => tc.issuesId === issueId); | |
| setRelatedTestcases(filtered); | |
| } catch (error) { | |
| console.error("Error fetching related test cases:", error); | |
| toast.error("Failed to load related test cases"); | |
| } finally { | |
| setIsLoadingTestcases(false); | |
| } | |
| }; | |
| const fetchRelatedBugs = async (issueId: number) => { | |
| setIsLoadingBugs(true); | |
| try { | |
| const data = await tasksApi.getByType("Bug"); | |
| const filtered = data.filter(bug => bug.issuesId === issueId); | |
| setRelatedBugs(filtered); | |
| } catch (error) { | |
| console.error("Error fetching related bugs:", error); | |
| toast.error("Failed to load related bugs"); | |
| } finally { | |
| setIsLoadingBugs(false); | |
| } | |
| }; | |
| const handleDelete = async () => { | |
| if (!issue || isDeleting) return; | |
| setIsDeleting(true); | |
| try { | |
| await issuesApi.delete(issue.id); | |
| toast.success("Featuredeleted successfully"); | |
| navigate("/issues"); | |
| } catch (error) { | |
| console.error("Error deleting issue:", error); | |
| toast.error("Failed to delete issue. Please try again."); | |
| setIsDeleting(false); | |
| } | |
| }; | |
| const getTypeBadge = (type: string) => { | |
| let color = ""; | |
| switch (type.toLowerCase()) { | |
| case "bug": | |
| color = "bg-red-100 text-red-800"; | |
| break; | |
| case "feature": | |
| case "user story": | |
| color = "bg-green-100 text-green-800"; | |
| break; | |
| case "task": | |
| color = "bg-blue-100 text-blue-800"; | |
| break; | |
| case "enhancement": | |
| color = "bg-purple-100 text-purple-800"; | |
| break; | |
| case "documentation": | |
| color = "bg-gray-100 text-gray-800"; | |
| break; | |
| default: | |
| color = "bg-gray-100 text-gray-800"; | |
| } | |
| return ( | |
| <Badge variant="outline" className={cn("font-medium", color)}> | |
| {type} | |
| </Badge> | |
| ); | |
| }; | |
| const getPriorityBadge = (priority: string) => { | |
| let color = ""; | |
| switch (priority.toLowerCase()) { | |
| case "low": | |
| color = "bg-blue-100 text-blue-800"; | |
| break; | |
| case "medium": | |
| color = "bg-yellow-100 text-yellow-800"; | |
| break; | |
| case "high": | |
| color = "bg-orange-100 text-orange-800"; | |
| break; | |
| case "critical": | |
| color = "bg-red-100 text-red-800"; | |
| break; | |
| default: | |
| color = "bg-gray-100 text-gray-800"; | |
| } | |
| return ( | |
| <Badge variant="outline" className={cn("font-medium", color)}> | |
| {priority} | |
| </Badge> | |
| ); | |
| }; | |
| const getSeverityBadge = (severity: string) => { | |
| let color = ""; | |
| switch (severity.toLowerCase()) { | |
| case "low": | |
| color = "bg-blue-100 text-blue-800"; | |
| break; | |
| case "medium": | |
| color = "bg-yellow-100 text-yellow-800"; | |
| break; | |
| case "high": | |
| color = "bg-orange-100 text-orange-800"; | |
| break; | |
| case "critical": | |
| color = "bg-red-100 text-red-800"; | |
| break; | |
| default: | |
| color = "bg-gray-100 text-gray-800"; | |
| } | |
| return ( | |
| <Badge variant="outline" className={cn("font-medium", color)}> | |
| {severity} | |
| </Badge> | |
| ); | |
| }; | |
| const getEmployeeInitials = (employee: Employee | null) => { | |
| if (!employee) return "UN"; | |
| return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`; | |
| }; | |
| // Get task assignee name | |
| const getTaskAssigneeName = (assignedToId: number | null): string => { | |
| if (!assignedToId) return "Unassigned"; | |
| const employee = employees.find(emp => emp.id === assignedToId); | |
| if (!employee) return `ID: ${assignedToId}`; | |
| return `${employee.firstName} ${employee.lastName}`; | |
| }; | |
| // Get task assignee initials for avatar | |
| const getTaskAssigneeInitials = (assignedToId: number | null): string => { | |
| if (!assignedToId) return "UN"; | |
| const employee = employees.find(emp => emp.id === assignedToId); | |
| if (!employee) return "?"; | |
| return `${employee.firstName.charAt(0)}${employee.lastName.charAt(0)}`; | |
| }; | |
| // Helper to get task status badge | |
| const getTaskStatusBadge = (status: string) => { | |
| switch (status) { | |
| case "Completed": | |
| case "Closed": | |
| return ( | |
| <Badge variant="default" className="bg-green-500 hover:bg-green-600 text-white flex items-center gap-1"> | |
| <CheckCircle className="h-3 w-3" /> | |
| {status} | |
| </Badge> | |
| ); | |
| case "In Progress": | |
| return ( | |
| <Badge variant="default" className="bg-blue-500 hover:bg-blue-600 text-white flex items-center gap-1"> | |
| <RefreshCw className="h-3 w-3" /> | |
| {status} | |
| </Badge> | |
| ); | |
| case "In Review": | |
| return ( | |
| <Badge variant="default" className="bg-purple-500 hover:bg-purple-600 text-white flex items-center gap-1"> | |
| <Clock className="h-3 w-3" /> | |
| {status} | |
| </Badge> | |
| ); | |
| case "New": | |
| default: | |
| return ( | |
| <Badge variant="default" className="bg-gray-500 hover:bg-gray-600 text-white flex items-center gap-1"> | |
| <AlertCircle className="h-3 w-3" /> | |
| {status || "New"} | |
| </Badge> | |
| ); | |
| } | |
| }; | |
| // Helper to get task priority badge | |
| const getTaskPriorityBadge = (priority: string) => { | |
| switch (priority.toLowerCase()) { | |
| case "high": | |
| return ( | |
| <Badge variant="outline" className="border-red-500 text-red-500 flex items-center gap-1"> | |
| {priority} | |
| </Badge> | |
| ); | |
| case "medium": | |
| return ( | |
| <Badge variant="outline" className="border-orange-500 text-orange-500 flex items-center gap-1"> | |
| {priority} | |
| </Badge> | |
| ); | |
| case "low": | |
| return ( | |
| <Badge variant="outline" className="border-green-500 text-green-500 flex items-center gap-1"> | |
| {priority} | |
| </Badge> | |
| ); | |
| default: | |
| return ( | |
| <Badge variant="outline" className="border-gray-500 text-gray-500 flex items-center gap-1"> | |
| {priority || "Normal"} | |
| </Badge> | |
| ); | |
| } | |
| }; | |
| // Format date for task | |
| const formatTaskDate = (dateString: string | null) => { | |
| if (!dateString) return "Not set"; | |
| try { | |
| return new Date(dateString).toLocaleDateString(); | |
| } catch (error) { | |
| return "Invalid date"; | |
| } | |
| }; | |
| // Extract text from PDF file | |
| const extractPdfText = async (file: File): Promise<string> => { | |
| setIsExtractingPdf(true); | |
| try { | |
| const arrayBuffer = await file.arrayBuffer(); | |
| const pdfjsLib = await import('pdfjs-dist'); | |
| // Set worker path | |
| pdfjsLib.GlobalWorkerOptions.workerSrc = `//cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.js`; | |
| const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise; | |
| let fullText = ''; | |
| for (let i = 1; i <= pdf.numPages; i++) { | |
| const page = await pdf.getPage(i); | |
| const textContent = await page.getTextContent(); | |
| const pageText = textContent.items | |
| .map((item: any) => item.str) | |
| .join(' '); | |
| fullText += pageText + '\n'; | |
| } | |
| return fullText.trim(); | |
| } catch (error) { | |
| console.error('Error extracting PDF text:', error); | |
| throw new Error('Failed to extract text from PDF'); | |
| } finally { | |
| setIsExtractingPdf(false); | |
| } | |
| }; | |
| // Handle file selection for context | |
| const handleContextFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| if (file.type !== 'application/pdf') { | |
| toast.error('Only PDF files are supported'); | |
| return; | |
| } | |
| setContextFile(file); | |
| try { | |
| const extractedText = await extractPdfText(file); | |
| // Append to existing context | |
| setAdditionalContext(prev => { | |
| const newContext = prev | |
| ? `${prev}\n\n--- PDF Content (${file.name}) ---\n${extractedText}` | |
| : `--- PDF Content (${file.name}) ---\n${extractedText}`; | |
| return newContext; | |
| }); | |
| toast.success(`Extracted text from ${file.name}`); | |
| } catch (error) { | |
| toast.error('Failed to extract PDF text. Please try again or paste content manually.'); | |
| } | |
| }; | |
| // Clear context file | |
| const clearContextFile = () => { | |
| setContextFile(null); | |
| // Remove PDF content from additionalContext | |
| setAdditionalContext(prev => { | |
| if (!prev) return ''; | |
| const lines = prev.split('\n'); | |
| const pdfStartIndex = lines.findIndex(line => line.includes('--- PDF Content')); | |
| if (pdfStartIndex >= 0) { | |
| return lines.slice(0, pdfStartIndex).join('\n').trim(); | |
| } | |
| return prev; | |
| }); | |
| }; | |
| const handleGenerateTestCases = async () => { | |
| if (!issue) return; | |
| setIsGeneratingTestCases(true); | |
| try { | |
| // Build list of existing test cases to avoid duplicates | |
| const existingTestCases = [ | |
| // Saved test cases from backend | |
| ...relatedTestcases.map(tc => ({ | |
| title: tc.title, | |
| description: tc.description | |
| })), | |
| // Generated test cases in current session (not saved yet) | |
| ...generatedTestCases.map(tc => ({ | |
| title: tc.title, | |
| description: tc.description | |
| })) | |
| ]; | |
| const response = await aiTestCaseApi.generateForFeature({ | |
| feature_id: issue.id, | |
| feature_title: issue.title, | |
| feature_description: issue.description, | |
| project_id: issue.projectId, | |
| project_name: project?.projectName, | |
| deliverable_id: issue.deliverablesId, | |
| deliverable_name: deliverable?.name, | |
| tasks: relatedTasks.map(t => ({ | |
| id: t.id, | |
| taskCode: t.taskCode, | |
| title: t.title, | |
| description: t.description, | |
| status: t.status, | |
| assignedTo: t.assignedTo | |
| })), | |
| test_case_count: 3, | |
| environment: 'Staging', | |
| existing_test_cases: existingTestCases, | |
| additional_context: additionalContext || undefined | |
| }); | |
| if (!response.success) { | |
| toast.error(response.message); | |
| return; | |
| } | |
| if (response.test_cases.length === 0) { | |
| toast.warning('No test cases were generated'); | |
| return; | |
| } | |
| // Append generated test cases to existing ones (not replace) | |
| const prevLength = generatedTestCases.length; | |
| setGeneratedTestCases(prev => [...prev, ...response.test_cases]); | |
| // Select and expand new test cases | |
| setSelectedGenerated(prev => { | |
| const newSet = new Set(prev); | |
| response.test_cases.forEach((_, i) => newSet.add(prevLength + i)); | |
| return newSet; | |
| }); | |
| setExpandedGenerated(prev => { | |
| const newSet = new Set(prev); | |
| newSet.add(prevLength); // Expand first new test case | |
| return newSet; | |
| }); | |
| // Initialize saving state for new test cases only | |
| setSavingGenerated(prev => ({ ...prev, ...Object.fromEntries(response.test_cases.map((_, i) => [prevLength + i, 'idle'])) })); | |
| setEditedGenerated(prev => ({ ...prev })); | |
| toast.success(`Generated ${response.test_cases.length} test cases. Total: ${prevLength + response.test_cases.length}`); | |
| } catch (error) { | |
| console.error('Error generating test cases:', error); | |
| toast.error('Failed to generate test cases. Is the AI backend running?'); | |
| } finally { | |
| setIsGeneratingTestCases(false); | |
| } | |
| }; | |
| // Toggle selection for generated test case | |
| const toggleGeneratedSelection = (index: number) => { | |
| const newSelected = new Set(selectedGenerated); | |
| if (newSelected.has(index)) { | |
| newSelected.delete(index); | |
| } else { | |
| newSelected.add(index); | |
| } | |
| setSelectedGenerated(newSelected); | |
| }; | |
| // Toggle expand for generated test case | |
| const toggleGeneratedExpand = (index: number) => { | |
| const newExpanded = new Set(expandedGenerated); | |
| if (newExpanded.has(index)) { | |
| newExpanded.delete(index); | |
| } else { | |
| newExpanded.add(index); | |
| } | |
| setExpandedGenerated(newExpanded); | |
| }; | |
| // Update edited generated test case | |
| const updateGeneratedTestCase = (index: number, field: keyof FrontendTestCase, value: string) => { | |
| setEditedGenerated(prev => ({ | |
| ...prev, | |
| [index]: { | |
| ...(editedGenerated[index] || generatedTestCases[index]), | |
| [field]: value, | |
| }, | |
| })); | |
| }; | |
| // Save single generated test case | |
| const saveGeneratedTestCase = async (index: number) => { | |
| if (!issue) return; | |
| const testCase = editedGenerated[index] || generatedTestCases[index]; | |
| // Convert assignedTo from string to number or null | |
| const assignedToValue = testCase.assignedTo | |
| ? (typeof testCase.assignedTo === 'string' | |
| ? (testCase.assignedTo ? parseInt(testCase.assignedTo, 10) : null) | |
| : testCase.assignedTo) | |
| : null; | |
| setSavingGenerated(prev => ({ ...prev, [index]: 'saving' })); | |
| try { | |
| await tasksApi.create({ | |
| type: 'Test Case', | |
| title: testCase.title, | |
| description: testCase.description, | |
| issuesId: issue.id, | |
| status: testCase.status || 'Draft', | |
| priority: testCase.priority || 'Medium', | |
| severity: testCase.severity || 'Minor', | |
| environment: testCase.environment || 'Staging', | |
| stepsToReproduce: testCase.stepsToReproduce || '', | |
| inputs: testCase.inputs || '', | |
| expectedBehavior: testCase.expectedBehavior || '', | |
| actualBehavior: '', | |
| rootCause: '', | |
| resolution: '', | |
| version: testCase.version || '', | |
| url: testCase.url || '', | |
| sprintName: null, | |
| estimates: '', | |
| esUnit: null, | |
| stateDate: new Date().toISOString(), | |
| endDate: null, | |
| assignedTo: assignedToValue, | |
| remainingHr: null, | |
| }); | |
| toast.success(`Saved: ${testCase.title}`); | |
| // Remove from generated list (move to saved section) | |
| setGeneratedTestCases(prev => prev.filter((_, i) => i !== index)); | |
| setSelectedGenerated(prev => { | |
| const newSet = new Set(prev); | |
| newSet.delete(index); | |
| return newSet; | |
| }); | |
| setExpandedGenerated(prev => { | |
| const newSet = new Set(prev); | |
| newSet.delete(index); | |
| return newSet; | |
| }); | |
| setEditedGenerated(prev => { | |
| const newEdited: Record<number, FrontendTestCase> = {}; | |
| Object.keys(prev).forEach(key => { | |
| const k = parseInt(key); | |
| if (k < index) { | |
| newEdited[k] = prev[k]; | |
| } else if (k > index) { | |
| newEdited[k - 1] = prev[k]; // Shift indices down | |
| } | |
| }); | |
| return newEdited; | |
| }); | |
| setSavingGenerated(prev => { | |
| // Update the type to match exactly what the state expects | |
| const newSaving: Record<number, 'idle' | 'saving' | 'saved' | 'error'> = {}; | |
| Object.keys(prev).forEach(key => { | |
| const k = parseInt(key); | |
| if (k < index) { | |
| newSaving[k] = prev[k]; | |
| } else if (k > index) { | |
| newSaving[k - 1] = prev[k]; // Shift indices down | |
| } | |
| }); | |
| return newSaving; | |
| }); | |
| // Refresh saved test cases list | |
| fetchRelatedTestcases(issue.id); | |
| } catch (error) { | |
| console.error('Failed to save test case:', error); | |
| setSavingGenerated(prev => ({ ...prev, [index]: 'error' })); | |
| toast.error(`Failed to save: ${testCase.title}`); | |
| } | |
| }; | |
| // Save all selected generated test cases | |
| const saveAllSelectedGenerated = async () => { | |
| if (selectedGenerated.size === 0) { | |
| toast.warning('No test cases selected'); | |
| return; | |
| } | |
| // Sort indices in descending order to prevent index shifting issues | |
| const indices = Array.from(selectedGenerated).sort((a, b) => b - a); | |
| for (const index of indices) { | |
| if (savingGenerated[index] !== 'saved') { | |
| await saveGeneratedTestCase(index); | |
| } | |
| } | |
| }; | |
| // Clear generated test cases | |
| const clearGeneratedTestCases = () => { | |
| setGeneratedTestCases([]); | |
| setSelectedGenerated(new Set()); | |
| setExpandedGenerated(new Set()); | |
| setSavingGenerated({}); | |
| setEditedGenerated({}); | |
| }; | |
| // Handle manual test case input change | |
| const handleManualInputChange = (field: string, value: string) => { | |
| setManualTestCase(prev => ({ ...prev, [field]: value })); | |
| }; | |
| // Save manual test case | |
| const saveManualTestCase = async () => { | |
| if (!issue) return; | |
| if (!manualTestCase.title.trim()) { | |
| toast.error('Title is required'); | |
| return; | |
| } | |
| setIsSavingManual(true); | |
| // Convert steps - auto-detect from newlines | |
| let formattedSteps = manualTestCase.stepsToReproduce; | |
| if (formattedSteps && !formattedSteps.match(/^\d+\./m)) { | |
| // If steps don't start with numbers, auto-format them | |
| const lines = formattedSteps.split(/\n/).filter(line => line.trim()); | |
| formattedSteps = lines.map((line, i) => `${i + 1}. ${line.trim()}`).join('\n'); | |
| } | |
| const assignedToValue = manualTestCase.assignedTo | |
| ? parseInt(manualTestCase.assignedTo, 10) | |
| : null; | |
| try { | |
| await tasksApi.create({ | |
| type: 'Test Case', | |
| title: manualTestCase.title, | |
| description: manualTestCase.description, | |
| issuesId: issue.id, | |
| status: manualTestCase.status || 'Draft', | |
| priority: manualTestCase.priority, | |
| severity: manualTestCase.severity, | |
| environment: manualTestCase.environment, | |
| stepsToReproduce: formattedSteps, | |
| inputs: manualTestCase.inputs || '', | |
| expectedBehavior: manualTestCase.expectedBehavior, | |
| actualBehavior: manualTestCase.actualBehavior || '', | |
| rootCause: '', | |
| resolution: '', | |
| version: manualTestCase.version || '', | |
| url: manualTestCase.url || '', | |
| sprintName: null, | |
| estimates: '', | |
| esUnit: null, | |
| stateDate: manualTestCase.startDate ? new Date(manualTestCase.startDate).toISOString() : new Date().toISOString(), | |
| endDate: manualTestCase.endDate ? new Date(manualTestCase.endDate).toISOString() : null, | |
| assignedTo: assignedToValue, | |
| remainingHr: null, | |
| }); | |
| toast.success('Test case added successfully'); | |
| setManualTestCase({ | |
| title: '', | |
| description: '', | |
| stepsToReproduce: '', | |
| inputs: '', | |
| expectedBehavior: '', | |
| actualBehavior: '', | |
| priority: 'Medium', | |
| severity: 'Minor', | |
| environment: 'Staging', | |
| status: 'Draft', | |
| version: '', | |
| url: '', | |
| assignedTo: '', | |
| startDate: new Date().toISOString().split('T')[0], | |
| endDate: '' | |
| }); | |
| setShowManualForm(false); | |
| fetchRelatedTestcases(issue.id); | |
| } catch (error) { | |
| console.error('Failed to save manual test case:', error); | |
| toast.error('Failed to add test case'); | |
| } finally { | |
| setIsSavingManual(false); | |
| } | |
| }; | |
| if (isLoading) { | |
| return ( | |
| <Layout> | |
| <div className="flex justify-center items-center h-[70vh]"> | |
| <div className="flex flex-col items-center"> | |
| <Loader2 className="h-7 w-7 md:h-8 md:w-8 animate-spin text-primary mb-3" /> | |
| <p className="text-sm md:text-base text-muted-foreground">Loading Feature details...</p> | |
| </div> | |
| </div> | |
| </Layout> | |
| ); | |
| } | |
| if (!issue) { | |
| return ( | |
| <Layout> | |
| <div className="flex flex-col items-center justify-center h-[70vh] px-4"> | |
| <h2 className="text-xl md:text-2xl font-bold mb-2">Feature Not Found</h2> | |
| <p className="text-sm md:text-base text-muted-foreground mb-4 text-center"> | |
| The Feature you're looking for doesn't exist or has been removed. | |
| </p> | |
| <Button onClick={() => navigate("/issues")} size="sm" className="h-9">Back to Features</Button> | |
| </div> | |
| </Layout> | |
| ); | |
| } | |
| return ( | |
| <Layout> | |
| <div className="space-y-4 md:space-y-6 px-3 md:px-6 animate-fadeIn"> | |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 md:gap-4"> | |
| <div className="flex items-start gap-2 md:gap-4"> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={() => navigate(-1)} | |
| className="mt-0.5" | |
| > | |
| <ArrowLeft className="h-4 w-4" /> | |
| </Button> | |
| <div> | |
| <div className="flex items-center gap-2 flex-wrap"> | |
| {getTypeBadge(issue.type)} | |
| <h1 className="text-xl md:text-2xl font-bold tracking-tight"> | |
| {issue.title} | |
| </h1> | |
| </div> | |
| <p className="text-sm text-muted-foreground"> | |
| Feature #{issue.id} {issue.biCode && `• ${issue.biCode}`} | |
| </p> | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-2 self-end sm:self-auto"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => navigate(`/issues/edit/${issue.id}`)} | |
| className="h-9" | |
| > | |
| <Pencil className="h-4 w-4 mr-1.5" /> Edit | |
| </Button> | |
| <Dialog open={confirmDialogOpen} onOpenChange={setConfirmDialogOpen}> | |
| <DialogTrigger asChild> | |
| <Button variant="destructive" size="sm" className="h-9"> | |
| <Trash2 className="h-4 w-4 mr-1.5" /> Delete | |
| </Button> | |
| </DialogTrigger> | |
| <DialogContent className="sm:max-w-md"> | |
| <DialogHeader> | |
| <DialogTitle>Delete Feature</DialogTitle> | |
| <DialogDescription> | |
| Are you sure you want to delete this Feature? This action cannot be undone. | |
| </DialogDescription> | |
| </DialogHeader> | |
| <DialogFooter className="flex flex-col sm:flex-row gap-2 sm:gap-0"> | |
| <Button | |
| variant="outline" | |
| onClick={() => setConfirmDialogOpen(false)} | |
| className="sm:w-auto w-full" | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| variant="destructive" | |
| onClick={handleDelete} | |
| disabled={isDeleting} | |
| className="sm:w-auto w-full" | |
| > | |
| {isDeleting ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| Deleting... | |
| </> | |
| ) : ( | |
| "Delete" | |
| )} | |
| </Button> | |
| </DialogFooter> | |
| </DialogContent> | |
| </Dialog> | |
| </div> | |
| </div> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-4 md:gap-6"> | |
| {/* Main content area */} | |
| <div className="md:col-span-2 space-y-4 md:space-y-6"> | |
| <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> | |
| <TabsList className="grid grid-cols-7 mb-4 overflow-x-auto whitespace-nowrap"> | |
| <TabsTrigger value="details" className="text-xs md:text-sm px-1 md:px-2 h-9"> | |
| <FileText className="h-3.5 w-3.5 md:h-4 md:w-4 mr-1 md:mr-2" /> | |
| <span className="hidden sm:inline">Details</span> | |
| <span className="sm:hidden">Info</span> | |
| </TabsTrigger> | |
| <TabsTrigger value="tasks" className="text-xs md:text-sm px-1 md:px-2 h-9"> | |
| <ClipboardList className="h-3.5 w-3.5 md:h-4 md:w-4 mr-1 md:mr-2" /> | |
| <span className="hidden sm:inline">Tasks</span> | |
| <span className="sm:hidden">Task</span> | |
| </TabsTrigger> | |
| <TabsTrigger value="testcases" className="text-xs md:text-sm px-1 md:px-2 h-9"> | |
| <CheckSquare className="h-3.5 w-3.5 md:h-4 md:w-4 mr-1 md:mr-2" /> | |
| <span className="hidden sm:inline">Testcases</span> | |
| <span className="sm:hidden">Test</span> | |
| </TabsTrigger> | |
| <TabsTrigger value="bugs" className="text-xs md:text-sm px-1 md:px-2 h-9"> | |
| <Bug className="h-3.5 w-3.5 md:h-4 md:w-4 mr-1 md:mr-2" /> | |
| <span className="hidden sm:inline">Bugs</span> | |
| <span className="sm:hidden">Bug</span> | |
| </TabsTrigger> | |
| <TabsTrigger value="attachments" className="text-xs md:text-sm px-1 md:px-2 h-9"> | |
| <Paperclip className="h-3.5 w-3.5 md:h-4 md:w-4 mr-1 md:mr-2" /> | |
| <span className="hidden sm:inline">Files</span> | |
| <span className="sm:hidden">File</span> | |
| </TabsTrigger> | |
| <TabsTrigger value="timelogs" className="text-xs md:text-sm px-1 md:px-2 h-9"> | |
| <History className="h-3.5 w-3.5 md:h-4 md:w-4 mr-1 md:mr-2" /> | |
| <span className="hidden sm:inline">Time Logs</span> | |
| <span className="sm:hidden">Time</span> | |
| </TabsTrigger> | |
| <TabsTrigger value="discussion" className="text-xs md:text-sm px-1 md:px-2 h-9"> | |
| <MessageSquare className="h-3.5 w-3.5 md:h-4 md:w-4 mr-1 md:mr-2" /> | |
| <span className="hidden sm:inline">Discussion</span> | |
| <span className="sm:hidden">Chat</span> | |
| </TabsTrigger> | |
| </TabsList> | |
| <TabsContent value="details" className="space-y-4 md:space-y-6"> | |
| <Card> | |
| <CardHeader className="px-4 py-3 md:px-6 md:py-4"> | |
| <CardTitle className="text-base md:text-lg">Description</CardTitle> | |
| </CardHeader> | |
| <CardContent className="px-4 md:px-6 text-sm md:text-base"> | |
| <div className="whitespace-pre-wrap">{issue.description || "No description provided."}</div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardHeader className="px-4 py-3 md:px-6 md:py-4"> | |
| <CardTitle className="text-base md:text-lg">Effort Estimation</CardTitle> | |
| </CardHeader> | |
| <CardContent className="px-4 md:px-6"> | |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm md:text-base"> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Estimates</h3> | |
| <p>{issue.estimates || "Not specified"}</p> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Estimate Units</h3> | |
| <p>{issue.esUnit >= 8 ? "Days" : "Hours"}</p> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Remaining Hours</h3> | |
| <p>{issue.remainingHr}</p> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </TabsContent> | |
| <TabsContent value="tasks" className="space-y-4 md:space-y-6"> | |
| <Card> | |
| <CardHeader className="flex flex-row items-center justify-between pb-2 px-4 py-3 md:px-6 md:py-4"> | |
| <div> | |
| <CardTitle className="text-base md:text-lg">Related Tasks</CardTitle> | |
| <CardDescription className="text-xs md:text-sm">Tasks associated with this Feature</CardDescription> | |
| </div> | |
| <Button | |
| size="sm" | |
| className="flex items-center gap-1 h-8" | |
| onClick={openAddTaskDialog} | |
| > | |
| <Plus className="h-3.5 w-3.5" /> | |
| <span className="hidden sm:inline">Add Task</span> | |
| <span className="sm:hidden">Add</span> | |
| </Button> | |
| </CardHeader> | |
| <CardContent className="px-4 md:px-6"> | |
| {isLoadingTasks ? ( | |
| <div className="flex items-center justify-center py-6"> | |
| <Loader2 className="h-5 w-5 md:h-6 md:w-6 animate-spin text-muted-foreground" /> | |
| <span className="ml-2 text-sm md:text-base text-muted-foreground">Loading tasks...</span> | |
| </div> | |
| ) : relatedTasks.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-6 md:py-8 text-center"> | |
| <div className="rounded-full bg-muted p-2 md:p-3 mb-2 md:mb-3"> | |
| <ClipboardList className="h-5 w-5 md:h-6 md:w-6 text-muted-foreground" /> | |
| </div> | |
| <p className="text-sm md:text-base text-muted-foreground mb-2 md:mb-3">No tasks associated with this Feature</p> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="flex items-center gap-1 text-xs h-8" | |
| onClick={openAddTaskDialog} | |
| > | |
| <Plus className="h-3.5 w-3.5" /> | |
| Create Task | |
| </Button> | |
| </div> | |
| ) : ( | |
| <div className="space-y-2 md:space-y-3"> | |
| {relatedTasks.map((task) => ( | |
| <div | |
| key={task.id} | |
| className="border rounded-md p-2 md:p-3 hover:bg-accent transition-colors cursor-pointer" | |
| onClick={() => navigate(`/tasks/${task.id}`)} | |
| > | |
| <div className="flex flex-col sm:flex-row sm:items-start justify-between mb-2 gap-2"> | |
| <div className="font-medium text-sm md:text-base">{task.title}</div> | |
| <div className="flex flex-wrap items-center gap-1.5 md:gap-2"> | |
| {getTaskStatusBadge(task.status)} | |
| {getTaskPriorityBadge(task.type)} | |
| </div> | |
| </div> | |
| <div | |
| className="text-xs md:text-sm text-muted-foreground mb-2 md:mb-3 line-clamp-2" | |
| dangerouslySetInnerHTML={{ __html: task.description }} | |
| /> | |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between text-xs text-muted-foreground gap-2 sm:gap-0"> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <span> | |
| Start: {formatTaskDate(task.stateDate)} | |
| </span> | |
| {task.endDate && ( | |
| <span> | |
| Due: {formatTaskDate(task.endDate)} | |
| </span> | |
| )} | |
| </div> | |
| {task.assignedTo && ( | |
| <div className="flex items-center"> | |
| <Avatar className="h-4 w-4 md:h-5 md:w-5 mr-1"> | |
| <AvatarFallback className="text-[9px] md:text-[10px]"> | |
| {getTaskAssigneeInitials(task.assignedTo)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="text-xs">{getTaskAssigneeName(task.assignedTo)}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </CardContent> | |
| <CardFooter className="flex justify-end pt-0 px-4 md:px-6"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="text-xs h-8" | |
| onClick={() => navigate(`/tasks?issueId=${issue?.id}`)} | |
| > | |
| Manage Tasks | |
| </Button> | |
| </CardFooter> | |
| </Card> | |
| </TabsContent> | |
| <TabsContent value="testcases" className="space-y-4 md:space-y-6"> | |
| <Card> | |
| <CardHeader className="flex flex-row items-center justify-between pb-2 px-4 py-3 md:px-6 md:py-4"> | |
| <div> | |
| <CardTitle className="text-base md:text-lg">Test Cases</CardTitle> | |
| <CardDescription className="text-xs md:text-sm">Manage test cases for this Feature</CardDescription> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="default" | |
| size="sm" | |
| className="bg-purple-600 hover:bg-purple-700 text-white gap-1.5 h-8" | |
| onClick={handleGenerateTestCases} | |
| disabled={isGeneratingTestCases || isExtractingPdf || relatedTasks.length === 0} | |
| > | |
| {isGeneratingTestCases || isExtractingPdf ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| <span className="hidden sm:inline">{isExtractingPdf ? 'Extracting PDF...' : 'Generating...'}</span> | |
| </> | |
| ) : ( | |
| <> | |
| <Sparkles className="h-4 w-4" /> | |
| <span className="hidden sm:inline">Generate with AI</span> | |
| <span className="sm:hidden">Generate</span> | |
| </> | |
| )} | |
| </Button> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="gap-1.5 h-8" | |
| onClick={() => setShowManualForm(!showManualForm)} | |
| > | |
| <Plus className="h-4 w-4" /> | |
| <span className="hidden sm:inline">Add Test Case</span> | |
| <span className="sm:hidden">Add</span> | |
| </Button> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="px-4 md:px-6 py-4 space-y-4"> | |
| {/* Optional Context Input - before manual form */} | |
| <div className="border border-purple-200 rounded-lg p-4 bg-purple-50/20"> | |
| <div className="flex items-center justify-between mb-2"> | |
| <h4 className="font-medium text-sm flex items-center gap-2"> | |
| <FileText className="h-4 w-4 text-purple-600" /> | |
| Additional Context <span className="text-muted-foreground font-normal">(Optional)</span> | |
| </h4> | |
| {(additionalContext || contextFile) && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-7 text-xs" | |
| onClick={() => { | |
| setAdditionalContext(''); | |
| setContextFile(null); | |
| }} | |
| > | |
| Clear All | |
| </Button> | |
| )} | |
| </div> | |
| <p className="text-xs text-muted-foreground mb-3"> | |
| Add extra context to help AI generate more relevant test cases. Supports text input and PDF files. | |
| </p> | |
| {/* Text Context */} | |
| <div className="mb-3"> | |
| <textarea | |
| value={additionalContext} | |
| onChange={(e) => setAdditionalContext(e.target.value)} | |
| placeholder="Paste requirements, specifications, user stories, or any additional context here..." | |
| className="w-full text-sm border rounded-md p-2 min-h-[80px] focus:outline-none focus:ring-1 focus:ring-purple-500 resize-y" | |
| /> | |
| </div> | |
| {/* PDF Upload */} | |
| <div className="flex items-center gap-3"> | |
| <label className="flex items-center gap-2 px-3 py-2 border border-dashed border-purple-300 rounded-md cursor-pointer hover:bg-purple-50 transition-colors"> | |
| <Upload className="h-4 w-4 text-purple-600" /> | |
| <span className="text-xs text-purple-700">Upload PDF</span> | |
| <input | |
| type="file" | |
| accept=".pdf" | |
| onChange={handleContextFileChange} | |
| className="hidden" | |
| disabled={isExtractingPdf} | |
| /> | |
| </label> | |
| {contextFile && ( | |
| <div className="flex items-center gap-2 px-3 py-1.5 bg-purple-100 rounded-md"> | |
| <File className="h-4 w-4 text-purple-600" /> | |
| <span className="text-xs text-purple-700 truncate max-w-[150px]">{contextFile.name}</span> | |
| <button | |
| onClick={clearContextFile} | |
| className="text-purple-500 hover:text-purple-700" | |
| > | |
| <X className="h-3 w-3" /> | |
| </button> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* Manual Test Case Form - shown inside same card */} | |
| {showManualForm && ( | |
| <div className="border border-blue-200 rounded-lg p-4 bg-blue-50/30 space-y-4"> | |
| <div className="flex items-center justify-between pb-2 border-b border-blue-200"> | |
| <h4 className="font-medium text-sm flex items-center gap-2"> | |
| <Plus className="h-4 w-4 text-blue-600" /> | |
| Add Test Case Manually | |
| </h4> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="h-7 w-7 p-0" | |
| onClick={() => setShowManualForm(false)} | |
| > | |
| <XCircle className="h-4 w-4" /> | |
| </Button> | |
| </div> | |
| {/* Title */} | |
| <div> | |
| <label className="text-sm font-medium">Title <span className="text-red-500">*</span></label> | |
| <input | |
| type="text" | |
| value={manualTestCase.title} | |
| onChange={(e) => handleManualInputChange('title', e.target.value)} | |
| placeholder="Enter test case title" | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| {/* Description */} | |
| <div> | |
| <label className="text-sm font-medium">Description</label> | |
| <textarea | |
| value={manualTestCase.description} | |
| onChange={(e) => handleManualInputChange('description', e.target.value)} | |
| placeholder="Describe what this test case validates" | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[50px] focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| {/* Steps to Reproduce */} | |
| <div> | |
| <label className="text-sm font-medium">Steps to Reproduce</label> | |
| <p className="text-xs text-muted-foreground mb-1">Enter each step on a new line - auto-numbered on save</p> | |
| <textarea | |
| value={manualTestCase.stepsToReproduce} | |
| onChange={(e) => handleManualInputChange('stepsToReproduce', e.target.value)} | |
| placeholder="Open the application Navigate to login page Enter credentials Click submit" | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[80px] font-mono focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| {/* Inputs */} | |
| <div> | |
| <label className="text-sm font-medium">Inputs</label> | |
| <p className="text-xs text-muted-foreground mb-1">Test data and inputs needed</p> | |
| <textarea | |
| value={manualTestCase.inputs} | |
| onChange={(e) => handleManualInputChange('inputs', e.target.value)} | |
| placeholder="Test data: username, password, file paths, etc." | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[50px] focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| {/* Expected Behavior */} | |
| <div> | |
| <label className="text-sm font-medium">Expected Behavior</label> | |
| <textarea | |
| value={manualTestCase.expectedBehavior} | |
| onChange={(e) => handleManualInputChange('expectedBehavior', e.target.value)} | |
| placeholder="What should happen after following the steps" | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[50px] focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| {/* Actual Behavior */} | |
| <div> | |
| <label className="text-sm font-medium">Actual Behavior</label> | |
| <textarea | |
| value={manualTestCase.actualBehavior} | |
| onChange={(e) => handleManualInputChange('actualBehavior', e.target.value)} | |
| placeholder="What actually happens (optional, for bug reporting)" | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[50px] focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| {/* Status, Priority, Severity, Environment */} | |
| <div className="grid grid-cols-2 md:grid-cols-4 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium">Status</label> | |
| <select | |
| value={manualTestCase.status} | |
| onChange={(e) => handleManualInputChange('status', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| > | |
| <option value="Draft">Draft</option> | |
| <option value="Ready">Ready</option> | |
| <option value="In Progress">In Progress</option> | |
| <option value="Blocked">Blocked</option> | |
| <option value="Completed">Completed</option> | |
| <option value="Passed">Passed</option> | |
| <option value="Failed">Failed</option> | |
| <option value="Skipped">Skipped</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium">Priority</label> | |
| <select | |
| value={manualTestCase.priority} | |
| onChange={(e) => handleManualInputChange('priority', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| > | |
| <option value="Critical">Critical</option> | |
| <option value="High">High</option> | |
| <option value="Medium">Medium</option> | |
| <option value="Low">Low</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium">Severity</label> | |
| <select | |
| value={manualTestCase.severity} | |
| onChange={(e) => handleManualInputChange('severity', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| > | |
| <option value="Blocker">Blocker</option> | |
| <option value="Critical">Critical</option> | |
| <option value="Major">Major</option> | |
| <option value="Minor">Minor</option> | |
| <option value="Trivial">Trivial</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium">Environment</label> | |
| <select | |
| value={manualTestCase.environment} | |
| onChange={(e) => handleManualInputChange('environment', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| > | |
| <option value="Development">Development</option> | |
| <option value="Testing">Testing</option> | |
| <option value="Staging">Staging</option> | |
| <option value="Production">Production</option> | |
| </select> | |
| </div> | |
| </div> | |
| {/* Version, URL, AssignedTo */} | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium">Version</label> | |
| <input | |
| type="text" | |
| value={manualTestCase.version} | |
| onChange={(e) => handleManualInputChange('version', e.target.value)} | |
| placeholder="e.g., 1.2.3" | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium">Related URL</label> | |
| <input | |
| type="url" | |
| value={manualTestCase.url} | |
| onChange={(e) => handleManualInputChange('url', e.target.value)} | |
| placeholder="e.g., https://example.com/page" | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium">Assigned To</label> | |
| <select | |
| value={manualTestCase.assignedTo} | |
| onChange={(e) => handleManualInputChange('assignedTo', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| > | |
| <option value="">Unassigned</option> | |
| {employees.map(emp => ( | |
| <option key={emp.id} value={emp.id}> | |
| {emp.firstName} {emp.lastName} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| </div> | |
| {/* Start Date, End Date */} | |
| <div className="grid grid-cols-1 md:grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium">Start Date</label> | |
| <input | |
| type="date" | |
| value={manualTestCase.startDate} | |
| onChange={(e) => handleManualInputChange('startDate', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium">Target Fix Date</label> | |
| <input | |
| type="date" | |
| value={manualTestCase.endDate} | |
| onChange={(e) => handleManualInputChange('endDate', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-blue-500" | |
| /> | |
| </div> | |
| </div> | |
| {/* Action Buttons */} | |
| <div className="flex justify-end gap-2 pt-2 border-t border-blue-200"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setShowManualForm(false)} | |
| className="h-8" | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| size="sm" | |
| onClick={saveManualTestCase} | |
| disabled={isSavingManual || !manualTestCase.title.trim()} | |
| className="bg-blue-600 hover:bg-blue-700 text-white h-8 gap-1.5" | |
| > | |
| {isSavingManual ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| Saving... | |
| </> | |
| ) : ( | |
| <> | |
| <Save className="h-4 w-4" /> | |
| Save Test Case | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| {/* Generated Test Cases - shown inside same card */} | |
| {generatedTestCases.length > 0 && ( | |
| <div className="border border-purple-200 rounded-lg p-4 bg-purple-50/30 space-y-3"> | |
| <div className="flex items-center justify-between pb-2 border-b border-purple-200"> | |
| <div> | |
| <h4 className="font-medium text-sm flex items-center gap-2"> | |
| <Sparkles className="h-4 w-4 text-purple-600" /> | |
| Generated Test Cases ({generatedTestCases.length}) | |
| </h4> | |
| <p className="text-xs text-muted-foreground mt-0.5"> | |
| {Object.values(savingGenerated).filter(s => s === 'saved').length} saved • {generatedTestCases.length - Object.values(savingGenerated).filter(s => s === 'saved').length} pending | |
| </p> | |
| </div> | |
| <div className="flex items-center gap-2"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| className="h-7 text-xs" | |
| onClick={clearGeneratedTestCases} | |
| > | |
| Clear All | |
| </Button> | |
| <Button | |
| size="sm" | |
| className="bg-purple-600 hover:bg-purple-700 text-white h-7 text-xs gap-1" | |
| onClick={saveAllSelectedGenerated} | |
| disabled={selectedGenerated.size === 0} | |
| > | |
| <Save className="h-3 w-3" /> | |
| Save Selected ({selectedGenerated.size}) | |
| </Button> | |
| </div> | |
| </div> | |
| {/* Select All */} | |
| <div className="flex items-center gap-2 pb-2 border-b"> | |
| <Checkbox | |
| id="select-all-generated" | |
| checked={selectedGenerated.size === generatedTestCases.length} | |
| onCheckedChange={() => { | |
| if (selectedGenerated.size === generatedTestCases.length) { | |
| setSelectedGenerated(new Set()); | |
| } else { | |
| setSelectedGenerated(new Set(generatedTestCases.map((_, i) => i))); | |
| } | |
| }} | |
| /> | |
| <label htmlFor="select-all-generated" className="text-sm font-medium cursor-pointer"> | |
| Select All ({selectedGenerated.size}/{generatedTestCases.length} selected) | |
| </label> | |
| </div> | |
| {/* Generated Test Cases List */} | |
| {generatedTestCases.map((tc, index) => { | |
| const currentCase = editedGenerated[index] || tc; | |
| const isExpanded = expandedGenerated.has(index); | |
| const isSelected = selectedGenerated.has(index); | |
| const saveState = savingGenerated[index] || 'idle'; | |
| return ( | |
| <div | |
| key={index} | |
| className={`border rounded-lg transition-all ${ | |
| isSelected ? 'border-purple-400 bg-white' : 'border-gray-200 bg-white/50' | |
| }`} | |
| > | |
| {/* Header */} | |
| <div className="flex items-start gap-3 p-3"> | |
| <div className="flex items-center gap-2"> | |
| <Checkbox | |
| checked={isSelected} | |
| onCheckedChange={() => toggleGeneratedSelection(index)} | |
| className="mt-1" | |
| /> | |
| <span className="text-xs text-muted-foreground font-mono bg-purple-100 px-1.5 py-0.5 rounded">#{index + 1}</span> | |
| </div> | |
| <div className="flex-1 min-w-0"> | |
| <div className="flex items-start justify-between gap-2"> | |
| <div className="flex-1"> | |
| <input | |
| type="text" | |
| value={currentCase.title} | |
| onChange={(e) => updateGeneratedTestCase(index, 'title', e.target.value)} | |
| className="font-medium text-sm w-full bg-transparent border-b border-transparent hover:border-gray-300 focus:border-purple-500 focus:outline-none px-0.5" | |
| /> | |
| <div className="flex flex-wrap items-center gap-1.5 mt-1.5"> | |
| <Badge variant="outline" className="text-xs"> | |
| {currentCase.priority} | |
| </Badge> | |
| <Badge variant="outline" className="text-xs"> | |
| {currentCase.severity} | |
| </Badge> | |
| <Badge variant="outline" className="text-xs"> | |
| {currentCase.environment} | |
| </Badge> | |
| {saveState === 'saved' && ( | |
| <Badge className="bg-green-500 text-white text-xs"> | |
| <CheckCircle className="h-3 w-3 mr-1" /> | |
| Saved | |
| </Badge> | |
| )} | |
| {saveState === 'error' && ( | |
| <Badge className="bg-red-500 text-white text-xs"> | |
| <XCircle className="h-3 w-3 mr-1" /> | |
| Error | |
| </Badge> | |
| )} | |
| </div> | |
| </div> | |
| <div className="flex items-center gap-1"> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => saveGeneratedTestCase(index)} | |
| disabled={saveState === 'saving' || saveState === 'saved'} | |
| className="h-7 w-7 p-0" | |
| title="Save this test case" | |
| > | |
| {saveState === 'saving' ? ( | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| ) : ( | |
| <Save className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| onClick={() => toggleGeneratedExpand(index)} | |
| className="h-7 w-7 p-0" | |
| title={isExpanded ? "Collapse" : "Expand"} | |
| > | |
| {isExpanded ? ( | |
| <ChevronUp className="h-4 w-4" /> | |
| ) : ( | |
| <ChevronDown className="h-4 w-4" /> | |
| )} | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Expanded Content */} | |
| {isExpanded && ( | |
| <div className="px-3 pb-3 pt-0 space-y-3 border-t"> | |
| {/* Description */} | |
| <div className="pt-3"> | |
| <label className="text-xs font-medium text-muted-foreground">Description</label> | |
| <textarea | |
| value={currentCase.description} | |
| onChange={(e) => updateGeneratedTestCase(index, 'description', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[50px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| /> | |
| </div> | |
| {/* Steps to Reproduce */} | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Steps to Reproduce</label> | |
| <textarea | |
| value={currentCase.stepsToReproduce || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'stepsToReproduce', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[70px] font-mono focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="1. First step 2. Second step 3. Third step" | |
| /> | |
| </div> | |
| {/* Expected Behavior */} | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Expected Behavior</label> | |
| <textarea | |
| value={currentCase.expectedBehavior || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'expectedBehavior', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[40px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| /> | |
| </div> | |
| {/* Test Data / Inputs */} | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Test Data / Inputs</label> | |
| <textarea | |
| value={currentCase.inputs || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'inputs', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[40px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="Test data or inputs needed" | |
| /> | |
| </div> | |
| {/* Actual Behavior */} | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Actual Behavior</label> | |
| <textarea | |
| value={currentCase.actualBehavior || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'actualBehavior', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[40px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="What actually happens (filled after testing)" | |
| /> | |
| </div> | |
| {/* Version and URL */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Version</label> | |
| <input | |
| type="text" | |
| value={currentCase.version || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'version', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="e.g., 1.2.3" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Related URL</label> | |
| <input | |
| type="text" | |
| value={currentCase.url || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'url', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="https://..." | |
| /> | |
| </div> | |
| </div> | |
| {/* Root Cause and Resolution */} | |
| <div className="grid grid-cols-1 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Root Cause</label> | |
| <textarea | |
| value={currentCase.rootCause || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'rootCause', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[40px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="What caused the issue (filled after analysis)" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Resolution</label> | |
| <textarea | |
| value={currentCase.resolution || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'resolution', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 min-h-[40px] focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| placeholder="How the issue was/will be resolved" | |
| /> | |
| </div> | |
| </div> | |
| {/* Two column layout */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Priority</label> | |
| <select | |
| value={currentCase.priority || 'Medium'} | |
| onChange={(e) => updateGeneratedTestCase(index, 'priority', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| > | |
| <option value="Critical">Critical</option> | |
| <option value="High">High</option> | |
| <option value="Medium">Medium</option> | |
| <option value="Low">Low</option> | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Severity</label> | |
| <select | |
| value={currentCase.severity || 'Minor'} | |
| onChange={(e) => updateGeneratedTestCase(index, 'severity', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| > | |
| <option value="Blocker">Blocker</option> | |
| <option value="Critical">Critical</option> | |
| <option value="Major">Major</option> | |
| <option value="Minor">Minor</option> | |
| <option value="Trivial">Trivial</option> | |
| </select> | |
| </div> | |
| </div> | |
| {/* Assigned To and Environment */} | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Assigned To</label> | |
| <select | |
| value={currentCase.assignedTo || ''} | |
| onChange={(e) => updateGeneratedTestCase(index, 'assignedTo', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| > | |
| <option value="">Unassigned</option> | |
| {employees.map(emp => ( | |
| <option key={emp.id} value={emp.id}> | |
| {emp.firstName} {emp.lastName} | |
| </option> | |
| ))} | |
| </select> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground">Environment</label> | |
| <select | |
| value={currentCase.environment || 'Staging'} | |
| onChange={(e) => updateGeneratedTestCase(index, 'environment', e.target.value)} | |
| className="w-full mt-1 text-sm border rounded-md p-2 focus:outline-none focus:ring-1 focus:ring-purple-500" | |
| > | |
| <option value="Development">Development</option> | |
| <option value="Testing">Testing</option> | |
| <option value="Staging">Staging</option> | |
| <option value="Production">Production</option> | |
| </select> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| )} | |
| {/* Saved Test Cases List */} | |
| {isLoadingTestcases ? ( | |
| <div className="flex items-center justify-center py-6"> | |
| <Loader2 className="h-5 w-5 md:h-6 md:w-6 animate-spin text-muted-foreground" /> | |
| <span className="ml-2 text-sm md:text-base text-muted-foreground">Loading test cases...</span> | |
| </div> | |
| ) : relatedTestcases.length === 0 && generatedTestCases.length === 0 && !showManualForm ? ( | |
| <div className="flex flex-col items-center justify-center py-8 text-center"> | |
| <div className="rounded-full bg-muted p-3 mb-3"> | |
| <CheckSquare className="h-6 w-6 text-muted-foreground" /> | |
| </div> | |
| <p className="text-base text-muted-foreground mb-2">No test cases saved yet</p> | |
| <p className="text-xs text-muted-foreground">Use "Generate with AI" or "Add Test Case" above to get started</p> | |
| </div> | |
| ) : relatedTestcases.length > 0 ? ( | |
| <div className="space-y-2 md:space-y-3"> | |
| <h4 className="font-medium text-sm text-muted-foreground pb-2 border-b">Saved Test Cases ({relatedTestcases.length})</h4> | |
| {relatedTestcases.map((tc) => ( | |
| <div | |
| key={tc.id} | |
| className="border rounded-md p-3 hover:bg-accent transition-colors cursor-pointer" | |
| onClick={() => navigate(`/test-cases/${tc.id}`)} | |
| > | |
| <div className="flex flex-col sm:flex-row sm:items-start justify-between mb-2 gap-2"> | |
| <div className="font-medium text-sm md:text-base">{tc.title}</div> | |
| <div className="flex flex-wrap items-center gap-1.5 md:gap-2"> | |
| {getTaskStatusBadge(tc.status)} | |
| {getTaskPriorityBadge(tc.priority || "Medium")} | |
| </div> | |
| </div> | |
| <div | |
| className="text-xs md:text-sm text-muted-foreground mb-2 md:mb-3 line-clamp-2" | |
| dangerouslySetInnerHTML={{ __html: tc.description }} | |
| /> | |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between text-xs text-muted-foreground gap-2 sm:gap-0"> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <span> | |
| Created: {formatTaskDate(tc.stateDate)} | |
| </span> | |
| </div> | |
| {tc.assignedTo && ( | |
| <div className="flex items-center"> | |
| <Avatar className="h-4 w-4 md:h-5 md:w-5 mr-1"> | |
| <AvatarFallback className="text-[9px] md:text-[10px]"> | |
| {getTaskAssigneeInitials(tc.assignedTo)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="text-xs">{getTaskAssigneeName(tc.assignedTo)}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| ) : null} | |
| </CardContent> | |
| </Card> | |
| </TabsContent> | |
| <TabsContent value="bugs" className="space-y-4 md:space-y-6"> | |
| <Card> | |
| <CardHeader className="flex flex-row items-center justify-between pb-2 px-4 py-3 md:px-6 md:py-4"> | |
| <div> | |
| <CardTitle className="text-base md:text-lg">Related Bugs</CardTitle> | |
| <CardDescription className="text-xs md:text-sm">Bugs associated with this Feature</CardDescription> | |
| </div> | |
| </CardHeader> | |
| <CardContent className="px-4 md:px-6"> | |
| {isLoadingBugs ? ( | |
| <div className="flex items-center justify-center py-6"> | |
| <Loader2 className="h-5 w-5 md:h-6 md:w-6 animate-spin text-muted-foreground" /> | |
| <span className="ml-2 text-sm md:text-base text-muted-foreground">Loading bugs...</span> | |
| </div> | |
| ) : relatedBugs.length === 0 ? ( | |
| <div className="flex flex-col items-center justify-center py-6 md:py-8 text-center"> | |
| <div className="rounded-full bg-muted p-2 md:p-3 mb-2 md:mb-3"> | |
| <Bug className="h-5 w-5 md:h-6 md:w-6 text-muted-foreground" /> | |
| </div> | |
| <p className="text-sm md:text-base text-muted-foreground mb-2 md:mb-3">No bugs associated with this Feature</p> | |
| </div> | |
| ) : ( | |
| <div className="space-y-2 md:space-y-3"> | |
| {relatedBugs.map((bug) => ( | |
| <div | |
| key={bug.id} | |
| className="border rounded-md p-2 md:p-3 hover:bg-accent transition-colors cursor-pointer" | |
| onClick={() => navigate(`/bug-detail/${bug.id}`)} | |
| > | |
| <div className="flex flex-col sm:flex-row sm:items-start justify-between mb-2 gap-2"> | |
| <div className="font-medium text-sm md:text-base">{bug.title}</div> | |
| <div className="flex flex-wrap items-center gap-1.5 md:gap-2"> | |
| {getTaskStatusBadge(bug.status)} | |
| {getTaskPriorityBadge(bug.priority || "Medium")} | |
| </div> | |
| </div> | |
| <div | |
| className="text-xs md:text-sm text-muted-foreground mb-2 md:mb-3 line-clamp-2" | |
| dangerouslySetInnerHTML={{ __html: bug.description }} | |
| /> | |
| <div className="flex flex-col sm:flex-row sm:items-center justify-between text-xs text-muted-foreground gap-2 sm:gap-0"> | |
| <div className="flex flex-wrap items-center gap-2"> | |
| <span> | |
| Created: {formatTaskDate(bug.stateDate)} | |
| </span> | |
| </div> | |
| {bug.assignedTo && ( | |
| <div className="flex items-center"> | |
| <Avatar className="h-4 w-4 md:h-5 md:w-5 mr-1"> | |
| <AvatarFallback className="text-[9px] md:text-[10px]"> | |
| {getTaskAssigneeInitials(bug.assignedTo)} | |
| </AvatarFallback> | |
| </Avatar> | |
| <span className="text-xs">{getTaskAssigneeName(bug.assignedTo)}</span> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| </TabsContent> | |
| <TabsContent value="attachments" className="space-y-4 md:space-y-6"> | |
| {issue && <TaskAttachmentsSection taskId={parseInt(id)} entityType="Issue" />} | |
| </TabsContent> | |
| <TabsContent value="timelogs" className="space-y-4 md:space-y-6"> | |
| {issue && <TaskTimeLogCard taskId={parseInt(id)} entityType="Issue" />} | |
| </TabsContent> | |
| <TabsContent value="discussion" className="space-y-4 md:space-y-6"> | |
| {issue && <TaskCommentSection taskId={parseInt(id)} entityType="Issue" />} | |
| </TabsContent> | |
| </Tabs> | |
| </div> | |
| {/* Sidebar - Details and People */} | |
| <div className="space-y-4 md:space-y-6"> | |
| <Card> | |
| <CardHeader className="px-4 py-3 md:px-6 md:py-4"> | |
| <CardTitle className="text-base md:text-lg">Details</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4 px-4 md:px-6 text-sm md:text-base"> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Status</h3> | |
| <Badge variant="outline" className={cn( | |
| "font-medium", | |
| issue.status?.toLowerCase() === "completed" && "bg-green-100 text-green-800", | |
| issue.status?.toLowerCase() === "in progress" && "bg-blue-100 text-blue-800", | |
| issue.status?.toLowerCase() === "open" && "bg-yellow-100 text-yellow-800", | |
| issue.status?.toLowerCase() === "in review" && "bg-purple-100 text-purple-800", | |
| issue.status?.toLowerCase() === "closed" && "bg-gray-100 text-gray-800", | |
| !issue.status && "bg-gray-100 text-gray-800" | |
| )}> | |
| {issue.status || "Open"} | |
| </Badge> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Type</h3> | |
| <div className="flex items-center gap-2"> | |
| {getTypeBadge(issue.type)} | |
| </div> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Priority</h3> | |
| <div className="flex items-center gap-2"> | |
| {getPriorityBadge(issue.priority)} | |
| </div> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Severity</h3> | |
| <div className="flex items-center gap-2"> | |
| {getSeverityBadge(issue.severity)} | |
| </div> | |
| </div> | |
| {issue.startDate && ( | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Start Date</h3> | |
| <div className="flex items-center gap-2"> | |
| <Calendar className="h-4 w-4 text-muted-foreground" /> | |
| <span>{format(new Date(issue.startDate), "MMMM d, yyyy")}</span> | |
| </div> | |
| </div> | |
| )} | |
| {issue.endDate && ( | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">End Date</h3> | |
| <div className="flex items-center gap-2"> | |
| <Calendar className="h-4 w-4 text-muted-foreground" /> | |
| <span>{format(new Date(issue.endDate), "MMMM d, yyyy")}</span> | |
| </div> | |
| </div> | |
| )} | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Project</h3> | |
| <p>{project ? project.projectName : "Unknown Project"}</p> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Deliverable</h3> | |
| <p>{deliverable ? deliverable.name : "Unknown Deliverable"}</p> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| <Card> | |
| <CardHeader className="px-4 py-3 md:px-6 md:py-4"> | |
| <CardTitle className="text-base md:text-lg">People</CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4 px-4 md:px-6 text-sm md:text-base"> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Reported By</h3> | |
| <div className="flex items-center"> | |
| <Avatar className="h-7 w-7 md:h-8 md:w-8 mr-2"> | |
| <AvatarFallback>{getEmployeeInitials(reporter)}</AvatarFallback> | |
| </Avatar> | |
| <span> | |
| {reporter ? `${reporter.firstName} ${reporter.lastName}` : "Unknown"} | |
| </span> | |
| </div> | |
| </div> | |
| <div> | |
| <h3 className="text-xs font-medium text-muted-foreground mb-1">Assigned To</h3> | |
| <div className="flex items-center"> | |
| <Avatar className="h-7 w-7 md:h-8 md:w-8 mr-2"> | |
| <AvatarFallback>{getEmployeeInitials(assignee)}</AvatarFallback> | |
| </Avatar> | |
| <span> | |
| {assignee ? `${assignee.firstName} ${assignee.lastName}` : "Unknown"} | |
| </span> | |
| </div> | |
| </div> | |
| </CardContent> | |
| </Card> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Add the simple task dialog outside of the tabs, but at the component level */} | |
| {issue && ( | |
| <SimpleAddTaskDialog | |
| issueId={issue.id} | |
| onTaskAdded={() => fetchRelatedTasks(issue.id)} | |
| employees={employees} | |
| isOpen={addTaskDialogOpen} | |
| onClose={() => setAddTaskDialogOpen(false)} | |
| /> | |
| )} | |
| </Layout> | |
| ); | |
| }; | |
| export default IssueDetail; |