diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index 7f80a5c..f22f44d 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -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) { diff --git a/src/components/Navbar.js b/src/components/Navbar.js index 95e50c4..dfb05cc 100644 --- a/src/components/Navbar.js +++ b/src/components/Navbar.js @@ -141,13 +141,35 @@ const Navbar = () => { > Gallery - - Documents - + + + + + + + + Newsletters + + + + + Financials + + + + + Bylaws + + + + { Gallery - setIsMobileMenuOpen(false)} - className="block px-4 py-3 text-white text-base font-medium hover:bg-white/10 rounded-lg transition-colors" - style={{ fontFamily: "'Poppins', sans-serif" }} - > - Documents - + {/* Documents Section */} +
+

+ Documents +

+ 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" }} + > + Newsletters + + 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 + + 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 + +
{ + 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 ( + + {status.replace('_', ' ')} + + ); + }; + + // ============================================================================ + // Render Step Content + // ============================================================================ + + const renderStepContent = () => { + switch (currentStep) { + case 1: + return ; + case 2: + return ; + case 3: + return ; + case 4: + return ; + case 5: + return ; + case 6: + return ; + default: + return null; + } + }; + + // ============================================================================ + // Step 1: Upload CSV + // ============================================================================ + + const Step1Upload = () => ( +
+
+

Upload WordPress CSV Export

+

+ Select the WordPress user export CSV file. The file will be analyzed for data quality issues. +

+
+ + +
+ +
+ + {uploadedFile && ( +

+ Selected: {uploadedFile.name} +

+ )} +
+
+
+ + {uploadedFile && !analysisResult && ( + + )} + + {analysisResult && ( + +

Analysis Complete

+
+
+

Total Rows

+

{analysisResult.total_rows}

+
+
+

Valid Rows

+

{analysisResult.valid_rows}

+
+
+

Warnings

+

{analysisResult.warnings}

+
+
+

Errors

+

{analysisResult.errors}

+
+
+ + {analysisResult.data_quality && ( +
+
Data Quality Issues:
+
    + {analysisResult.data_quality.invalid_dob > 0 && ( +
  • • {analysisResult.data_quality.invalid_dob} invalid dates of birth
  • + )} + {analysisResult.data_quality.missing_phone > 0 && ( +
  • • {analysisResult.data_quality.missing_phone} missing phone numbers
  • + )} + {analysisResult.data_quality.duplicate_email > 0 && ( +
  • • {analysisResult.data_quality.duplicate_email} duplicate emails
  • + )} +
+
+ )} +
+ )} +
+ ); + + // ============================================================================ + // Step 2: Field Mapping + // ============================================================================ + + const Step2FieldMapping = () => ( +
+
+

Field Mapping

+

+ WordPress fields have been automatically mapped to LOAF platform fields. +

+
+ + + + + + WordPress Field + + LOAF Field + + + + + user_email + + email + + + first_name + + first_name + + + last_name + + last_name + + + cell_phone + + phone + + + date_of_birth + + date_of_birth + + + wp_capabilities + + role + status + + +
+
+ + + + + WordPress roles will be automatically converted: +
    +
  • loaf_admin → admin (active)
  • +
  • loaf_treasure → finance (active)
  • +
  • administrator → superadmin (active)
  • +
  • pms_subscription_plan_63 → member
  • +
+
+
+
+ ); + + // ============================================================================ + // Step 3: Review & Adjust Status (KEY FEATURE) + // ============================================================================ + + const Step3ReviewStatus = () => ( +
+
+

Review & Adjust User Status

+

+ Review suggested status mappings and override as needed before import. +

+
+ + {/* Bulk edit toolbar */} + +
+ 0} + onCheckedChange={toggleSelectAll} + /> + + {selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'} + + {selectedRows.size > 0 && ( + + )} +
+
+ + {/* Data table */} + {loading ? ( +
+ +
+ ) : ( +
+ + + + + + + Row + Email + Name + WP Role + Suggested Status + Override Status + Issues + + + + {previewData.map((row) => ( + + + toggleRowSelection(row.row_number)} + /> + + {row.row_number} + {row.email} + + {row.first_name} {row.last_name} + + + + {row.wordpress_roles?.join(', ') || 'N/A'} + + + + + + + + + + {row.warnings?.map((warning, idx) => ( + + + {warning} + + ))} + + + ))} + +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+

+ Page {currentPage} of {totalPages} +

+
+ + +
+
+ )} +
+ ); + + // ============================================================================ + // Step 4: Preview + // ============================================================================ + + const Step4Preview = () => { + const overrideCount = Object.keys(statusOverrides).length; + + return ( +
+
+

Import Preview

+

+ Review the final import settings before execution. +

+
+ +
+ +

Total Users

+

{analysisResult?.total_rows}

+
+ +

Status Overrides

+

{overrideCount}

+
+ +

Expected Imports

+

{analysisResult?.valid_rows}

+
+
+ + +

Import Options

+
+
+ + Send password reset emails to all imported users +
+
+ + Skip rows with errors and continue import +
+
+ + Full rollback capability available after import +
+
+
+ + {overrideCount > 0 && ( + + + + You have overridden {overrideCount} user status{overrideCount > 1 ? 'es' : ''}. + These will be applied during import. + + + )} +
+ ); + }; + + // ============================================================================ + // Step 5: Execute + // ============================================================================ + + const Step5Execute = () => ( +
+
+

+ {importing ? 'Import in Progress...' : 'Ready to Import'} +

+

+ {importing + ? 'Please wait while users are imported. This may take a few minutes.' + : 'Click "Start Import" to begin importing users.'} +

+
+ + {importing && ( +
+ +

+ {importProgress.toFixed(1)}% complete +

+
+ )} + + {!importing && !importResults && ( + + )} +
+ ); + + // ============================================================================ + // Step 6: Results & Rollback + // ============================================================================ + + const Step6Results = () => ( +
+
+

Import Complete

+

+ Review the import results and download error reports if needed. +

+
+ + {/* Stats cards */} +
+ +

Successful Imports

+

{importResults?.successful_rows || 0}

+
+ +

Failed Imports

+

{importResults?.failed_rows || 0}

+
+ +

Password Emails Sent

+

{importResults?.password_emails_queued || 0}

+
+
+ + {/* Action buttons */} +
+
+ {importResults?.failed_rows > 0 && ( + + )} + +
+ + {/* Rollback button (prominent, red) */} + {importResults?.successful_rows > 0 && ( + + )} +
+ + {/* Rollback confirmation dialog */} + + + +
+
+ +
+ + Confirm Rollback + +
+ + This will permanently delete{' '} + {importResults?.successful_rows} users that were imported. + This action cannot be undone. + +
+ +
+

+ Type "DELETE {importResults?.successful_rows} USERS" to confirm: +

+ setConfirmText(e.target.value)} + className="mt-2" + placeholder={`DELETE ${importResults?.successful_rows} USERS`} + /> +
+ + + + + +
+
+
+ ); + + // ============================================================================ + // Main Render + // ============================================================================ + + return ( + + + + + WordPress Import Wizard + + + Import WordPress users with interactive status review and full rollback capability + + + + {/* Step indicator */} +
+ {steps.map((step, index) => { + const StepIcon = step.icon; + const isCompleted = currentStep > step.number; + const isCurrent = currentStep === step.number; + + return ( + +
+
+ {isCompleted ? ( + + ) : ( + + )} +
+

+ {step.title} +

+
+ {index < steps.length - 1 && ( +
+ )} + + ); + })} +
+ + {/* Step content */} +
+ {renderStepContent()} +
+ + {/* Navigation footer */} + + + + {currentStep < 5 && ( + + )} + + {currentStep === 6 && ( + + )} + + +
+ ); +} diff --git a/src/context/AuthContext.js b/src/context/AuthContext.js index 792f25f..ca70795 100644 --- a/src/context/AuthContext.js +++ b/src/context/AuthContext.js @@ -60,8 +60,13 @@ export const AuthProvider = ({ children }) => { setToken(access_token); setUser(userData); - // Fetch user permissions - await fetchPermissions(access_token); + // 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; }; diff --git a/src/pages/Dashboard.js b/src/pages/Dashboard.js index 55d4054..e2d798b 100644 --- a/src/pages/Dashboard.js +++ b/src/pages/Dashboard.js @@ -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 = () => { )} + + {/* Event Activity Section */} +
+
+

+ My Event Activity +

+
+ + {activityLoading ? ( +

Loading event activity...

+ ) : eventActivity ? ( +
+ {/* Stats Cards */} +
+ +
+
+ +
+
+

Total RSVPs

+

+ {eventActivity.total_rsvps} +

+
+
+
+ +
+
+ +
+
+

Events Attended

+

+ {eventActivity.total_attended} +

+
+
+
+
+ + {/* Upcoming RSVP'd Events */} + {eventActivity.upcoming_events && eventActivity.upcoming_events.length > 0 && ( + +

+ Upcoming Events (RSVP'd) +

+
+ {eventActivity.upcoming_events.map((event) => ( + +
+
+
+

{event.title}

+

+ {new Date(event.start_at).toLocaleDateString()} at{' '} + {new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+

{event.location}

+
+ + {event.rsvp_status === 'yes' ? 'Going' : + event.rsvp_status === 'maybe' ? 'Maybe' : 'Not Going'} + +
+
+ + ))} +
+
+ )} + + {/* Past Events & Attendance */} + {eventActivity.past_events && eventActivity.past_events.length > 0 && ( + +

+ Past Events +

+
+ {eventActivity.past_events.slice(0, 5).map((event) => ( +
+
+
+

{event.title}

+

+ {new Date(event.start_at).toLocaleDateString()} at{' '} + {new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} +

+
+
+ + {event.attended ? 'Attended' : 'Did not attend'} + + {event.attended && event.attended_at && ( +

+ Checked in: {new Date(event.attended_at).toLocaleDateString()} +

+ )} +
+
+
+ ))} +
+ {eventActivity.past_events.length > 5 && ( +

+ Showing 5 of {eventActivity.past_events.length} past events +

+ )} +
+ )} + + {/* No Events Message */} + {(!eventActivity.upcoming_events || eventActivity.upcoming_events.length === 0) && + (!eventActivity.past_events || eventActivity.past_events.length === 0) && ( + +
+ +

+ No Event Activity Yet +

+

+ Browse upcoming events and RSVP to start building your event history! +

+ + + +
+
+ )} +
+ ) : ( + +
+ +

+ Failed to load event activity. Please try refreshing the page. +

+
+
+ )} +
diff --git a/src/pages/admin/AdminMembers.js b/src/pages/admin/AdminMembers.js index b581e6b..be79d52 100644 --- a/src/pages/admin/AdminMembers.js +++ b/src/pages/admin/AdminMembers.js @@ -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} /> - { 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}

+
+

Rejected

+

+ {pendingUsers.filter(u => u.status === 'rejected').length} +

+
@@ -327,6 +349,8 @@ const AdminValidations = () => { Awaiting Email Pending Validation Pre-Validated + Payment Pending + Rejected @@ -384,7 +408,16 @@ const AdminValidations = () => {
- {user.status === 'pending_email' ? ( + {user.status === 'rejected' ? ( + + ) : user.status === 'pending_email' ? ( <>