Spaces:
Sleeping
Sleeping
| import React, { useState, useRef } from 'react'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Label } from '@/components/ui/label'; | |
| import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; | |
| import { Badge } from '@/components/ui/badge'; | |
| import { Alert, AlertDescription } from '@/components/ui/alert'; | |
| import { | |
| Upload, | |
| Download, | |
| FileSpreadsheet, | |
| CheckCircle, | |
| XCircle, | |
| AlertTriangle, | |
| Loader2, | |
| Settings | |
| } from 'lucide-react'; | |
| import * as XLSX from 'xlsx'; | |
| import { SystemDetailsCreateRequest } from '@/types/asset-management'; | |
| import ImportResults from './ImportResults'; | |
| interface SystemDetailsImportData extends Omit<SystemDetailsCreateRequest, 'assetID'> { | |
| assetID?: number; | |
| // Asset creation fields if asset doesn't exist | |
| assetType?: string; | |
| brandModel?: string; | |
| serialNumber?: string; | |
| assetCondition?: string; | |
| purchaseDate?: string; | |
| warrantyExpiry?: string; | |
| purchaseCost?: number; | |
| currentValue?: number; | |
| location?: string; | |
| notes?: string; | |
| } | |
| interface ImportResult { | |
| success: boolean; | |
| data?: SystemDetailsImportData[]; | |
| errors?: string[]; | |
| warnings?: string[]; | |
| totalRows?: number; | |
| validRows?: number; | |
| } | |
| interface SystemDetailsBulkImportResponse { | |
| success: boolean; | |
| message: string; | |
| results: { | |
| successful: number; | |
| failed: number; | |
| details: Array<{ | |
| row: number; | |
| success: boolean; | |
| message: string; | |
| data?: any; | |
| }>; | |
| }; | |
| } | |
| interface SystemDetailsExcelImportProps { | |
| onImport: (systemDetails: SystemDetailsImportData[]) => Promise<SystemDetailsBulkImportResponse>; | |
| isImporting?: boolean; | |
| } | |
| const SystemDetailsExcelImport: React.FC<SystemDetailsExcelImportProps> = ({ | |
| onImport, | |
| isImporting = false | |
| }) => { | |
| const [file, setFile] = useState<File | null>(null); | |
| const [importResult, setImportResult] = useState<ImportResult | null>(null); | |
| const [apiResult, setApiResult] = useState<SystemDetailsBulkImportResponse | null>(null); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| // Template data for Excel generation | |
| const generateTemplate = () => { | |
| const templateData = [ | |
| { | |
| 'Asset ID': 1, | |
| 'System Model': 'Dell Latitude 5430', | |
| 'Processor': 'Intel Core i7-1265U', | |
| 'Installed RAM': '16GB DDR4', | |
| 'Graphics': 'Intel Iris Xe Graphics', | |
| 'Screen Resolution': '1920x1080', | |
| 'Disk Model': 'Samsung SSD 980 PRO', | |
| 'Disk Size': '512GB', | |
| 'Manufacturer': 'Dell', | |
| // Asset creation fields (used if asset ID doesn't exist) | |
| 'Asset Type': 'Laptop', | |
| 'Brand/Model': 'Dell Latitude 5430', | |
| 'Serial Number': 'DL5430-001', | |
| 'Asset Condition': 'Excellent', | |
| 'Purchase Date': '2024-01-15', | |
| 'Warranty Expiry': '2027-01-15', | |
| 'Purchase Cost': '1200.00', | |
| 'Current Value': '1000.00', | |
| 'Location': 'Office Floor 1', | |
| 'Notes': 'Primary development laptop' | |
| }, | |
| { | |
| 'Asset ID': 999, | |
| 'System Model': 'Samsung 27" 4K Monitor', | |
| 'Processor': 'N/A', | |
| 'Installed RAM': 'N/A', | |
| 'Graphics': 'Built-in Display Controller', | |
| 'Screen Resolution': '3840x2160', | |
| 'Disk Model': 'N/A', | |
| 'Disk Size': 'N/A', | |
| 'Manufacturer': 'Samsung', | |
| // Asset creation fields (will be used since Asset ID 999 likely doesn't exist) | |
| 'Asset Type': 'Extended Screen', | |
| 'Brand/Model': 'Samsung 27" 4K', | |
| 'Serial Number': 'SM27-4K-002', | |
| 'Asset Condition': 'Good', | |
| 'Purchase Date': '2024-02-01', | |
| 'Warranty Expiry': '2027-02-01', | |
| 'Purchase Cost': '400.00', | |
| 'Current Value': '350.00', | |
| 'Location': 'Office Floor 2', | |
| 'Notes': 'External monitor for workstation' | |
| }, | |
| { | |
| 'Asset ID': '', | |
| 'System Model': 'MacBook Pro 16"', | |
| 'Processor': 'Apple M2 Pro', | |
| 'Installed RAM': '32GB', | |
| 'Graphics': 'Apple M2 Pro GPU', | |
| 'Screen Resolution': '3456x2234', | |
| 'Disk Model': 'Apple SSD', | |
| 'Disk Size': '1TB', | |
| 'Manufacturer': 'Apple', | |
| // Asset creation fields (required since no Asset ID provided) | |
| 'Asset Type': 'Laptop', | |
| 'Brand/Model': 'MacBook Pro 16"', | |
| 'Serial Number': 'MBP16-003', | |
| 'Asset Condition': 'Excellent', | |
| 'Purchase Date': '2024-03-01', | |
| 'Warranty Expiry': '2027-03-01', | |
| 'Purchase Cost': '2500.00', | |
| 'Current Value': '2200.00', | |
| 'Location': 'Office Floor 3', | |
| 'Notes': 'Design team laptop' | |
| } | |
| ]; | |
| const ws = XLSX.utils.json_to_sheet(templateData); | |
| const wb = XLSX.utils.book_new(); | |
| XLSX.utils.book_append_sheet(wb, ws, 'System Details Template'); | |
| // Set column widths | |
| const colWidths = [ | |
| { wch: 10 }, // Asset ID | |
| { wch: 25 }, // System Model | |
| { wch: 20 }, // Processor | |
| { wch: 15 }, // Installed RAM | |
| { wch: 25 }, // Graphics | |
| { wch: 15 }, // Screen Resolution | |
| { wch: 20 }, // Disk Model | |
| { wch: 12 }, // Disk Size | |
| { wch: 15 }, // Manufacturer | |
| { wch: 15 }, // Asset Type | |
| { wch: 25 }, // Brand/Model | |
| { wch: 20 }, // Serial Number | |
| { wch: 15 }, // Asset Condition | |
| { wch: 15 }, // Purchase Date | |
| { wch: 15 }, // Warranty Expiry | |
| { wch: 12 }, // Purchase Cost | |
| { wch: 12 }, // Current Value | |
| { wch: 20 }, // Location | |
| { wch: 30 } // Notes | |
| ]; | |
| ws['!cols'] = colWidths; | |
| XLSX.writeFile(wb, 'system_details_import_template.xlsx'); | |
| }; | |
| const validateSystemDetailsData = (data: any[]): ImportResult => { | |
| const errors: string[] = []; | |
| const warnings: string[] = []; | |
| const validSystemDetails: SystemDetailsImportData[] = []; | |
| data.forEach((row, index) => { | |
| const rowNum = index + 2; // +2 because Excel is 1-indexed and we skip header | |
| const systemDetail: SystemDetailsImportData = {}; | |
| // Asset ID validation (optional - if not provided, we'll create a new asset) | |
| if (row['Asset ID']) { | |
| const assetId = parseInt(row['Asset ID']); | |
| if (isNaN(assetId) || assetId <= 0) { | |
| warnings.push(`Row ${rowNum}: Invalid Asset ID "${row['Asset ID']}". Will attempt to create new asset.`); | |
| } else { | |
| systemDetail.assetID = assetId; | |
| } | |
| } | |
| // Required system details fields | |
| if (!row['System Model']) { | |
| errors.push(`Row ${rowNum}: System Model is required`); | |
| return; | |
| } | |
| systemDetail.systemModel = String(row['System Model']).trim(); | |
| if (!row['Manufacturer']) { | |
| errors.push(`Row ${rowNum}: Manufacturer is required`); | |
| return; | |
| } | |
| systemDetail.manufacturer = String(row['Manufacturer']).trim(); | |
| // Optional system details fields | |
| systemDetail.processor = row['Processor'] ? String(row['Processor']).trim() : ''; | |
| systemDetail.installedRAM = row['Installed RAM'] ? String(row['Installed RAM']).trim() : ''; | |
| systemDetail.graphics = row['Graphics'] ? String(row['Graphics']).trim() : ''; | |
| systemDetail.screenResolution = row['Screen Resolution'] ? String(row['Screen Resolution']).trim() : ''; | |
| systemDetail.diskModel = row['Disk Model'] ? String(row['Disk Model']).trim() : ''; | |
| systemDetail.diskSize = row['Disk Size'] ? String(row['Disk Size']).trim() : ''; | |
| // Asset creation fields (always include for validation, will be used if asset doesn't exist) | |
| if (row['Asset Type']) { | |
| systemDetail.assetType = String(row['Asset Type']).trim(); | |
| } | |
| if (row['Brand/Model']) { | |
| systemDetail.brandModel = String(row['Brand/Model']).trim(); | |
| } | |
| if (row['Serial Number']) { | |
| systemDetail.serialNumber = String(row['Serial Number']).trim(); | |
| } | |
| if (row['Asset Condition']) { | |
| systemDetail.assetCondition = String(row['Asset Condition']).trim(); | |
| } | |
| // If no asset ID provided, require asset creation fields | |
| if (!systemDetail.assetID) { | |
| if (!systemDetail.assetType) { | |
| errors.push(`Row ${rowNum}: Asset Type is required when Asset ID is not provided`); | |
| return; | |
| } | |
| if (!systemDetail.brandModel) { | |
| errors.push(`Row ${rowNum}: Brand/Model is required when Asset ID is not provided`); | |
| return; | |
| } | |
| if (!systemDetail.serialNumber) { | |
| errors.push(`Row ${rowNum}: Serial Number is required when Asset ID is not provided`); | |
| return; | |
| } | |
| if (!systemDetail.assetCondition) { | |
| errors.push(`Row ${rowNum}: Asset Condition is required when Asset ID is not provided`); | |
| return; | |
| } | |
| } | |
| // Optional asset fields (always include for potential asset creation) | |
| if (row['Purchase Date']) { | |
| const purchaseDate = new Date(row['Purchase Date']); | |
| if (isNaN(purchaseDate.getTime())) { | |
| warnings.push(`Row ${rowNum}: Invalid Purchase Date format`); | |
| } else { | |
| systemDetail.purchaseDate = purchaseDate.toISOString().split('T')[0]; | |
| } | |
| } | |
| if (row['Warranty Expiry']) { | |
| const warrantyExpiry = new Date(row['Warranty Expiry']); | |
| if (isNaN(warrantyExpiry.getTime())) { | |
| warnings.push(`Row ${rowNum}: Invalid Warranty Expiry format`); | |
| } else { | |
| systemDetail.warrantyExpiry = warrantyExpiry.toISOString().split('T')[0]; | |
| } | |
| } | |
| if (row['Purchase Cost']) { | |
| const cost = parseFloat(row['Purchase Cost']); | |
| if (isNaN(cost) || cost < 0) { | |
| warnings.push(`Row ${rowNum}: Invalid Purchase Cost`); | |
| } else { | |
| systemDetail.purchaseCost = cost; | |
| } | |
| } | |
| if (row['Current Value']) { | |
| const value = parseFloat(row['Current Value']); | |
| if (isNaN(value) || value < 0) { | |
| warnings.push(`Row ${rowNum}: Invalid Current Value`); | |
| } else { | |
| systemDetail.currentValue = value; | |
| } | |
| } | |
| if (row['Location']) { | |
| systemDetail.location = String(row['Location']).trim(); | |
| } | |
| if (row['Notes']) { | |
| systemDetail.notes = String(row['Notes']).trim(); | |
| } | |
| validSystemDetails.push(systemDetail); | |
| }); | |
| return { | |
| success: errors.length === 0, | |
| data: validSystemDetails, | |
| errors, | |
| warnings, | |
| totalRows: data.length, | |
| validRows: validSystemDetails.length | |
| }; | |
| }; | |
| const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { | |
| const selectedFile = event.target.files?.[0]; | |
| if (selectedFile) { | |
| setFile(selectedFile); | |
| setImportResult(null); | |
| } | |
| }; | |
| const processFile = async () => { | |
| if (!file) return; | |
| setIsProcessing(true); | |
| try { | |
| const data = await file.arrayBuffer(); | |
| const workbook = XLSX.read(data); | |
| const sheetName = workbook.SheetNames[0]; | |
| const worksheet = workbook.Sheets[sheetName]; | |
| const jsonData = XLSX.utils.sheet_to_json(worksheet); | |
| if (jsonData.length === 0) { | |
| setImportResult({ | |
| success: false, | |
| errors: ['The Excel file appears to be empty or has no data rows.'] | |
| }); | |
| return; | |
| } | |
| const result = validateSystemDetailsData(jsonData); | |
| setImportResult(result); | |
| } catch (error) { | |
| setImportResult({ | |
| success: false, | |
| errors: ['Failed to process the Excel file. Please ensure it\'s a valid Excel file.'] | |
| }); | |
| } finally { | |
| setIsProcessing(false); | |
| } | |
| }; | |
| const handleImport = async () => { | |
| if (!importResult?.data) return; | |
| try { | |
| const result = await onImport(importResult.data); | |
| setApiResult(result); | |
| // Only clear the form if the import was completely successful | |
| if (result.success && result.results.failed === 0) { | |
| setFile(null); | |
| setImportResult(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Import failed:', error); | |
| } | |
| }; | |
| const resetImport = () => { | |
| setFile(null); | |
| setImportResult(null); | |
| setApiResult(null); | |
| if (fileInputRef.current) { | |
| fileInputRef.current.value = ''; | |
| } | |
| }; | |
| return ( | |
| <div className="space-y-6"> | |
| {/* Template Download */} | |
| <Card> | |
| <CardHeader className="pb-4"> | |
| <CardTitle className="flex items-center gap-2"> | |
| <FileSpreadsheet className="h-5 w-5" /> | |
| Excel Template | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent> | |
| <p className="text-sm text-muted-foreground mb-4"> | |
| Download the Excel template to ensure your data is formatted correctly for import. | |
| If you provide an Asset ID, the system will check if it exists - if not found, a new asset will be created using the asset fields. | |
| If no Asset ID is provided, a new asset will be created automatically. | |
| </p> | |
| <Button | |
| onClick={generateTemplate} | |
| variant="outline" | |
| className="flex items-center gap-2" | |
| > | |
| <Download className="h-4 w-4" /> | |
| Download Template | |
| </Button> | |
| </CardContent> | |
| </Card> | |
| {/* File Upload */} | |
| <Card> | |
| <CardHeader className="pb-4"> | |
| <CardTitle className="flex items-center gap-2"> | |
| <Upload className="h-5 w-5" /> | |
| Upload Excel File | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| <div> | |
| <Label htmlFor="excel-file">Select Excel File</Label> | |
| <Input | |
| id="excel-file" | |
| ref={fileInputRef} | |
| type="file" | |
| accept=".xlsx,.xls" | |
| onChange={handleFileSelect} | |
| className="mt-1" | |
| /> | |
| </div> | |
| {file && ( | |
| <div className="flex items-center justify-between p-3 bg-muted rounded-lg"> | |
| <div className="flex items-center gap-2"> | |
| <FileSpreadsheet className="h-4 w-4" /> | |
| <span className="text-sm font-medium">{file.name}</span> | |
| <Badge variant="secondary">{(file.size / 1024).toFixed(1)} KB</Badge> | |
| </div> | |
| <div className="flex gap-2"> | |
| <Button | |
| onClick={processFile} | |
| disabled={isProcessing} | |
| size="sm" | |
| > | |
| {isProcessing ? ( | |
| <> | |
| <Loader2 className="h-3 w-3 mr-1 animate-spin" /> | |
| Processing... | |
| </> | |
| ) : ( | |
| 'Process File' | |
| )} | |
| </Button> | |
| <Button | |
| onClick={resetImport} | |
| variant="outline" | |
| size="sm" | |
| > | |
| Remove | |
| </Button> | |
| </div> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| {/* Import Results */} | |
| {importResult && ( | |
| <Card> | |
| <CardHeader className="pb-4"> | |
| <CardTitle className="flex items-center gap-2"> | |
| {importResult.success ? ( | |
| <CheckCircle className="h-5 w-5 text-green-600" /> | |
| ) : ( | |
| <XCircle className="h-5 w-5 text-red-600" /> | |
| )} | |
| Import Results | |
| </CardTitle> | |
| </CardHeader> | |
| <CardContent className="space-y-4"> | |
| {/* Summary */} | |
| <div className="grid grid-cols-2 gap-4"> | |
| <div className="text-center p-3 bg-muted rounded-lg"> | |
| <div className="text-2xl font-bold">{importResult.totalRows || 0}</div> | |
| <div className="text-sm text-muted-foreground">Total Rows</div> | |
| </div> | |
| <div className="text-center p-3 bg-muted rounded-lg"> | |
| <div className="text-2xl font-bold text-green-600">{importResult.validRows || 0}</div> | |
| <div className="text-sm text-muted-foreground">Valid Rows</div> | |
| </div> | |
| </div> | |
| {/* Errors */} | |
| {importResult.errors && importResult.errors.length > 0 && ( | |
| <Alert variant="destructive"> | |
| <XCircle className="h-4 w-4" /> | |
| <AlertDescription> | |
| <div className="font-medium mb-2">Errors found:</div> | |
| <ul className="list-disc list-inside space-y-1 text-sm"> | |
| {importResult.errors.map((error, index) => ( | |
| <li key={index}>{error}</li> | |
| ))} | |
| </ul> | |
| </AlertDescription> | |
| </Alert> | |
| )} | |
| {/* Warnings */} | |
| {importResult.warnings && importResult.warnings.length > 0 && ( | |
| <Alert> | |
| <AlertTriangle className="h-4 w-4" /> | |
| <AlertDescription> | |
| <div className="font-medium mb-2">Warnings:</div> | |
| <ul className="list-disc list-inside space-y-1 text-sm"> | |
| {importResult.warnings.map((warning, index) => ( | |
| <li key={index}>{warning}</li> | |
| ))} | |
| </ul> | |
| </AlertDescription> | |
| </Alert> | |
| )} | |
| {/* Import Button */} | |
| {importResult.success && importResult.data && importResult.data.length > 0 && ( | |
| <div className="flex justify-end gap-2 pt-4"> | |
| <Button | |
| onClick={resetImport} | |
| variant="outline" | |
| > | |
| Cancel | |
| </Button> | |
| <Button | |
| onClick={handleImport} | |
| disabled={isImporting} | |
| className="flex items-center gap-2" | |
| > | |
| {isImporting ? ( | |
| <> | |
| <Loader2 className="h-4 w-4 animate-spin" /> | |
| Importing... | |
| </> | |
| ) : ( | |
| <> | |
| <Settings className="h-4 w-4" /> | |
| Import {importResult.data.length} System Details | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* API Import Results */} | |
| {apiResult && ( | |
| <ImportResults | |
| result={apiResult} | |
| onClose={resetImport} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default SystemDetailsExcelImport; |