RBAC, Permissions, and Export/Import

This commit is contained in:
Koncept Kit
2025-12-16 20:04:00 +07:00
parent 02e38e1050
commit 9ed778db1c
30 changed files with 4579 additions and 487 deletions

View 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;