336 lines
12 KiB
JavaScript
336 lines
12 KiB
JavaScript
import React, { useState } from 'react';
|
|
import api from '../utils/api';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from './ui/dialog';
|
|
import { Button } from './ui/button';
|
|
import { Label } from './ui/label';
|
|
import { Checkbox } from './ui/checkbox';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from './ui/table';
|
|
import { Alert, AlertDescription } from './ui/alert';
|
|
import { Badge } from './ui/badge';
|
|
import { toast } from 'sonner';
|
|
import { Loader2, Upload, FileUp, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
|
|
|
const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
|
const [file, setFile] = useState(null);
|
|
const [updateExisting, setUpdateExisting] = useState(false);
|
|
const [loading, setLoading] = useState(false);
|
|
const [dragActive, setDragActive] = useState(false);
|
|
const [importResult, setImportResult] = useState(null);
|
|
|
|
const handleDrag = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (e.type === "dragenter" || e.type === "dragover") {
|
|
setDragActive(true);
|
|
} else if (e.type === "dragleave") {
|
|
setDragActive(false);
|
|
}
|
|
};
|
|
|
|
const handleDrop = (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDragActive(false);
|
|
|
|
if (e.dataTransfer.files && e.dataTransfer.files[0]) {
|
|
handleFileSelect(e.dataTransfer.files[0]);
|
|
}
|
|
};
|
|
|
|
const handleFileSelect = (selectedFile) => {
|
|
if (!selectedFile.name.endsWith('.csv')) {
|
|
toast.error('Please select a CSV file');
|
|
return;
|
|
}
|
|
setFile(selectedFile);
|
|
setImportResult(null);
|
|
};
|
|
|
|
const handleFileInput = (e) => {
|
|
if (e.target.files && e.target.files[0]) {
|
|
handleFileSelect(e.target.files[0]);
|
|
}
|
|
};
|
|
|
|
const handleSubmit = async () => {
|
|
if (!file) {
|
|
toast.error('Please select a file');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('update_existing', updateExisting);
|
|
|
|
const response = await api.post('/admin/users/import', formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
});
|
|
|
|
setImportResult(response.data);
|
|
|
|
if (response.data.status === 'completed') {
|
|
toast.success('All members imported successfully!');
|
|
} else if (response.data.status === 'partial') {
|
|
toast.warning('Import partially successful. Check errors below.');
|
|
} else {
|
|
toast.error('Import failed. Check errors below.');
|
|
}
|
|
|
|
if (onSuccess) onSuccess();
|
|
} catch (error) {
|
|
const errorMessage = error.response?.data?.detail || 'Failed to import members';
|
|
toast.error(errorMessage);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setFile(null);
|
|
setUpdateExisting(false);
|
|
setImportResult(null);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
const getStatusIcon = (status) => {
|
|
if (status === 'completed') {
|
|
return <CheckCircle className="h-6 w-6 text-green-500" />;
|
|
} else if (status === 'partial') {
|
|
return <AlertCircle className="h-6 w-6 text-orange-500" />;
|
|
} else {
|
|
return <XCircle className="h-6 w-6 text-red-500" />;
|
|
}
|
|
};
|
|
|
|
const getStatusBadge = (status) => {
|
|
const config = {
|
|
completed: { label: 'Completed', className: 'bg-green-500 text-white' },
|
|
partial: { label: 'Partial Success', className: 'bg-orange-500 text-white' },
|
|
failed: { label: 'Failed', className: 'bg-red-500 text-white' },
|
|
};
|
|
|
|
const statusConfig = config[status] || config.failed;
|
|
return (
|
|
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full`}>
|
|
{statusConfig.label}
|
|
</Badge>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={handleClose}>
|
|
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
<Upload className="h-6 w-6" />
|
|
{importResult ? 'Import Results' : 'Import Members from CSV'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{importResult
|
|
? 'Review the import results below'
|
|
: 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{!importResult ? (
|
|
// Upload Form
|
|
<div className="grid gap-6 py-4">
|
|
{/* CSV Format Instructions */}
|
|
<Alert className="border-[#664fa3] bg-[#F9F8FB]">
|
|
<AlertDescription className="text-sm text-[#422268]">
|
|
<strong>Required columns:</strong> Email, First Name, Last Name, Phone, Role
|
|
<br />
|
|
<strong>Optional columns:</strong> Status, Address, City, State, Zipcode, Date of Birth, Member Since
|
|
<br />
|
|
<strong>Date format:</strong> YYYY-MM-DD (e.g., 2024-01-15)
|
|
</AlertDescription>
|
|
</Alert>
|
|
|
|
{/* File Upload Area */}
|
|
<div
|
|
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${
|
|
dragActive
|
|
? 'border-[#664fa3] bg-[#F9F8FB]'
|
|
: 'border-[#ddd8eb] hover:border-[#664fa3] hover:bg-[#F9F8FB]'
|
|
}`}
|
|
onDragEnter={handleDrag}
|
|
onDragLeave={handleDrag}
|
|
onDragOver={handleDrag}
|
|
onDrop={handleDrop}
|
|
>
|
|
{file ? (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<FileUp className="h-16 w-16 text-[#81B29A]" />
|
|
<div>
|
|
<p className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{file.name}
|
|
</p>
|
|
<p className="text-sm text-[#664fa3]">
|
|
{(file.size / 1024).toFixed(2)} KB
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setFile(null)}
|
|
className="rounded-xl"
|
|
>
|
|
Remove File
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<Upload className="h-16 w-16 text-[#ddd8eb]" />
|
|
<div>
|
|
<p className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Drag and drop your CSV file here
|
|
</p>
|
|
<p className="text-sm text-[#664fa3] mb-4">or</p>
|
|
<Label htmlFor="file-upload">
|
|
<Button variant="outline" className="rounded-xl cursor-pointer" asChild>
|
|
<span>Browse Files</span>
|
|
</Button>
|
|
</Label>
|
|
<input
|
|
id="file-upload"
|
|
type="file"
|
|
accept=".csv"
|
|
onChange={handleFileInput}
|
|
className="hidden"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div className="flex items-center gap-3 p-4 bg-[#F9F8FB] rounded-xl">
|
|
<Checkbox
|
|
checked={updateExisting}
|
|
onCheckedChange={setUpdateExisting}
|
|
id="update-existing"
|
|
className="h-5 w-5 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
|
|
/>
|
|
<Label htmlFor="update-existing" className="text-[#422268] cursor-pointer">
|
|
Update existing members (if email already exists)
|
|
</Label>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
// Import Results
|
|
<div className="grid gap-6 py-4">
|
|
{/* Summary Cards */}
|
|
<div className="grid md:grid-cols-4 gap-4">
|
|
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] text-center">
|
|
<p className="text-sm text-[#664fa3] mb-1">Total Rows</p>
|
|
<p className="text-2xl font-semibold text-[#422268]">{importResult.total_rows}</p>
|
|
</div>
|
|
<div className="p-4 bg-green-50 rounded-xl border border-green-200 text-center">
|
|
<p className="text-sm text-green-700 mb-1">Successful</p>
|
|
<p className="text-2xl font-semibold text-green-600">{importResult.successful_rows}</p>
|
|
</div>
|
|
<div className="p-4 bg-red-50 rounded-xl border border-red-200 text-center">
|
|
<p className="text-sm text-red-700 mb-1">Failed</p>
|
|
<p className="text-2xl font-semibold text-red-600">{importResult.failed_rows}</p>
|
|
</div>
|
|
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] flex items-center justify-center gap-2">
|
|
{getStatusIcon(importResult.status)}
|
|
{getStatusBadge(importResult.status)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Errors Table */}
|
|
{importResult.errors && importResult.errors.length > 0 && (
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Errors ({importResult.errors.length} {importResult.errors.length === 10 ? '- showing first 10' : ''})
|
|
</h3>
|
|
<div className="border border-[#ddd8eb] rounded-xl overflow-hidden">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
|
|
<TableHead className="text-[#422268] font-semibold">Row</TableHead>
|
|
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
|
|
<TableHead className="text-[#422268] font-semibold">Error</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{importResult.errors.map((error, idx) => (
|
|
<TableRow key={idx} className="hover:bg-[#F9F8FB]">
|
|
<TableCell className="font-medium text-[#422268]">{error.row}</TableCell>
|
|
<TableCell className="text-[#664fa3]">{error.email}</TableCell>
|
|
<TableCell className="text-red-600 text-sm">{error.error}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
{!importResult ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleClose}
|
|
className="rounded-xl"
|
|
disabled={loading}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
|
disabled={loading || !file}
|
|
>
|
|
{loading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
Importing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
Import Members
|
|
</>
|
|
)}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
onClick={handleClose}
|
|
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
|
>
|
|
Done
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|
|
|
|
export default ImportMembersDialog;
|