Spaces:
Sleeping
Sleeping
| import React, { useState, useCallback, useEffect } from "react"; | |
| import { Button } from "@/components/ui/button"; | |
| import { Upload, FileText, AlertCircle, Download } from "lucide-react"; | |
| import { toast } from "sonner"; | |
| import { tasksApi, TaskCreateRequest } from "@/services/tasksApi"; | |
| import { employeeService, projectService, issuesApi } from "@/lib/api"; | |
| import * as XLSX from 'xlsx'; | |
| import { Employee, ProjectApi, IssueApi } from "@/types"; | |
| import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; | |
| import { Progress } from "@/components/ui/progress"; | |
| import CustomDialog from "@/components/CustomDialog"; | |
| import { Dropdown } from "@/components/ui/dropdown"; | |
| interface ImportTasksDialogProps { | |
| taskType: "Bug" | "Test Case"; | |
| projectId?: number; | |
| onImportComplete: () => void; | |
| } | |
| interface PreviewData { | |
| preview: any[]; | |
| total: number; | |
| all: any[]; | |
| } | |
| const ImportTasksDialog: React.FC<ImportTasksDialogProps> = ({ | |
| taskType, | |
| projectId, | |
| onImportComplete | |
| }) => { | |
| const [isOpen, setIsOpen] = useState(false); | |
| const [isUploading, setIsUploading] = useState(false); | |
| const [error, setError] = useState<string | null>(null); | |
| const [uploadProgress, setUploadProgress] = useState(0); | |
| const [previewData, setPreviewData] = useState<PreviewData | null>(null); | |
| const [fileName, setFileName] = useState<string>(""); | |
| // State for data from APIs | |
| const [loading, setLoading] = useState(false); | |
| const [employees, setEmployees] = useState<Employee[]>([]); | |
| const [projects, setProjects] = useState<ProjectApi[]>([]); | |
| const [issues, setIssues] = useState<IssueApi[]>([]); | |
| const [filteredIssues, setFilteredIssues] = useState<IssueApi[]>([]); | |
| // Selected values | |
| const [selectedAssignedTo, setSelectedAssignedTo] = useState<number | "">(""); | |
| const [selectedProjectId, setSelectedProjectId] = useState<number | "">(projectId || ""); | |
| const [selectedIssueId, setSelectedIssueId] = useState<number | "">(""); | |
| // Get default status based on task type | |
| const getDefaultStatus = () => { | |
| if (taskType === "Bug") { | |
| return "Open"; | |
| } else { | |
| return "Draft"; | |
| } | |
| }; | |
| // Fetch initial data | |
| useEffect(() => { | |
| const fetchInitialData = async () => { | |
| if (!isOpen) return; | |
| setLoading(true); | |
| try { | |
| const [employeeData, projectData, issuesData] = await Promise.all([ | |
| employeeService.getAll(), | |
| projectService.getAll(), | |
| issuesApi.getAll() | |
| ]); | |
| setEmployees(employeeData); | |
| setProjects(projectData); | |
| setIssues(issuesData); | |
| if (projectId) { | |
| setSelectedProjectId(projectId); | |
| } | |
| } catch (error) { | |
| console.error("Error fetching data:", error); | |
| toast.error("Failed to load data"); | |
| } finally { | |
| setLoading(false); | |
| } | |
| }; | |
| fetchInitialData(); | |
| }, [isOpen, projectId]); | |
| // Filter issues based on selected project | |
| useEffect(() => { | |
| if (selectedProjectId && typeof selectedProjectId === 'number') { | |
| const filtered = issues.filter(issue => issue.projectId === selectedProjectId); | |
| setFilteredIssues(filtered); | |
| // Reset selected issue if it's not in the filtered list | |
| if (selectedIssueId && !filtered.find(issue => issue.id === selectedIssueId)) { | |
| setSelectedIssueId(""); | |
| } | |
| } else { | |
| setFilteredIssues(issues); | |
| } | |
| }, [selectedProjectId, issues, selectedIssueId]); | |
| // Template Excel download handler | |
| const handleDownloadTemplate = () => { | |
| const headers = taskType === "Bug" | |
| ? [ | |
| "Title", | |
| "Description", | |
| "Issue/Feature ID (Required)", | |
| "Priority", | |
| "Severity", | |
| "Assigned To (Employee ID - Optional)", | |
| "Project ID (Optional)", | |
| "Version (Optional)", | |
| "Environment (Optional)", | |
| "Steps To Reproduce (Optional)", | |
| "Expected Behavior (Optional)", | |
| "Actual Behavior (Optional)" | |
| ] | |
| : [ | |
| "Title", | |
| "Description", | |
| "Issue/Feature ID (Required)", | |
| "Priority", | |
| "Severity", | |
| "Assigned To (Employee ID - Optional)", | |
| "Project ID (Optional)", | |
| "Version (Optional)", | |
| "Test Steps (Optional)", | |
| "Expected Result (Optional)", | |
| "Actual Result (Optional)" | |
| ]; | |
| const sampleData = taskType === "Bug" | |
| ? [ | |
| [ | |
| "Sample Bug Title", | |
| "Description of the bug", | |
| "1", // Issue/Feature ID - Required (use actual ID from your issues list) | |
| "High", // Priority: Low, Medium, High, Critical | |
| "Major", // Severity: Critical, Major, Minor, Trivial | |
| "", // Assigned To (Employee ID) - Optional | |
| projectId || "", // Project ID - Optional | |
| "1.0.0", // Version | |
| "Production", // Environment | |
| "1. Step one\n2. Step two", // Steps to reproduce | |
| "Expected behavior description", // Expected Behavior | |
| "Actual behavior description" // Actual Behavior | |
| ] | |
| ] | |
| : [ | |
| [ | |
| "Sample Test Case Title", | |
| "Description of the test case", | |
| "1", // Issue/Feature ID - Required (use actual ID from your issues list) | |
| "High", // Priority: Low, Medium, High, Critical | |
| "High", // Severity: High, Medium, Low | |
| "", // Assigned To (Employee ID) - Optional | |
| projectId || "", // Project ID - Optional | |
| "1.0.0", // Version | |
| "1. Test step one\n2. Test step two", // Test Steps | |
| "Expected result description", // Expected Result | |
| "Actual result description" // Actual Result | |
| ] | |
| ]; | |
| const workbook = XLSX.utils.book_new(); | |
| const worksheet = XLSX.utils.aoa_to_sheet([headers, ...sampleData]); | |
| // Set column widths | |
| worksheet['!cols'] = headers.map(() => ({ wch: 20 })); | |
| XLSX.utils.book_append_sheet(workbook, worksheet, taskType === "Bug" ? "Bugs" : "Test Cases"); | |
| const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' }); | |
| const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); | |
| const url = URL.createObjectURL(blob); | |
| const link = document.createElement('a'); | |
| link.setAttribute('href', url); | |
| link.setAttribute('download', `${taskType.toLowerCase().replace(' ', '_')}_import_template.xlsx`); | |
| document.body.appendChild(link); | |
| link.click(); | |
| document.body.removeChild(link); | |
| toast.success("Template downloaded successfully"); | |
| }; | |
| const handleFileUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const file = event.target.files?.[0]; | |
| if (!file) { | |
| return; | |
| } | |
| const fileExtension = file.name.split('.').pop()?.toLowerCase(); | |
| if (fileExtension !== 'xlsx' && fileExtension !== 'xls') { | |
| const errorMsg = "Invalid file type. Please upload an Excel file (.xlsx or .xls)."; | |
| setError(errorMsg); | |
| toast.error(errorMsg); | |
| return; | |
| } | |
| if (file.size > 10 * 1024 * 1024) { | |
| const errorMsg = "File size too large. Maximum size is 10MB"; | |
| setError(errorMsg); | |
| toast.error(errorMsg); | |
| return; | |
| } | |
| setFileName(file.name); | |
| setError(null); | |
| setPreviewData(null); | |
| setIsUploading(true); | |
| try { | |
| const reader = new FileReader(); | |
| reader.onload = (e) => { | |
| try { | |
| const data = new Uint8Array(e.target?.result as ArrayBuffer); | |
| const workbook = XLSX.read(data, { type: 'array' }); | |
| const firstSheetName = workbook.SheetNames[0]; | |
| const worksheet = workbook.Sheets[firstSheetName]; | |
| const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 }); | |
| if (jsonData.length <= 1) { | |
| setError("The Excel file contains no data rows."); | |
| setIsUploading(false); | |
| return; | |
| } | |
| const headers = jsonData[0] as string[]; | |
| const rows = []; | |
| for (let i = 1; i < jsonData.length; i++) { | |
| const row = jsonData[i] as any[]; | |
| const obj: any = {}; | |
| for (let j = 0; j < headers.length; j++) { | |
| obj[headers[j]] = row[j] || ""; | |
| } | |
| rows.push(obj); | |
| } | |
| const requiredFields = ["Title", "Description", "Issue/Feature ID (Required)"]; | |
| const missingFields = requiredFields.filter(field => | |
| !headers.includes(field) | |
| ); | |
| if (missingFields.length > 0) { | |
| setError(`Missing required columns: ${missingFields.join(', ')}`); | |
| setIsUploading(false); | |
| return; | |
| } | |
| const allRows = [...rows]; | |
| const previewRows = rows.slice(0, 10); | |
| setPreviewData({ | |
| preview: previewRows, | |
| total: allRows.length, | |
| all: allRows | |
| }); | |
| toast.success(`Excel file loaded with ${allRows.length} ${taskType.toLowerCase()}s ready to import`); | |
| setIsUploading(false); | |
| } catch (error) { | |
| console.error('Excel parsing error:', error); | |
| setError("Failed to parse Excel file. Please check the file format."); | |
| toast.error("Failed to parse Excel file"); | |
| setIsUploading(false); | |
| } | |
| }; | |
| reader.readAsArrayBuffer(file); | |
| } catch (error) { | |
| console.error('File reading error:', error); | |
| setError("Failed to read the file. Please try again."); | |
| toast.error("Failed to read the file"); | |
| setIsUploading(false); | |
| } | |
| }, [taskType]); | |
| const handleImport = useCallback(async () => { | |
| if (!previewData?.all?.length) { | |
| toast.error("No data to import"); | |
| return; | |
| } | |
| setIsUploading(true); | |
| setError(null); | |
| setUploadProgress(10); | |
| try { | |
| const allTasks = previewData.all; | |
| const progressStep = 80 / allTasks.length; | |
| let successCount = 0; | |
| let failureCount = 0; | |
| for (let i = 0; i < allTasks.length; i++) { | |
| const taskData = allTasks[i] as Record<string, any>; | |
| try { | |
| const issueId = selectedIssueId || parseInt(String(taskData["Issue/Feature ID (Required)"])) || null; | |
| // Skip if missing required issue/feature ID | |
| if (!issueId) { | |
| console.error(`Row ${i + 1}: Missing required Issue/Feature ID`); | |
| failureCount++; | |
| continue; | |
| } | |
| const taskRequest: TaskCreateRequest = { | |
| type: taskType, | |
| title: taskData.Title || "", | |
| description: taskData.Description || "", | |
| priority: taskData.Priority || "Medium", | |
| severity: taskData.Severity || (taskType === "Bug" ? "Minor" : "Medium"), | |
| status: getDefaultStatus(), | |
| assignedTo: selectedAssignedTo || parseInt(String(taskData["Assigned To (Employee ID - Optional)"])) || null, | |
| issuesId: issueId, | |
| version: taskData["Version (Optional)"] || "", | |
| estimates: "0", | |
| esUnit: 1, | |
| remainingHr: 0, | |
| stateDate: new Date().toISOString(), | |
| endDate: null, | |
| sprintName: null, | |
| }; | |
| // Add bug-specific fields | |
| if (taskType === "Bug") { | |
| taskRequest.environment = taskData["Environment (Optional)"] || ""; | |
| taskRequest.stepsToReproduce = taskData["Steps To Reproduce (Optional)"] || ""; | |
| taskRequest.expectedBehavior = taskData["Expected Behavior (Optional)"] || ""; | |
| taskRequest.actualBehavior = taskData["Actual Behavior (Optional)"] || ""; | |
| } else { | |
| // Test case specific fields | |
| taskRequest.stepsToReproduce = taskData["Test Steps (Optional)"] || ""; | |
| taskRequest.expectedBehavior = taskData["Expected Result (Optional)"] || ""; | |
| taskRequest.actualBehavior = taskData["Actual Result (Optional)"] || ""; | |
| } | |
| if (!taskRequest.title) { | |
| failureCount++; | |
| continue; | |
| } | |
| await tasksApi.create(taskRequest); | |
| successCount++; | |
| } catch (error) { | |
| console.error(`Error importing ${taskType.toLowerCase()}:`, error); | |
| failureCount++; | |
| } | |
| setUploadProgress(10 + (i + 1) * progressStep); | |
| } | |
| setUploadProgress(100); | |
| if (successCount > 0) { | |
| toast.success(`Successfully imported ${successCount} ${taskType.toLowerCase()}${successCount > 1 ? 's' : ''}`); | |
| if (failureCount > 0) { | |
| toast.error(`Failed to import ${failureCount} ${taskType.toLowerCase()}${failureCount > 1 ? 's' : ''}`); | |
| } | |
| setTimeout(() => { | |
| setIsOpen(false); | |
| onImportComplete(); | |
| setPreviewData(null); | |
| setFileName(""); | |
| setUploadProgress(0); | |
| setSelectedAssignedTo(""); | |
| setSelectedProjectId(""); | |
| setSelectedIssueId(""); | |
| }, 1500); | |
| } else { | |
| throw new Error(`Failed to import any ${taskType.toLowerCase()}s`); | |
| } | |
| } catch (error: any) { | |
| console.error('Import processing error:', error); | |
| setError(error.message || "Failed to process Excel data"); | |
| toast.error(error.message || "Failed to process Excel data"); | |
| setUploadProgress(0); | |
| setIsUploading(false); | |
| } | |
| }, [previewData, projectId, onImportComplete, selectedAssignedTo, selectedProjectId, selectedIssueId, taskType]); | |
| const renderContent = () => ( | |
| <div className="flex-1 overflow-auto"> | |
| <div className="flex flex-col space-y-4"> | |
| <div className="mb-4 flex justify-between items-center"> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={handleDownloadTemplate} | |
| className="flex items-center gap-2" | |
| > | |
| <Download className="h-4 w-4" /> | |
| Download Excel Template | |
| </Button> | |
| {fileName && previewData?.preview?.length > 0 && ( | |
| <div className="text-sm text-muted-foreground"> | |
| Showing {previewData.preview.length} of {previewData.total} {taskType.toLowerCase()}s | |
| </div> | |
| )} | |
| </div> | |
| {!fileName && ( | |
| <div className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8"> | |
| <input | |
| type="file" | |
| accept=".xlsx,.xls" | |
| onChange={handleFileUpload} | |
| className="hidden" | |
| id="task-excel-upload" | |
| disabled={isUploading} | |
| /> | |
| <label | |
| htmlFor="task-excel-upload" | |
| className={`cursor-pointer flex flex-col items-center justify-center ${ | |
| isUploading ? 'opacity-50 cursor-not-allowed' : '' | |
| }`} | |
| > | |
| <Upload className="h-12 w-12 mb-2 text-muted-foreground" /> | |
| <span className="text-sm font-medium mb-1">Click to upload Excel file</span> | |
| <span className="text-xs text-muted-foreground">Supported formats: .xlsx, .xls (Max: 10MB)</span> | |
| </label> | |
| </div> | |
| )} | |
| {isUploading && !previewData && ( | |
| <div className="flex justify-center py-8"> | |
| <div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div> | |
| </div> | |
| )} | |
| {fileName && previewData?.preview?.length > 0 && ( | |
| <div className="space-y-4"> | |
| <div className="flex items-center p-3 bg-muted/50 rounded-md"> | |
| <FileText className="h-5 w-5 mr-2 text-muted-foreground" /> | |
| <span className="text-sm font-medium">{fileName}</span> | |
| {!isUploading && ( | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="ml-auto" | |
| onClick={() => { | |
| setFileName(""); | |
| setPreviewData(null); | |
| const input = document.getElementById("task-excel-upload") as HTMLInputElement; | |
| if (input) input.value = ""; | |
| }} | |
| > | |
| Change | |
| </Button> | |
| )} | |
| </div> | |
| <div className="border rounded-md p-4 bg-muted/20"> | |
| <h4 className="text-sm font-medium mb-3">Set values for all {taskType.toLowerCase()}s:</h4> | |
| <div className="grid grid-cols-1 md:grid-cols-3 gap-3"> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground mb-1 block"> | |
| Assigned To | |
| </label> | |
| <Dropdown | |
| options={[ | |
| { label: "— Select Assignee —", value: "" }, | |
| ...employees.map(emp => ({ | |
| label: `${emp.firstName} ${emp.lastName}`, | |
| value: emp.id.toString() | |
| })) | |
| ]} | |
| value={selectedAssignedTo ? selectedAssignedTo.toString() : ""} | |
| onValueChange={(value) => setSelectedAssignedTo(value ? parseInt(value) : "")} | |
| placeholder="Select Assignee" | |
| disabled={isUploading || loading} | |
| className="w-full" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground mb-1 block"> | |
| Project | |
| </label> | |
| <Dropdown | |
| options={[ | |
| { label: "— Select Project —", value: "" }, | |
| ...projects.map(proj => ({ | |
| label: proj.projectName, | |
| value: proj.id.toString() | |
| })) | |
| ]} | |
| value={selectedProjectId ? selectedProjectId.toString() : ""} | |
| onValueChange={(value) => setSelectedProjectId(value ? parseInt(value) : "")} | |
| placeholder="Select Project" | |
| disabled={isUploading || loading} | |
| className="w-full" | |
| /> | |
| </div> | |
| <div> | |
| <label className="text-xs font-medium text-muted-foreground mb-1 block"> | |
| Issue/Feature <span className="text-red-500">*</span> | |
| </label> | |
| <Dropdown | |
| options={[ | |
| { label: "— Select Issue/Feature —", value: "" }, | |
| ...filteredIssues.map(issue => ({ | |
| label: `${issue.biCode} - ${issue.title}`, | |
| value: issue.id.toString() | |
| })) | |
| ]} | |
| value={selectedIssueId ? selectedIssueId.toString() : ""} | |
| onValueChange={(value) => setSelectedIssueId(value ? parseInt(value) : "")} | |
| placeholder="Select Issue/Feature" | |
| disabled={isUploading || loading} | |
| className="w-full" | |
| /> | |
| </div> | |
| </div> | |
| <p className="text-xs text-muted-foreground mt-2"> | |
| <span className="font-medium">Note:</span> Issue/Feature is mandatory for all {taskType.toLowerCase()}s. | |
| If set here, it will override values in the imported file. Otherwise, each row must have a valid Issue/Feature ID. | |
| </p> | |
| </div> | |
| <div className="space-y-2"> | |
| <div className="flex justify-between items-center"> | |
| <h4 className="text-sm font-medium">Preview rows:</h4> | |
| <Button | |
| variant="ghost" | |
| size="sm" | |
| className="text-xs" | |
| onClick={() => { | |
| setFileName(""); | |
| setPreviewData(null); | |
| const input = document.getElementById("task-excel-upload") as HTMLInputElement; | |
| if (input) input.value = ""; | |
| }} | |
| > | |
| Upload Different File | |
| </Button> | |
| </div> | |
| <div className="border rounded-md overflow-auto" style={{ maxHeight: "300px" }}> | |
| <table className="min-w-full divide-y divide-border"> | |
| <thead className="bg-muted/50 sticky top-0 z-10"> | |
| <tr> | |
| {previewData?.preview?.length > 0 && Object.keys(previewData.preview[0]).map((header) => ( | |
| <th | |
| key={header} | |
| className="px-3 py-2 text-xs font-medium text-muted-foreground text-left" | |
| > | |
| {header} | |
| </th> | |
| ))} | |
| </tr> | |
| </thead> | |
| <tbody className="divide-y divide-border"> | |
| {previewData?.preview?.map((row, rowIndex) => ( | |
| <tr key={rowIndex} className={rowIndex % 2 === 0 ? "bg-muted/20" : ""}> | |
| {Object.values(row).map((cell: any, cellIndex) => ( | |
| <td | |
| key={cellIndex} | |
| className="px-3 py-2 text-xs whitespace-nowrap" | |
| > | |
| {String(cell || '').substring(0, 50)} | |
| {String(cell || '').length > 50 ? '...' : ''} | |
| </td> | |
| ))} | |
| </tr> | |
| ))} | |
| </tbody> | |
| </table> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| {isUploading && previewData && ( | |
| <div className="space-y-2"> | |
| <div className="flex items-center justify-between"> | |
| <span className="text-sm">Importing {taskType.toLowerCase()}s...</span> | |
| <span className="text-sm font-medium">{Math.round(uploadProgress)}%</span> | |
| </div> | |
| <Progress value={uploadProgress} className="h-2" /> | |
| </div> | |
| )} | |
| </div> | |
| {error && ( | |
| <Alert variant="destructive" className="mt-4"> | |
| <AlertCircle className="h-4 w-4" /> | |
| <AlertTitle>Error</AlertTitle> | |
| <AlertDescription>{error}</AlertDescription> | |
| </Alert> | |
| )} | |
| </div> | |
| ); | |
| const renderFooter = () => ( | |
| <div className="flex justify-end space-x-2"> | |
| <Button | |
| variant="outline" | |
| onClick={() => setIsOpen(false)} | |
| disabled={isUploading} | |
| > | |
| Cancel | |
| </Button> | |
| {previewData?.preview?.length > 0 && !isUploading && ( | |
| <Button | |
| onClick={handleImport} | |
| className="gap-2" | |
| > | |
| Import {previewData.total} {taskType.toLowerCase()}{previewData.total > 1 ? 's' : ''} | |
| </Button> | |
| )} | |
| </div> | |
| ); | |
| return ( | |
| <> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={() => setIsOpen(true)} | |
| className="flex items-center gap-2" | |
| > | |
| <Upload className="h-4 w-4" /> | |
| Import {taskType}s | |
| </Button> | |
| <CustomDialog | |
| isOpen={isOpen} | |
| onClose={() => !isUploading && setIsOpen(false)} | |
| title={`Import ${taskType}s`} | |
| description={`Upload an Excel file containing ${taskType.toLowerCase()} data. Download the template for the required format.`} | |
| allowClose={!isUploading} | |
| isPending={isUploading} | |
| > | |
| <div className="flex flex-col h-[calc(100vh-260px)] max-h-[650px]"> | |
| <div className="flex-1 overflow-hidden"> | |
| {renderContent()} | |
| </div> | |
| <div className="pt-4 mt-4 border-t sticky bottom-0 bg-background"> | |
| {renderFooter()} | |
| </div> | |
| </div> | |
| </CustomDialog> | |
| </> | |
| ); | |
| }; | |
| export default ImportTasksDialog; | |