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(null); const [project, setProject] = useState(null); const [deliverable, setDeliverable] = useState(null); const [reporter, setReporter] = useState(null); const [assignee, setAssignee] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isDeleting, setIsDeleting] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); const [relatedTasks, setRelatedTasks] = useState([]); const [relatedTestcases, setRelatedTestcases] = useState([]); const [relatedBugs, setRelatedBugs] = useState([]); const [isLoadingTasks, setIsLoadingTasks] = useState(false); const [isLoadingTestcases, setIsLoadingTestcases] = useState(false); const [isLoadingBugs, setIsLoadingBugs] = useState(false); const [employees, setEmployees] = useState([]); const [activeTab, setActiveTab] = useState("tasks"); const [addTaskDialogOpen, setAddTaskDialogOpen] = useState(false); const [isGeneratingTestCases, setIsGeneratingTestCases] = useState(false); const [generatedTestCases, setGeneratedTestCases] = useState([]); const [selectedGenerated, setSelectedGenerated] = useState>(new Set()); const [savingGenerated, setSavingGenerated] = useState>({}); const [expandedGenerated, setExpandedGenerated] = useState>(new Set()); const [editedGenerated, setEditedGenerated] = useState>({}); 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(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 ( {type} ); }; 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 ( {priority} ); }; 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 ( {severity} ); }; 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 ( {status} ); case "In Progress": return ( {status} ); case "In Review": return ( {status} ); case "New": default: return ( {status || "New"} ); } }; // Helper to get task priority badge const getTaskPriorityBadge = (priority: string) => { switch (priority.toLowerCase()) { case "high": return ( {priority} ); case "medium": return ( {priority} ); case "low": return ( {priority} ); default: return ( {priority || "Normal"} ); } }; // 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 => { 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) => { 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 = {}; 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 = {}; 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 (

Loading Feature details...

); } if (!issue) { return (

Feature Not Found

The Feature you're looking for doesn't exist or has been removed.

); } return (
{getTypeBadge(issue.type)}

{issue.title}

Feature #{issue.id} {issue.biCode && `• ${issue.biCode}`}

Delete Feature Are you sure you want to delete this Feature? This action cannot be undone.
{/* Main content area */}
Details Info Tasks Task Testcases Test Bugs Bug Files File Time Logs Time Discussion Chat Description
{issue.description || "No description provided."}
Effort Estimation

Estimates

{issue.estimates || "Not specified"}

Estimate Units

{issue.esUnit >= 8 ? "Days" : "Hours"}

Remaining Hours

{issue.remainingHr}

Related Tasks Tasks associated with this Feature
{isLoadingTasks ? (
Loading tasks...
) : relatedTasks.length === 0 ? (

No tasks associated with this Feature

) : (
{relatedTasks.map((task) => (
navigate(`/tasks/${task.id}`)} >
{task.title}
{getTaskStatusBadge(task.status)} {getTaskPriorityBadge(task.type)}
Start: {formatTaskDate(task.stateDate)} {task.endDate && ( Due: {formatTaskDate(task.endDate)} )}
{task.assignedTo && (
{getTaskAssigneeInitials(task.assignedTo)} {getTaskAssigneeName(task.assignedTo)}
)}
))}
)}
Test Cases Manage test cases for this Feature
{/* Optional Context Input - before manual form */}

Additional Context (Optional)

{(additionalContext || contextFile) && ( )}

Add extra context to help AI generate more relevant test cases. Supports text input and PDF files.

{/* Text Context */}