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 | |
| } from 'lucide-react'; | |
| import * as XLSX from 'xlsx'; | |
| import { AssetCreateRequest, ASSET_TYPES, ASSET_CONDITIONS, BulkImportResponse } from '@/types/asset-management'; | |
| import ImportResults from './ImportResults'; | |
| interface ImportResult { | |
| success: boolean; | |
| data?: AssetCreateRequest[]; | |
| errors?: string[]; | |
| warnings?: string[]; | |
| totalRows?: number; | |
| validRows?: number; | |
| } | |
| interface ExcelImportProps { | |
| onImport: (assets: AssetCreateRequest[]) => Promise<BulkImportResponse>; | |
| isImporting?: boolean; | |
| } | |
| const ExcelImport: React.FC<ExcelImportProps> = ({ | |
| onImport, | |
| isImporting = false | |
| }) => { | |
| const [file, setFile] = useState<File | null>(null); | |
| const [importResult, setImportResult] = useState<ImportResult | null>(null); | |
| const [apiResult, setApiResult] = useState<BulkImportResponse | null>(null); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| // Template data for Excel generation | |
| const generateTemplate = () => { | |
| const templateData = [ | |
| { | |
| '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 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 Type': 'Wireless Mouse', | |
| 'Brand/Model': 'Logitech MX Master 3', | |
| 'Serial Number': 'LG-MX3-003', | |
| 'Asset Condition': 'Excellent', | |
| 'Purchase Date': '2024-03-01', | |
| 'Warranty Expiry': '2026-03-01', | |
| 'Purchase Cost': '99.99', | |
| 'Current Value': '80.00', | |
| 'Location': 'Office Floor 1', | |
| 'Notes': 'Ergonomic wireless mouse' | |
| }, | |
| { | |
| 'Asset Type': 'Laptop Stand', | |
| 'Brand/Model': 'Rain Design mStand', | |
| 'Serial Number': 'RD-MS-004', | |
| 'Asset Condition': 'Good', | |
| 'Purchase Date': '2024-03-15', | |
| 'Warranty Expiry': '2026-03-15', | |
| 'Purchase Cost': '59.99', | |
| 'Current Value': '50.00', | |
| 'Location': 'Office Floor 1', | |
| 'Notes': 'Aluminum laptop stand for ergonomics' | |
| } | |
| ]; | |
| const ws = XLSX.utils.json_to_sheet(templateData); | |
| const wb = XLSX.utils.book_new(); | |
| XLSX.utils.book_append_sheet(wb, ws, 'Assets Template'); | |
| // Set column widths | |
| const colWidths = [ | |
| { 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, 'assets_import_template.xlsx'); | |
| }; | |
| const validateAssetData = (data: any[]): ImportResult => { | |
| const errors: string[] = []; | |
| const warnings: string[] = []; | |
| const validAssets: AssetCreateRequest[] = []; | |
| data.forEach((row, index) => { | |
| const rowNum = index + 2; // +2 because Excel is 1-indexed and we skip header | |
| const asset: Partial<AssetCreateRequest> = {}; | |
| // Required fields validation | |
| if (!row['Asset Type']) { | |
| errors.push(`Row ${rowNum}: Asset Type is required`); | |
| return; | |
| } | |
| if (!ASSET_TYPES.includes(row['Asset Type'])) { | |
| errors.push(`Row ${rowNum}: Invalid Asset Type "${row['Asset Type']}". Must be one of: ${ASSET_TYPES.join(', ')}`); | |
| return; | |
| } | |
| asset.assetType = String(row['Asset Type']).trim(); | |
| if (!row['Brand/Model']) { | |
| errors.push(`Row ${rowNum}: Brand/Model is required`); | |
| return; | |
| } | |
| asset.brandModel = String(row['Brand/Model']).trim(); | |
| if (!row['Serial Number']) { | |
| errors.push(`Row ${rowNum}: Serial Number is required`); | |
| return; | |
| } | |
| // Convert serialNumber to string (handles numbers, strings, etc.) | |
| asset.serialNumber = String(row['Serial Number']).trim(); | |
| if (!row['Asset Condition']) { | |
| errors.push(`Row ${rowNum}: Asset Condition is required`); | |
| return; | |
| } | |
| if (!ASSET_CONDITIONS.includes(row['Asset Condition'])) { | |
| errors.push(`Row ${rowNum}: Invalid Asset Condition "${row['Asset Condition']}". Must be one of: ${ASSET_CONDITIONS.join(', ')}`); | |
| return; | |
| } | |
| asset.assetCondition = String(row['Asset Condition']).trim(); | |
| // Optional fields with validation | |
| if (row['Purchase Date']) { | |
| const purchaseDate = new Date(row['Purchase Date']); | |
| if (isNaN(purchaseDate.getTime())) { | |
| warnings.push(`Row ${rowNum}: Invalid Purchase Date format`); | |
| } else { | |
| asset.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 { | |
| asset.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 { | |
| asset.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 { | |
| asset.currentValue = value; | |
| } | |
| } | |
| if (row['Location']) { | |
| asset.location = String(row['Location']).trim(); | |
| } | |
| if (row['Notes']) { | |
| asset.notes = String(row['Notes']).trim(); | |
| } | |
| validAssets.push(asset as AssetCreateRequest); | |
| }); | |
| return { | |
| success: errors.length === 0, | |
| data: validAssets, | |
| errors, | |
| warnings, | |
| totalRows: data.length, | |
| validRows: validAssets.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 = validateAssetData(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. | |
| </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... | |
| </> | |
| ) : ( | |
| <> | |
| <Upload className="h-4 w-4" /> | |
| Import {importResult.data.length} Assets | |
| </> | |
| )} | |
| </Button> | |
| </div> | |
| )} | |
| </CardContent> | |
| </Card> | |
| )} | |
| {/* API Import Results */} | |
| {apiResult && ( | |
| <ImportResults | |
| result={apiResult} | |
| onClose={resetImport} | |
| /> | |
| )} | |
| </div> | |
| ); | |
| }; | |
| export default ExcelImport; |