Files
padmaja/components/ui/csv-import.tsx
2026-01-17 14:17:42 +05:30

430 lines
14 KiB
TypeScript

'use client'
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 { Alert, AlertDescription } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog'
import { Progress } from '@/components/ui/progress'
import { Upload, Download, FileText, AlertTriangle, CheckCircle, X } from 'lucide-react'
import Papa from 'papaparse'
interface ColumnDefinition {
key: string
label: string
required?: boolean
}
interface CsvImportProps {
title: string
description: string
templateColumns: Array<ColumnDefinition | string>
onImport: (data: any[], onProgress?: (progress: number) => void) => Promise<{
success: boolean
message: string
successCount?: number
errorCount?: number
errors?: string[]
}>
sampleData?: Record<string, any>[]
validationRules?: {
required?: string[]
formats?: Record<string, (value: any) => boolean>
transforms?: Record<string, (value: any) => any>
}
maxFileSize?: number // in MB
children?: React.ReactNode
}
export default function CsvImport({
title,
description,
templateColumns,
onImport,
sampleData = [],
validationRules = {},
maxFileSize = 10,
children
}: CsvImportProps) {
const [isOpen, setIsOpen] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [importing, setImporting] = useState(false)
const [progress, setProgress] = useState(0)
const [result, setResult] = useState<{
success: boolean
message: string
successCount?: number
errorCount?: number
errors?: string[]
} | null>(null)
const [preview, setPreview] = useState<any[]>([])
const [validationErrors, setValidationErrors] = useState<string[]>([])
const fileInputRef = useRef<HTMLInputElement>(null)
// Normalize templateColumns to always be ColumnDefinition objects
const normalizedColumns: ColumnDefinition[] = templateColumns.map(col =>
typeof col === 'string'
? { key: col, label: col, required: false }
: col
)
const downloadTemplate = () => {
const headers = normalizedColumns.map(col => col.label)
const csvContent = Papa.unparse([
headers,
...sampleData.map(row => normalizedColumns.map(col => row[col.key] || ''))
])
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
const link = document.createElement('a')
const url = URL.createObjectURL(blob)
link.setAttribute('href', url)
link.setAttribute('download', `${title.toLowerCase().replace(/\s+/g, '_')}_template.csv`)
link.style.visibility = 'hidden'
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
}
const validateData = (data: any[]): string[] => {
const errors: string[] = []
const { required = [], formats = {}, transforms = {} } = validationRules
// Get required fields from template columns and validation rules
const requiredFields = [
...required,
...normalizedColumns.filter(col => col.required).map(col => col.key)
]
data.forEach((row, index) => {
// Check required fields
requiredFields.forEach(field => {
if (!row[field] || row[field].toString().trim() === '') {
const column = normalizedColumns.find(col => col.key === field)
const fieldLabel = column ? column.label : field
errors.push(`Row ${index + 1}: ${fieldLabel} is required`)
}
})
// Check format validations
Object.entries(formats).forEach(([field, validator]) => {
if (row[field] && !validator(row[field])) {
const column = normalizedColumns.find(col => col.key === field)
const fieldLabel = column ? column.label : field
errors.push(`Row ${index + 1}: ${fieldLabel} has invalid format`)
}
})
// Apply transformations
Object.entries(transforms).forEach(([field, transformer]) => {
if (row[field]) {
try {
row[field] = transformer(row[field])
} catch (error) {
const column = normalizedColumns.find(col => col.key === field)
const fieldLabel = column ? column.label : field
errors.push(`Row ${index + 1}: ${fieldLabel} transformation failed`)
}
}
})
})
return errors
}
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0]
if (!selectedFile) return
// Check file size
if (selectedFile.size > maxFileSize * 1024 * 1024) {
setValidationErrors([`File size must be less than ${maxFileSize}MB`])
return
}
// Check file type
if (!selectedFile.name.toLowerCase().endsWith('.csv')) {
setValidationErrors(['Please select a CSV file'])
return
}
setFile(selectedFile)
setValidationErrors([])
setResult(null)
// Parse and preview the file
Papa.parse(selectedFile, {
header: true,
skipEmptyLines: true,
complete: (results) => {
if (results.errors.length > 0) {
setValidationErrors(results.errors.map(err => err.message))
return
}
const data = results.data as any[]
const errors = validateData(data)
if (errors.length > 0) {
setValidationErrors(errors.slice(0, 10)) // Show first 10 errors
return
}
setPreview(data.slice(0, 5)) // Show first 5 rows
setValidationErrors([])
},
error: (error) => {
setValidationErrors([error.message])
}
})
}
const handleImport = async () => {
if (!file) return
setImporting(true)
setProgress(0)
setResult(null)
try {
Papa.parse(file, {
header: true,
skipEmptyLines: true,
complete: async (results) => {
try {
const data = results.data as any[]
const errors = validateData(data)
if (errors.length > 0) {
setResult({
success: false,
message: 'Validation failed',
errors: errors
})
return
}
const result = await onImport(data, setProgress)
setResult(result)
if (result.success) {
setFile(null)
setPreview([])
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
} catch (error) {
setResult({
success: false,
message: error instanceof Error ? error.message : 'Import failed'
})
} finally {
setImporting(false)
setProgress(0)
}
}
})
} catch (error) {
setResult({
success: false,
message: error instanceof Error ? error.message : 'Import failed'
})
setImporting(false)
setProgress(0)
}
}
const resetDialog = () => {
setFile(null)
setPreview([])
setValidationErrors([])
setResult(null)
setProgress(0)
if (fileInputRef.current) {
fileInputRef.current.value = ''
}
}
return (
<Dialog open={isOpen} onOpenChange={(open) => {
setIsOpen(open)
if (!open) resetDialog()
}}>
<DialogTrigger asChild>
{children || (
<Button variant="outline">
<Upload className="h-4 w-4 mr-2" />
Import CSV
</Button>
)}
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Download Template */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center space-x-3">
<FileText className="h-5 w-5 text-blue-500" />
<div>
<h4 className="font-medium">Download Template</h4>
<p className="text-sm text-gray-500">
Download CSV template with required columns
</p>
</div>
</div>
<Button variant="outline" onClick={downloadTemplate}>
<Download className="h-4 w-4 mr-2" />
Template
</Button>
</div>
{/* File Upload */}
<div className="space-y-2">
<Label htmlFor="csv-file">Select CSV File</Label>
<Input
ref={fileInputRef}
id="csv-file"
type="file"
accept=".csv"
onChange={handleFileChange}
disabled={importing}
/>
<p className="text-xs text-gray-500">
Maximum file size: {maxFileSize}MB. Only CSV files are supported.
</p>
</div>
{/* Validation Errors */}
{validationErrors.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
<p className="font-medium">Validation Errors:</p>
<ul className="list-disc list-inside text-sm space-y-1">
{validationErrors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</div>
</AlertDescription>
</Alert>
)}
{/* Preview */}
{preview.length > 0 && (
<div className="space-y-2">
<h4 className="font-medium">Preview (First 5 rows)</h4>
<div className="border rounded-lg overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
{normalizedColumns.map(col => (
<th key={col.key} className="px-3 py-2 text-left font-medium">
{col.label}
{col.required && <span className="text-red-500 ml-1">*</span>}
</th>
))}
</tr>
</thead>
<tbody>
{preview.map((row, index) => (
<tr key={index} className="border-t">
{normalizedColumns.map(col => (
<td key={col.key} className="px-3 py-2">
{row[col.key]?.toString() || ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Import Progress */}
{importing && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Importing...</span>
<span className="text-sm text-gray-500">{Math.round(progress)}%</span>
</div>
<Progress value={progress} className="w-full" />
</div>
)}
{/* Import Result */}
{result && (
<Alert variant={result.success ? 'default' : 'destructive'}>
{result.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<X className="h-4 w-4" />
)}
<AlertDescription>
<div className="space-y-2">
<p className="font-medium">{result.message}</p>
{result.successCount !== undefined && (
<p className="text-sm">
Successfully imported: {result.successCount} records
</p>
)}
{result.errorCount !== undefined && result.errorCount > 0 && (
<p className="text-sm">
Failed to import: {result.errorCount} records
</p>
)}
{result.errors && result.errors.length > 0 && (
<div className="text-sm">
<p className="font-medium">Errors:</p>
<ul className="list-disc list-inside space-y-1">
{result.errors.slice(0, 10).map((error, index) => (
<li key={index}>{error}</li>
))}
{result.errors.length > 10 && (
<li>... and {result.errors.length - 10} more errors</li>
)}
</ul>
</div>
)}
</div>
</AlertDescription>
</Alert>
)}
{/* Action Buttons */}
<div className="flex items-center justify-end space-x-2 pt-4 border-t">
<Button
variant="outline"
onClick={() => setIsOpen(false)}
disabled={importing}
>
Cancel
</Button>
<Button
onClick={handleImport}
disabled={!file || validationErrors.length > 0 || importing}
>
{importing ? 'Importing...' : 'Import Data'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
)
}