pmtool / src /components /issue /import-issues-dialog.tsx
devarshia5's picture
Upload 487 files
d97b8f9 verified
Raw
History Blame Contribute Delete
24 kB
import React, { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Upload, FileText, AlertCircle, Check } from "lucide-react";
import { toast } from "@/lib/custom-toast";
import { issuesApi, employeeService, projectService, deliverablesApi } from "@/lib/api";
import * as XLSX from 'xlsx';
import { IssueCreateRequest, Employee, ProjectApi, Deliverable } 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 ImportIssuesDialogProps {
projectId?: number;
deliverableId?: number;
onImportComplete: () => void;
}
interface PreviewData {
preview: any[];
total: number;
all: any[];
}
const ImportIssuesDialog: React.FC<ImportIssuesDialogProps> = ({
projectId,
deliverableId,
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 [deliverables, setDeliverables] = useState<Deliverable[]>([]);
// Selected values
const [selectedAssignedTo, setSelectedAssignedTo] = useState<number | "">("");
const [selectedProjectId, setSelectedProjectId] = useState<number | "">(projectId || "");
const [selectedDeliverableId, setSelectedDeliverableId] = useState<number | "">(deliverableId || "");
// Fetch initial data
useEffect(() => {
const fetchInitialData = async () => {
if (!isOpen) return;
setLoading(true);
try {
// Fetch employees and projects in parallel
const [employeeData, projectData] = await Promise.all([
employeeService.getAll(),
projectService.getAll()
]);
setEmployees(employeeData);
setProjects(projectData);
// If projectId is provided, fetch deliverables for that project
if (projectId) {
try {
const deliverablesData = await deliverablesApi.getByProjectId(projectId);
setDeliverables(deliverablesData);
// Set default selected values from props
setSelectedProjectId(projectId);
if (deliverableId) {
setSelectedDeliverableId(deliverableId);
}
} catch (error) {
console.error("Error fetching deliverables:", error);
}
}
} catch (error) {
console.error("Error fetching data:", error);
toast.error("Failed to load data");
} finally {
setLoading(false);
}
};
fetchInitialData();
}, [isOpen, projectId, deliverableId]);
// Fetch deliverables when project selection changes
useEffect(() => {
const fetchDeliverables = async () => {
if (!selectedProjectId || typeof selectedProjectId !== 'number') {
setDeliverables([]);
setSelectedDeliverableId("");
return;
}
setLoading(true);
try {
const deliverablesData = await deliverablesApi.getByProjectId(selectedProjectId);
setDeliverables(deliverablesData);
} catch (error) {
console.error("Error fetching deliverables:", error);
toast.error("Failed to load deliverables");
setDeliverables([]);
} finally {
setLoading(false);
}
};
fetchDeliverables();
}, [selectedProjectId]);
// Template Excel download handler
const handleDownloadTemplate = () => {
const headers = [
"Title",
"Type",
"Priority",
"Severity",
"Description",
"Estimated Hours",
"Reported By (Optional)",
"Assigned To (Optional)",
"Project ID (Optional)",
"Deliverable ID (Optional)"
];
const sampleData = [
[
"Sample Issue Title",
"Feature", // Type: Bug, User Story, Task, Documentation, Enhancement
"Low", // Priority: Low, Medium, High, Critical
"Low", // Severity: Critical, Major, Minor, Trivial
"Description of the issue",
"8", // Estimated Hours
"", // Reported By (Employee ID) - Optional
"", // Assigned To (Employee ID) - Optional
projectId || "", // Project ID - Optional
deliverableId || "" // Deliverable ID - Optional
]
];
// Create workbook and worksheet
const workbook = XLSX.utils.book_new();
const worksheet = XLSX.utils.aoa_to_sheet([headers, ...sampleData]);
// Add worksheet to workbook
XLSX.utils.book_append_sheet(workbook, worksheet, "Issues");
// Write to buffer
const excelBuffer = XLSX.write(workbook, { bookType: 'xlsx', type: 'array' });
// Create blob and download
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', 'issues_import_template.xlsx');
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const handleFileUpload = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) {
return;
}
// Validate file type
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;
}
// Validate file size (max 10MB)
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);
// Show loading indicator
setIsUploading(true);
try {
// Read the Excel file
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = new Uint8Array(e.target?.result as ArrayBuffer);
const workbook = XLSX.read(data, { type: 'array' });
// Get first sheet
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Convert to JSON
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
// Check if there's data
if (jsonData.length <= 1) { // Just headers or empty
setError("The Excel file contains no data rows.");
setIsUploading(false);
return;
}
// Get headers from first row
const headers = jsonData[0] as string[];
// Convert to array of objects
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);
}
// Validate column headers
const requiredFields = ["Title", "Type", "Priority", "Description"];
const missingFields = requiredFields.filter(field =>
!headers.includes(field)
);
if (missingFields.length > 0) {
setError(`Missing required columns: ${missingFields.join(', ')}`);
setIsUploading(false);
return;
}
// Store all rows for processing, but only display first 10 in preview
const allRows = [...rows];
const previewRows = rows.slice(0, 10);
// Set data for preview
setPreviewData({
preview: previewRows,
total: allRows.length,
all: allRows
});
// Auto-start import if data is valid
toast.success(`Excel file loaded with ${allRows.length} issues 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);
}
}, []);
const handleImport = useCallback(async () => {
if (!previewData?.all?.length) {
toast.error("No data to import");
return;
}
setIsUploading(true);
setError(null);
setUploadProgress(10);
try {
// Get all rows from the stored data
const allIssues = previewData.all;
const progressStep = 80 / allIssues.length;
let successCount = 0;
let failureCount = 0;
// Process issues one by one
for (let i = 0; i < allIssues.length; i++) {
const issueData = allIssues[i] as Record<string, any>;
try {
// Map Excel fields to issue create request
const issueRequest: IssueCreateRequest = {
biCode: `${new Date().getTime()}-${i}`, // Generate a unique code
type: issueData.Type || "Task",
description: issueData.Description || "",
estimates: String(issueData["Estimated Hours"] || "0"),
esUnit: 1, // Default unit (hours)
priority: issueData.Priority || "Medium",
severity: issueData.Severity || "Minor",
// Use inline settings if they exist, otherwise use data from excel or default to 0
reportedBy: parseInt(String(issueData["Reported By (Optional)"])) || 0,
assignedTo: selectedAssignedTo || parseInt(String(issueData["Assigned To (Optional)"])) || 0,
remainingHr: parseFloat(String(issueData["Estimated Hours"])) || 0,
title: issueData.Title,
projectId: selectedProjectId || parseInt(String(issueData["Project ID (Optional)"])) || projectId || 0,
deliverablesId: selectedDeliverableId || parseInt(String(issueData["Deliverable ID (Optional)"])) || deliverableId || 0
};
// Skip if missing essential data
if (!issueRequest.title) {
failureCount++;
continue;
}
await issuesApi.create(issueRequest);
successCount++;
} catch (error) {
console.error('Error importing issue:', error);
failureCount++;
}
// Update progress
setUploadProgress(10 + (i + 1) * progressStep);
}
setUploadProgress(100);
if (successCount > 0) {
toast.success(`Successfully imported ${successCount} issues`);
if (failureCount > 0) {
toast.error(`Failed to import ${failureCount} issues`);
}
setTimeout(() => {
setIsOpen(false);
onImportComplete();
setPreviewData(null);
setFileName("");
setUploadProgress(0);
// Reset inline settings
setSelectedAssignedTo("");
setSelectedProjectId("");
setSelectedDeliverableId("");
}, 1500);
} else {
throw new Error("Failed to import any issues");
}
} 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, deliverableId, onImportComplete, selectedAssignedTo, selectedProjectId, selectedDeliverableId]);
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"
>
<FileText 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} issues
</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="issue-excel-upload"
disabled={isUploading}
/>
<label
htmlFor="issue-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 && (
<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("issue-excel-upload") as HTMLInputElement;
if (input) input.value = "";
}}
>
Change
</Button>
)}
</div>
{/* Inline settings for all issues */}
<div className="border rounded-md p-4 bg-muted/20">
<h4 className="text-sm font-medium mb-3">Set values for all issues:</h4>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
{/* Assigned To */}
<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>
{/* Project ID */}
<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) : "");
if (!value) {
setSelectedDeliverableId("");
}
}}
placeholder="Select Project"
disabled={isUploading || loading}
className="w-full"
/>
</div>
{/* Deliverable ID */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Deliverable
</label>
<Dropdown
options={[
{ label: "— Select Deliverable —", value: "" },
...deliverables.map(del => ({
label: del.name,
value: del.id.toString()
}))
]}
value={selectedDeliverableId ? selectedDeliverableId.toString() : ""}
onValueChange={(value) => setSelectedDeliverableId(value ? parseInt(value) : "")}
placeholder="Select Deliverable"
disabled={!selectedProjectId || isUploading || loading}
className="w-full"
/>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
These values will override any values in the imported file for all issues.
</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={() => {
// Reset file input to allow uploading more data
setFileName("");
setPreviewData(null);
const input = document.getElementById("issue-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 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm">Importing issues...</span>
<span className="text-sm font-medium">{Math.round(uploadProgress)}%</span>
</div>
<Progress value={uploadProgress} className="h-2" />
</div>
)}
</div>
{error && (
<Alert variant="destructive">
<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} issues
</Button>
)}
</div>
);
return (
<>
<Button
variant="outline"
size="sm"
onClick={() => setIsOpen(true)}
className="flex items-center gap-2"
>
<Upload className="h-4 w-4" />
Import Features
</Button>
<CustomDialog
isOpen={isOpen}
onClose={() => !isUploading && setIsOpen(false)}
title="Import Features"
description="Upload an Excel file containing issue 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 ImportIssuesDialog;