RBAC, Permissions, and Export/Import
This commit is contained in:
335
src/components/ImportMembersDialog.js
Normal file
335
src/components/ImportMembersDialog.js
Normal file
@@ -0,0 +1,335 @@
|
||||
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;
|
||||
Reference in New Issue
Block a user