Spaces:
Sleeping
Sleeping
| 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; |