pmtool / src /pages /issue-detail.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
96.2 kB
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&#10;Navigate to login page&#10;Enter credentials&#10;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&#10;2. Second step&#10;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;