Merge Kayela works to Dev #12
@@ -40,7 +40,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
const pending = response.data.filter(u =>
|
||||
['pending_validation', 'pre_validated'].includes(u.status)
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
||||
);
|
||||
setPendingCount(pending.length);
|
||||
} catch (error) {
|
||||
|
||||
@@ -141,13 +141,35 @@ const Navbar = () => {
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/newsletters"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity flex items-center gap-1 bg-transparent border-none cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Documents
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Newsletters
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/financials" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Financials
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Bylaws
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
@@ -297,14 +319,36 @@ const Navbar = () => {
|
||||
Gallery
|
||||
</Link>
|
||||
|
||||
{/* Documents Section */}
|
||||
<div className="space-y-1">
|
||||
<p className="px-4 py-2 text-white/70 text-sm font-semibold uppercase tracking-wider" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Documents
|
||||
</p>
|
||||
<Link
|
||||
to="/members/newsletters"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Documents
|
||||
Newsletters
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/financials"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/bylaws"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Bylaws
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
to="/profile"
|
||||
|
||||
987
src/components/WordPressImportWizard.js
Normal file
987
src/components/WordPressImportWizard.js
Normal file
@@ -0,0 +1,987 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter } from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Input } from './ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from './ui/table';
|
||||
import { Progress } from './ui/progress';
|
||||
import { Alert, AlertDescription } from './ui/alert';
|
||||
import { toast } from 'sonner';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Upload,
|
||||
FileCheck,
|
||||
CheckCircle,
|
||||
Eye,
|
||||
Play,
|
||||
CheckCircle2,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Trash2,
|
||||
FileDown,
|
||||
Users,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
* WordPress Import Wizard Component
|
||||
*
|
||||
* A comprehensive 6-step wizard for importing WordPress users to LOAF platform.
|
||||
* Features:
|
||||
* - CSV upload and analysis
|
||||
* - Interactive status review and adjustment
|
||||
* - Preview before import
|
||||
* - Real-time import progress
|
||||
* - Full rollback capability
|
||||
* - Error reporting
|
||||
*/
|
||||
export default function WordPressImportWizard({ open, onOpenChange, onSuccess }) {
|
||||
// Wizard state
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [importJobId, setImportJobId] = useState(null);
|
||||
|
||||
// Data state
|
||||
const [uploadedFile, setUploadedFile] = useState(null);
|
||||
const [analysisResult, setAnalysisResult] = useState(null);
|
||||
const [previewData, setPreviewData] = useState([]);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
|
||||
// Override state
|
||||
const [statusOverrides, setStatusOverrides] = useState({});
|
||||
const [selectedRows, setSelectedRows] = useState(new Set());
|
||||
|
||||
// Import execution state
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importProgress, setImportProgress] = useState(0);
|
||||
const [importResults, setImportResults] = useState(null);
|
||||
|
||||
// UI state
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Step definitions
|
||||
const steps = [
|
||||
{ number: 1, title: 'Upload CSV', icon: Upload },
|
||||
{ number: 2, title: 'Field Mapping', icon: FileCheck },
|
||||
{ number: 3, title: 'Review Status', icon: CheckCircle },
|
||||
{ number: 4, title: 'Preview', icon: Eye },
|
||||
{ number: 5, title: 'Execute', icon: Play },
|
||||
{ number: 6, title: 'Results', icon: CheckCircle2 }
|
||||
];
|
||||
|
||||
// Reset wizard state when dialog opens/closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setTimeout(() => {
|
||||
setCurrentStep(1);
|
||||
setImportJobId(null);
|
||||
setUploadedFile(null);
|
||||
setAnalysisResult(null);
|
||||
setPreviewData([]);
|
||||
setStatusOverrides({});
|
||||
setSelectedRows(new Set());
|
||||
setImporting(false);
|
||||
setImportProgress(0);
|
||||
setImportResults(null);
|
||||
}, 300); // Wait for dialog close animation
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// ============================================================================
|
||||
// Step Navigation
|
||||
// ============================================================================
|
||||
|
||||
const canProceed = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return uploadedFile && analysisResult;
|
||||
case 2:
|
||||
return true; // Field mapping auto-detected
|
||||
case 3:
|
||||
return true; // Status review is optional
|
||||
case 4:
|
||||
return true; // Preview is informational
|
||||
case 5:
|
||||
return !importing;
|
||||
case 6:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < 6 && canProceed()) {
|
||||
if (currentStep === 3) {
|
||||
// Load preview data when moving from step 3 to 4
|
||||
loadPreviewData(1);
|
||||
}
|
||||
setCurrentStep(currentStep + 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (currentStep > 1) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 1: Upload CSV
|
||||
// ============================================================================
|
||||
|
||||
const handleFileSelect = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
if (!file.name.endsWith('.csv')) {
|
||||
toast.error('Please upload a CSV file');
|
||||
return;
|
||||
}
|
||||
setUploadedFile(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!uploadedFile) return;
|
||||
|
||||
setUploading(true);
|
||||
const formData = new FormData();
|
||||
formData.append('file', uploadedFile);
|
||||
|
||||
try {
|
||||
const response = await api.post('/admin/import/upload-csv', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
|
||||
setImportJobId(response.data.import_job_id);
|
||||
setAnalysisResult(response.data);
|
||||
toast.success(`CSV analyzed: ${response.data.valid_rows} valid rows, ${response.data.warnings} warnings`);
|
||||
|
||||
// Auto-advance to next step
|
||||
setTimeout(() => setCurrentStep(2), 500);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to upload CSV');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 3: Review & Adjust Status
|
||||
// ============================================================================
|
||||
|
||||
const loadPreviewData = async (page = 1) => {
|
||||
if (!importJobId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await api.get(`/admin/import/${importJobId}/preview`, {
|
||||
params: { page, page_size: 50 }
|
||||
});
|
||||
|
||||
setPreviewData(response.data.rows);
|
||||
setCurrentPage(response.data.page);
|
||||
setTotalPages(response.data.total_pages);
|
||||
} catch (error) {
|
||||
toast.error('Failed to load preview data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (currentStep === 3 && importJobId && previewData.length === 0) {
|
||||
loadPreviewData(1);
|
||||
}
|
||||
}, [currentStep, importJobId]);
|
||||
|
||||
const handleStatusOverride = (rowNum, status) => {
|
||||
setStatusOverrides(prev => ({
|
||||
...prev,
|
||||
[rowNum]: { status }
|
||||
}));
|
||||
};
|
||||
|
||||
const handleBulkStatusChange = (status) => {
|
||||
const newOverrides = { ...statusOverrides };
|
||||
selectedRows.forEach(rowNum => {
|
||||
newOverrides[rowNum] = { status };
|
||||
});
|
||||
setStatusOverrides(newOverrides);
|
||||
toast.success(`Updated ${selectedRows.size} users to ${status}`);
|
||||
};
|
||||
|
||||
const toggleRowSelection = (rowNum) => {
|
||||
setSelectedRows(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(rowNum)) {
|
||||
newSet.delete(rowNum);
|
||||
} else {
|
||||
newSet.add(rowNum);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedRows.size === previewData.length) {
|
||||
setSelectedRows(new Set());
|
||||
} else {
|
||||
setSelectedRows(new Set(previewData.map(row => row.row_number)));
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 5: Execute Import
|
||||
// ============================================================================
|
||||
|
||||
const handleExecuteImport = async () => {
|
||||
setImporting(true);
|
||||
setCurrentStep(5);
|
||||
|
||||
try {
|
||||
// Start import
|
||||
const response = await api.post(`/admin/import/${importJobId}/execute`, {
|
||||
overrides: statusOverrides,
|
||||
options: {
|
||||
send_password_emails: true,
|
||||
skip_errors: true
|
||||
}
|
||||
});
|
||||
|
||||
setImportResults(response.data);
|
||||
toast.success(`Import completed: ${response.data.successful_rows} users imported`);
|
||||
|
||||
// Move to results step
|
||||
setCurrentStep(6);
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Import failed');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll for import progress
|
||||
useEffect(() => {
|
||||
if (currentStep === 5 && importing && importJobId) {
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await api.get(`/admin/import/${importJobId}/status`);
|
||||
setImportProgress(response.data.progress_percent);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch import status:', error);
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [currentStep, importing, importJobId]);
|
||||
|
||||
// ============================================================================
|
||||
// Step 6: Rollback
|
||||
// ============================================================================
|
||||
|
||||
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
|
||||
const handleRollback = async () => {
|
||||
try {
|
||||
await api.post(`/admin/import/${importJobId}/rollback`, { confirm: true });
|
||||
toast.success(`Rolled back ${importResults.successful_rows} users`);
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Rollback failed');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadErrors = async () => {
|
||||
try {
|
||||
const response = await api.get(`/admin/import/${importJobId}/errors/download`, {
|
||||
responseType: 'blob'
|
||||
});
|
||||
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `import_errors_${importJobId}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
|
||||
toast.success('Error report downloaded');
|
||||
} catch (error) {
|
||||
toast.error('Failed to download error report');
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Status Badge Component
|
||||
// ============================================================================
|
||||
|
||||
const StatusBadge = ({ status }) => {
|
||||
const colors = {
|
||||
active: 'bg-green-100 text-green-800 border-green-300',
|
||||
pre_validated: 'bg-blue-100 text-blue-800 border-blue-300',
|
||||
payment_pending: 'bg-yellow-100 text-yellow-800 border-yellow-300',
|
||||
inactive: 'bg-gray-100 text-gray-800 border-gray-300'
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colors[status] || 'bg-gray-100 text-gray-800'}>
|
||||
{status.replace('_', ' ')}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Render Step Content
|
||||
// ============================================================================
|
||||
|
||||
const renderStepContent = () => {
|
||||
switch (currentStep) {
|
||||
case 1:
|
||||
return <Step1Upload />;
|
||||
case 2:
|
||||
return <Step2FieldMapping />;
|
||||
case 3:
|
||||
return <Step3ReviewStatus />;
|
||||
case 4:
|
||||
return <Step4Preview />;
|
||||
case 5:
|
||||
return <Step5Execute />;
|
||||
case 6:
|
||||
return <Step6Results />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 1: Upload CSV
|
||||
// ============================================================================
|
||||
|
||||
const Step1Upload = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">Upload WordPress CSV Export</h3>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
Select the WordPress user export CSV file. The file will be analyzed for data quality issues.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6 border-2 border-dashed border-[#ddd8eb] bg-[#f9f5ff]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Upload className="h-12 w-12 text-[#664fa3]" />
|
||||
<div className="text-center">
|
||||
<Input
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-2">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{uploadedFile && !analysisResult && (
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#422268]"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Analyzing CSV...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="mr-2 h-4 w-4" />
|
||||
Upload and Analyze
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{analysisResult && (
|
||||
<Card className="p-6 bg-green-50 border-green-200">
|
||||
<h4 className="font-semibold text-green-900 mb-4">Analysis Complete</h4>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-green-700">Total Rows</p>
|
||||
<p className="text-2xl font-semibold text-green-900">{analysisResult.total_rows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-green-700">Valid Rows</p>
|
||||
<p className="text-2xl font-semibold text-green-900">{analysisResult.valid_rows}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-yellow-700">Warnings</p>
|
||||
<p className="text-2xl font-semibold text-yellow-900">{analysisResult.warnings}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-red-700">Errors</p>
|
||||
<p className="text-2xl font-semibold text-red-900">{analysisResult.errors}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{analysisResult.data_quality && (
|
||||
<div className="mt-4 pt-4 border-t border-green-300">
|
||||
<h5 className="text-sm font-semibold text-green-900 mb-2">Data Quality Issues:</h5>
|
||||
<ul className="text-sm text-green-800 space-y-1">
|
||||
{analysisResult.data_quality.invalid_dob > 0 && (
|
||||
<li>• {analysisResult.data_quality.invalid_dob} invalid dates of birth</li>
|
||||
)}
|
||||
{analysisResult.data_quality.missing_phone > 0 && (
|
||||
<li>• {analysisResult.data_quality.missing_phone} missing phone numbers</li>
|
||||
)}
|
||||
{analysisResult.data_quality.duplicate_email > 0 && (
|
||||
<li>• {analysisResult.data_quality.duplicate_email} duplicate emails</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 2: Field Mapping
|
||||
// ============================================================================
|
||||
|
||||
const Step2FieldMapping = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">Field Mapping</h3>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
WordPress fields have been automatically mapped to LOAF platform fields.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>WordPress Field</TableHead>
|
||||
<TableHead>→</TableHead>
|
||||
<TableHead>LOAF Field</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">user_email</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">email</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">first_name</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">first_name</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">last_name</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">last_name</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">cell_phone</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">phone</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">date_of_birth</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell className="font-mono text-sm">wp_capabilities</TableCell>
|
||||
<TableCell>→</TableCell>
|
||||
<TableCell className="font-mono text-sm">role + status</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Alert className="bg-blue-50 border-blue-200">
|
||||
<AlertCircle className="h-4 w-4 text-blue-600" />
|
||||
<AlertDescription className="text-blue-800">
|
||||
WordPress roles will be automatically converted:
|
||||
<ul className="mt-2 ml-4 space-y-1">
|
||||
<li>• <code>loaf_admin</code> → admin (active)</li>
|
||||
<li>• <code>loaf_treasure</code> → finance (active)</li>
|
||||
<li>• <code>administrator</code> → superadmin (active)</li>
|
||||
<li>• <code>pms_subscription_plan_63</code> → member</li>
|
||||
</ul>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 3: Review & Adjust Status (KEY FEATURE)
|
||||
// ============================================================================
|
||||
|
||||
const Step3ReviewStatus = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">Review & Adjust User Status</h3>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
Review suggested status mappings and override as needed before import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bulk edit toolbar */}
|
||||
<Card className="p-4 bg-[#f9f5ff] border-[#ddd8eb]">
|
||||
<div className="flex items-center gap-4">
|
||||
<Checkbox
|
||||
checked={selectedRows.size === previewData.length && previewData.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
<span className="text-sm text-[#664fa3] font-medium">
|
||||
{selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'}
|
||||
</span>
|
||||
{selectedRows.size > 0 && (
|
||||
<Select onValueChange={handleBulkStatusChange}>
|
||||
<SelectTrigger className="w-48">
|
||||
<SelectValue placeholder="Change status to..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Data table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#664fa3]" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#f9f5ff]">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox checked={false} />
|
||||
</TableHead>
|
||||
<TableHead>Row</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>WP Role</TableHead>
|
||||
<TableHead>Suggested Status</TableHead>
|
||||
<TableHead>Override Status</TableHead>
|
||||
<TableHead>Issues</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{previewData.map((row) => (
|
||||
<TableRow key={row.row_number}>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedRows.has(row.row_number)}
|
||||
onCheckedChange={() => toggleRowSelection(row.row_number)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{row.row_number}</TableCell>
|
||||
<TableCell className="text-sm">{row.email}</TableCell>
|
||||
<TableCell className="text-sm">
|
||||
{row.first_name} {row.last_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[#ddd8eb] text-[#422268]">
|
||||
{row.wordpress_roles?.join(', ') || 'N/A'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={row.suggested_status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={statusOverrides[row.row_number]?.status || row.suggested_status}
|
||||
onValueChange={(value) => handleStatusOverride(row.row_number, value)}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="active">Active</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="inactive">Inactive</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{row.warnings?.map((warning, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="outline"
|
||||
className="text-orange-600 border-orange-300 mr-1"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
{warning}
|
||||
</Badge>
|
||||
))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadPreviewData(currentPage - 1)}
|
||||
disabled={currentPage === 1 || loading}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => loadPreviewData(currentPage + 1)}
|
||||
disabled={currentPage === totalPages || loading}
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 4: Preview
|
||||
// ============================================================================
|
||||
|
||||
const Step4Preview = () => {
|
||||
const overrideCount = Object.keys(statusOverrides).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">Import Preview</h3>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
Review the final import settings before execution.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-[#664fa3]">Total Users</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]">{analysisResult?.total_rows}</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-[#664fa3]">Status Overrides</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]">{overrideCount}</p>
|
||||
</Card>
|
||||
<Card className="p-6">
|
||||
<p className="text-sm text-[#664fa3]">Expected Imports</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]">{analysisResult?.valid_rows}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h4 className="font-semibold text-[#422268] mb-4">Import Options</h4>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm text-[#664fa3]">Send password reset emails to all imported users</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm text-[#664fa3]">Skip rows with errors and continue import</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-green-600" />
|
||||
<span className="text-sm text-[#664fa3]">Full rollback capability available after import</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{overrideCount > 0 && (
|
||||
<Alert className="bg-yellow-50 border-yellow-200">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600" />
|
||||
<AlertDescription className="text-yellow-800">
|
||||
You have overridden {overrideCount} user status{overrideCount > 1 ? 'es' : ''}.
|
||||
These will be applied during import.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Step 5: Execute
|
||||
// ============================================================================
|
||||
|
||||
const Step5Execute = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">
|
||||
{importing ? 'Import in Progress...' : 'Ready to Import'}
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
{importing
|
||||
? 'Please wait while users are imported. This may take a few minutes.'
|
||||
: 'Click "Start Import" to begin importing users.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{importing && (
|
||||
<div className="space-y-4">
|
||||
<Progress value={importProgress} className="w-full" />
|
||||
<p className="text-center text-sm text-[#664fa3]">
|
||||
{importProgress.toFixed(1)}% complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!importing && !importResults && (
|
||||
<Button
|
||||
onClick={handleExecuteImport}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#422268] py-6 text-lg"
|
||||
>
|
||||
<Play className="mr-2 h-5 w-5" />
|
||||
Start Import
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Step 6: Results & Rollback
|
||||
// ============================================================================
|
||||
|
||||
const Step6Results = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">Import Complete</h3>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
Review the import results and download error reports if needed.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats cards */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Card className="p-6 bg-green-50 border-green-200">
|
||||
<p className="text-sm text-green-700">Successful Imports</p>
|
||||
<p className="text-4xl font-semibold text-green-900">{importResults?.successful_rows || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-red-50 border-red-200">
|
||||
<p className="text-sm text-red-700">Failed Imports</p>
|
||||
<p className="text-4xl font-semibold text-red-900">{importResults?.failed_rows || 0}</p>
|
||||
</Card>
|
||||
<Card className="p-6 bg-blue-50 border-blue-200">
|
||||
<p className="text-sm text-blue-700">Password Emails Sent</p>
|
||||
<p className="text-4xl font-semibold text-blue-900">{importResults?.password_emails_queued || 0}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex gap-4 justify-between flex-wrap">
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
{importResults?.failed_rows > 0 && (
|
||||
<Button onClick={handleDownloadErrors} variant="outline">
|
||||
<FileDown className="h-4 w-4 mr-2" />
|
||||
Download Error Report
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
}}
|
||||
variant="outline"
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
View Imported Members
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Rollback button (prominent, red) */}
|
||||
{importResults?.successful_rows > 0 && (
|
||||
<Button
|
||||
onClick={() => setRollbackConfirmOpen(true)}
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Rollback Import ({importResults.successful_rows} users)
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Rollback confirmation dialog */}
|
||||
<Dialog open={rollbackConfirmOpen} onOpenChange={setRollbackConfirmOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-3 bg-red-100 rounded-full">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600" />
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]">
|
||||
Confirm Rollback
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-[#664fa3]">
|
||||
This will permanently delete{' '}
|
||||
<strong>{importResults?.successful_rows} users</strong> that were imported.
|
||||
This action cannot be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 my-4">
|
||||
<p className="text-sm text-red-800 font-medium mb-2">
|
||||
Type "DELETE {importResults?.successful_rows} USERS" to confirm:
|
||||
</p>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
className="mt-2"
|
||||
placeholder={`DELETE ${importResults?.successful_rows} USERS`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRollbackConfirmOpen(false)} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleRollback}
|
||||
disabled={confirmText !== `DELETE ${importResults?.successful_rows} USERS`}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
Yes, Delete {importResults?.successful_rows} Users
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Main Render
|
||||
// ============================================================================
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]">
|
||||
WordPress Import Wizard
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]">
|
||||
Import WordPress users with interactive status review and full rollback capability
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Step indicator */}
|
||||
<div className="flex items-center justify-between mb-6 px-4">
|
||||
{steps.map((step, index) => {
|
||||
const StepIcon = step.icon;
|
||||
const isCompleted = currentStep > step.number;
|
||||
const isCurrent = currentStep === step.number;
|
||||
|
||||
return (
|
||||
<React.Fragment key={step.number}>
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center
|
||||
${isCurrent ? 'bg-[#664fa3] text-white' : ''}
|
||||
${isCompleted ? 'bg-green-600 text-white' : ''}
|
||||
${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''}
|
||||
`}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
) : (
|
||||
<StepIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${isCurrent ? 'font-semibold text-[#422268]' : 'text-gray-600'}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`flex-1 h-0.5 mx-2 ${isCompleted ? 'bg-green-600' : 'bg-gray-300'}`} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Step content */}
|
||||
<div className="py-6">
|
||||
{renderStepContent()}
|
||||
</div>
|
||||
|
||||
{/* Navigation footer */}
|
||||
<DialogFooter className="flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleBack}
|
||||
disabled={currentStep === 1 || importing}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Back
|
||||
</Button>
|
||||
|
||||
{currentStep < 5 && (
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="bg-[#664fa3] hover:bg-[#422268]"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{currentStep === 6 && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
}}
|
||||
className="bg-[#664fa3] hover:bg-[#422268]"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -60,8 +60,13 @@ export const AuthProvider = ({ children }) => {
|
||||
setToken(access_token);
|
||||
setUser(userData);
|
||||
|
||||
// Fetch user permissions
|
||||
// Fetch user permissions (don't let this fail the login)
|
||||
try {
|
||||
await fetchPermissions(access_token);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch permissions during login, will retry later:', error);
|
||||
// Don't throw - permissions can be fetched later if needed
|
||||
}
|
||||
|
||||
return userData;
|
||||
};
|
||||
|
||||
@@ -15,9 +15,12 @@ const Dashboard = () => {
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [eventActivity, setEventActivity] = useState(null);
|
||||
const [activityLoading, setActivityLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUpcomingEvents();
|
||||
fetchEventActivity();
|
||||
}, []);
|
||||
|
||||
const fetchUpcomingEvents = async () => {
|
||||
@@ -32,6 +35,17 @@ const Dashboard = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchEventActivity = async () => {
|
||||
try {
|
||||
const response = await api.get('/members/event-activity');
|
||||
setEventActivity(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch event activity:', error);
|
||||
} finally {
|
||||
setActivityLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setResendLoading(true);
|
||||
try {
|
||||
@@ -298,6 +312,156 @@ const Dashboard = () => {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Event Activity Section */}
|
||||
<div className="mt-12">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
My Event Activity
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{activityLoading ? (
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event activity...</p>
|
||||
) : eventActivity ? (
|
||||
<div className="space-y-8">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-4 rounded-lg">
|
||||
<Calendar className="h-8 w-8 text-[#664fa3]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{eventActivity.total_rsvps}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-[#81B29A]/20 p-4 rounded-lg">
|
||||
<CheckCircle className="h-8 w-8 text-[#81B29A]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Events Attended</p>
|
||||
<p className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{eventActivity.total_attended}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upcoming RSVP'd Events */}
|
||||
{eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && (
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Upcoming Events (RSVP'd)
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{eventActivity.upcoming_events.map((event) => (
|
||||
<Link to={`/events/${event.id}`} key={event.id}>
|
||||
<div className="p-4 border border-[#ddd8eb] rounded-xl hover:border-[#664fa3] hover:shadow-md transition-all">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(event.start_at).toLocaleDateString()} at{' '}
|
||||
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
|
||||
</div>
|
||||
<Badge className={
|
||||
event.rsvp_status === 'yes' ? 'bg-[#81B29A] text-white' :
|
||||
event.rsvp_status === 'maybe' ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-gray-200 text-gray-700'
|
||||
}>
|
||||
{event.rsvp_status === 'yes' ? 'Going' :
|
||||
event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Past Events & Attendance */}
|
||||
{eventActivity.past_events && eventActivity.past_events.length > 0 && (
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Past Events
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{eventActivity.past_events.slice(0, 5).map((event) => (
|
||||
<div key={event.id} className="p-4 border border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||
<p className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(event.start_at).toLocaleDateString()} at{' '}
|
||||
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<Badge className={event.attended ? 'bg-[#81B29A] text-white' : 'bg-gray-200 text-gray-700'}>
|
||||
{event.attended ? 'Attended' : 'Did not attend'}
|
||||
</Badge>
|
||||
{event.attended && event.attended_at && (
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Checked in: {new Date(event.attended_at).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{eventActivity.past_events.length > 5 && (
|
||||
<p className="text-sm text-center text-[#664fa3] mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Showing 5 of {eventActivity.past_events.length} past events
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* No Events Message */}
|
||||
{(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) &&
|
||||
(!eventActivity.past_events || eventActivity.past_events.length === 0) && (
|
||||
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<div className="text-center">
|
||||
<Calendar className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Event Activity Yet
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Browse upcoming events and RSVP to start building your event history!
|
||||
</p>
|
||||
<Link to="/events">
|
||||
<Button className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Browse Events
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Failed to load event activity. Please try refreshing the page.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
|
||||
@@ -19,7 +19,7 @@ import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import ImportMembersDialog from '../../components/ImportMembersDialog';
|
||||
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
||||
|
||||
const AdminMembers = () => {
|
||||
const navigate = useNavigate();
|
||||
@@ -569,7 +569,7 @@ const AdminMembers = () => {
|
||||
onSuccess={fetchMembers}
|
||||
/>
|
||||
|
||||
<ImportMembersDialog
|
||||
<WordPressImportWizard
|
||||
open={importDialogOpen}
|
||||
onOpenChange={setImportDialogOpen}
|
||||
onSuccess={fetchMembers}
|
||||
|
||||
@@ -74,7 +74,7 @@ const AdminValidations = () => {
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
const pending = response.data.filter(user =>
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(user.status)
|
||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending', 'rejected'].includes(user.status)
|
||||
);
|
||||
setPendingUsers(pending);
|
||||
} catch (error) {
|
||||
@@ -218,12 +218,28 @@ const AdminValidations = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleReactivateUser = async (user) => {
|
||||
setActionLoading(user.id);
|
||||
try {
|
||||
await api.put(`/admin/users/${user.id}/status`, {
|
||||
status: 'pending_validation'
|
||||
});
|
||||
toast.success(`${user.first_name} ${user.last_name} has been reactivated and moved to pending validation`);
|
||||
fetchPendingUsers();
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to reactivate user');
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
pending_email: { label: 'Awaiting Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_validation: { label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' }
|
||||
payment_pending: { label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status];
|
||||
@@ -302,6 +318,12 @@ const AdminValidations = () => {
|
||||
{pendingUsers.filter(u => u.status === 'payment_pending').length}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p>
|
||||
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{pendingUsers.filter(u => u.status === 'rejected').length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -327,6 +349,8 @@ const AdminValidations = () => {
|
||||
<SelectItem value="pending_email">Awaiting Email</SelectItem>
|
||||
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
||||
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
|
||||
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -384,7 +408,16 @@ const AdminValidations = () => {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
{user.status === 'pending_email' ? (
|
||||
{user.status === 'rejected' ? (
|
||||
<Button
|
||||
onClick={() => handleReactivateUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||
>
|
||||
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'}
|
||||
</Button>
|
||||
) : user.status === 'pending_email' ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleBypassAndValidateRequest(user)}
|
||||
|
||||
@@ -140,15 +140,17 @@ const MembersDirectory = () => {
|
||||
)}
|
||||
|
||||
{/* Member Since */}
|
||||
{member.created_at && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Calendar className="h-4 w-4 text-[#664fa3]" />
|
||||
<span className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Member since {new Date(member.member_since || member.created_at).toLocaleDateString('en-US', {
|
||||
Member since {new Date(member.created_at).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-3 mb-4">
|
||||
|
||||
Reference in New Issue
Block a user