pmtool / src /components /tasks /import-tasks-dialog.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
25.1 kB
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;