Compare commits
18 Commits
6d777ed583
...
templates
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f71931d4a7 | ||
|
|
97cc5bdedf | ||
|
|
8011913c4d | ||
|
|
40a0e3f342 | ||
|
|
968eaccac2 | ||
|
|
11de3d1eed | ||
|
|
11142ec50e | ||
|
|
0249cad261 | ||
|
|
56711e9136 | ||
|
|
03b76a8e58 | ||
|
|
1acb13ba79 | ||
|
|
fa9a1d1d1d | ||
|
|
48802fe0c6 | ||
|
|
8c351773ba | ||
|
|
3511e7a9c8 | ||
|
|
33a4d8f4c4 | ||
|
|
1d70ac4ec7 | ||
|
|
a6c2475092 |
3
.env.development
Normal file
3
.env.development
Normal file
@@ -0,0 +1,3 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||
REACT_APP_BASENAME=/membership
|
||||
PUBLIC_URL=/membership
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"homepage": "/become-a-member",
|
||||
"homepage": "/",
|
||||
"dependencies": {
|
||||
"@fontsource/dm-sans": "^5.2.8",
|
||||
"@fontsource/fraunces": "^5.2.9",
|
||||
|
||||
14
src/App.js
14
src/App.js
@@ -23,6 +23,7 @@ import AdminMembers from './pages/admin/AdminMembers';
|
||||
import AdminPermissions from './pages/admin/AdminPermissions';
|
||||
import AdminRoles from './pages/admin/AdminRoles';
|
||||
import AdminEvents from './pages/admin/AdminEvents';
|
||||
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
||||
import AdminValidations from './pages/admin/AdminValidations';
|
||||
import AdminPlans from './pages/admin/AdminPlans';
|
||||
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
||||
@@ -50,6 +51,8 @@ import Resources from './pages/Resources';
|
||||
import ContactUs from './pages/ContactUs';
|
||||
import TermsOfService from './pages/TermsOfService';
|
||||
import PrivacyPolicy from './pages/PrivacyPolicy';
|
||||
import AcceptInvitation from './pages/AcceptInvitation';
|
||||
import NotFound from './pages/NotFound';
|
||||
|
||||
const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
@@ -82,6 +85,7 @@ function App() {
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/verify-email" element={<VerifyEmail />} />
|
||||
<Route path="/accept-invitation" element={<AcceptInvitation />} />
|
||||
<Route path="/forgot-password" element={<ForgotPassword />} />
|
||||
<Route path="/reset-password" element={<ResetPassword />} />
|
||||
<Route path="/change-password-required" element={
|
||||
@@ -215,6 +219,13 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/events/:eventId/attendance" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminEventAttendance />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/validations" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
@@ -278,6 +289,9 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
|
||||
{/* 404 - Catch all undefined routes */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
</BrowserRouter>
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
Scale,
|
||||
HardDrive,
|
||||
Repeat,
|
||||
Heart
|
||||
Heart,
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
@@ -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) {
|
||||
@@ -204,8 +204,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
${item.disabled
|
||||
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
|
||||
: active
|
||||
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
||||
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
||||
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
||||
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -265,20 +265,24 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
||||
<div className="flex items-center gap-3">
|
||||
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
||||
<img
|
||||
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
||||
alt="LOAF Logo"
|
||||
className={`object-contain transition-all duration-200 ${
|
||||
isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
||||
}`}
|
||||
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
|
||||
}`}
|
||||
/>
|
||||
{isOpen && (
|
||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin
|
||||
</h2>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin
|
||||
</h2>
|
||||
<p className="text-xs text-[#664fa3] group-hover:text-[#ff9e77] transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View Public Site
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="p-2 rounded-lg hover:bg-[#DDD8EB]/20 transition-colors"
|
||||
@@ -295,7 +299,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 overflow-y-auto p-4">
|
||||
<nav className="flex-1 overflow-y-auto p-4 scrollbar-dashboard scrollbar-x-dashboard">
|
||||
{/* Dashboard - Standalone */}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
||||
|
||||
@@ -365,7 +369,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{/* User Section */}
|
||||
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
|
||||
{isOpen && user && (
|
||||
<div className="px-4 py-3 mb-2">
|
||||
<div className="px-4 py-3 mb-2 flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-[#DDD8EB] flex items-center justify-center text-[#422268] font-semibold">
|
||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||
@@ -379,6 +383,8 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to='/profile'><Settings size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -392,11 +398,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
</div>
|
||||
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${
|
||||
storagePercentage > 90 ? 'bg-red-500' :
|
||||
className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
|
||||
storagePercentage > 75 ? 'bg-yellow-500' :
|
||||
'bg-[#81B29A]'
|
||||
}`}
|
||||
'bg-[#81B29A]'
|
||||
}`}
|
||||
style={{ width: `${storagePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -407,11 +412,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
) : (
|
||||
<div className="flex justify-center">
|
||||
<div className="relative group">
|
||||
<HardDrive className={`h-5 w-5 ${
|
||||
storagePercentage > 90 ? 'text-red-500' :
|
||||
<HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
|
||||
storagePercentage > 75 ? 'text-yellow-500' :
|
||||
'text-[#664fa3]'
|
||||
}`} />
|
||||
'text-[#664fa3]'
|
||||
}`} />
|
||||
{storagePercentage > 75 && (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
|
||||
)}
|
||||
|
||||
@@ -40,15 +40,14 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
const fetchRoles = async () => {
|
||||
setLoadingRoles(true);
|
||||
try {
|
||||
const response = await api.get('/admin/roles');
|
||||
// Filter to show only admin-type roles (not guest or member)
|
||||
const staffRoles = response.data.filter(role =>
|
||||
['admin', 'superadmin', 'finance'].includes(role.code) || !role.is_system_role
|
||||
);
|
||||
setRoles(staffRoles);
|
||||
// New endpoint returns roles based on user's permission level
|
||||
// Superadmin: all roles
|
||||
// Admin: admin, finance, and non-elevated custom roles
|
||||
const response = await api.get('/admin/roles/assignable');
|
||||
setRoles(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch roles:', error);
|
||||
toast.error('Failed to load roles');
|
||||
console.error('Failed to fetch assignable roles:', error);
|
||||
toast.error('Failed to load roles. Please try again.');
|
||||
} finally {
|
||||
setLoadingRoles(false);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
>
|
||||
Documents
|
||||
</Link>
|
||||
<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>
|
||||
|
||||
<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"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Documents
|
||||
</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-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,33 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
import * as React from "react";
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
"inline-flex h-full items-center justify-center rounded-lg gap-6 p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
"inline-flex items-center justify-center whitespace-nowrap hover:bg-[#f1eef9] border-2 border-[#664fa3] rounded-2xl px-3 py-1 text-[#664fa3] text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-foreground data-[state=active]:text-background data-[state=active]:border-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
@@ -34,8 +36,9 @@ const TabsContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
|
||||
@@ -3,7 +3,14 @@ import axios from 'axios';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
const API_URL = process.env.REACT_APP_BACKEND_URL;
|
||||
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
|
||||
|
||||
// Log environment on module load for debugging
|
||||
console.log('[AuthContext] Module initialized with:', {
|
||||
REACT_APP_BACKEND_URL: process.env.REACT_APP_BACKEND_URL,
|
||||
REACT_APP_BASENAME: process.env.REACT_APP_BASENAME,
|
||||
API_URL: API_URL
|
||||
});
|
||||
|
||||
export const AuthProvider = ({ children }) => {
|
||||
const [user, setUser] = useState(null);
|
||||
@@ -54,16 +61,79 @@ export const AuthProvider = ({ children }) => {
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
const response = await axios.post(`${API_URL}/api/auth/login`, { email, password });
|
||||
const { access_token, user: userData } = response.data;
|
||||
localStorage.setItem('token', access_token);
|
||||
setToken(access_token);
|
||||
setUser(userData);
|
||||
try {
|
||||
console.log('[AuthContext] Starting login request...', {
|
||||
API_URL: API_URL,
|
||||
envBackendUrl: process.env.REACT_APP_BACKEND_URL,
|
||||
fullUrl: `${API_URL}/api/auth/login`
|
||||
});
|
||||
|
||||
// Fetch user permissions
|
||||
await fetchPermissions(access_token);
|
||||
const response = await axios.post(
|
||||
`${API_URL}/api/auth/login`,
|
||||
{ email, password },
|
||||
{
|
||||
timeout: 30000, // 30 second timeout
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
return userData;
|
||||
console.log('[AuthContext] Login response received:', {
|
||||
status: response.status,
|
||||
hasToken: !!response.data?.access_token,
|
||||
hasUser: !!response.data?.user
|
||||
});
|
||||
|
||||
const { access_token, user: userData } = response.data;
|
||||
|
||||
// Store token first
|
||||
localStorage.setItem('token', access_token);
|
||||
console.log('[AuthContext] Token stored in localStorage');
|
||||
|
||||
// Update state
|
||||
setToken(access_token);
|
||||
setUser(userData);
|
||||
console.log('[AuthContext] User state updated:', {
|
||||
email: userData.email,
|
||||
role: userData.role
|
||||
});
|
||||
|
||||
// Fetch user permissions (don't let this fail the login)
|
||||
// Use setTimeout to defer permission fetching slightly
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log('[AuthContext] Fetching permissions...');
|
||||
await fetchPermissions(access_token);
|
||||
console.log('[AuthContext] Permissions fetched successfully');
|
||||
} catch (error) {
|
||||
console.error('[AuthContext] Failed to fetch permissions (non-critical):', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status
|
||||
});
|
||||
// Don't throw - permissions can be fetched later if needed
|
||||
}
|
||||
}, 100); // Small delay to ensure state is settled
|
||||
|
||||
return userData;
|
||||
} catch (error) {
|
||||
// Enhanced error logging
|
||||
console.error('[AuthContext] Login failed:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
code: error.code,
|
||||
config: {
|
||||
url: error.config?.url,
|
||||
method: error.config?.method,
|
||||
timeout: error.config?.timeout
|
||||
}
|
||||
});
|
||||
|
||||
// Re-throw to let Login component handle the error
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
|
||||
@@ -116,3 +116,27 @@ code {
|
||||
border-bottom-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@supports selector(::-webkit-scrollbar) {
|
||||
.scrollbar-dashboard::-webkit-scrollbar {
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
.scrollbar-dashboard::-webkit-scrollbar-thumb {
|
||||
background-color: #ddd8eb;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.scrollbar-x-dashboard::-webkit-scrollbar:horizontal {
|
||||
height: 2px;
|
||||
}
|
||||
|
||||
.scrollbar-x-dashboard::-webkit-scrollbar-thumb:horizontal {
|
||||
background-color: #ddd8eb;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
.hide-scrollbar-x::-webkit-scrollbar:horizontal {
|
||||
height: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ const AcceptInvitation = () => {
|
||||
const [invitation, setInvitation] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [successUser, setSuccessUser] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [formData, setFormData] = useState({
|
||||
password: '',
|
||||
@@ -134,19 +136,23 @@ const AcceptInvitation = () => {
|
||||
const { access_token, user } = response.data;
|
||||
localStorage.setItem('token', access_token);
|
||||
|
||||
toast.success('Welcome to LOAF! Your account has been created successfully.');
|
||||
|
||||
// Call login to update auth context
|
||||
if (login) {
|
||||
await login(invitation.email, formData.password);
|
||||
}
|
||||
|
||||
// Redirect based on role
|
||||
if (user.role === 'admin' || user.role === 'superadmin') {
|
||||
navigate('/admin/dashboard');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
// Show success state
|
||||
setSuccessUser(user);
|
||||
setSuccess(true);
|
||||
|
||||
// Auto-redirect after 3 seconds
|
||||
setTimeout(() => {
|
||||
if (user.role === 'admin' || user.role === 'superadmin') {
|
||||
navigate('/admin');
|
||||
} else {
|
||||
navigate('/dashboard');
|
||||
}
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to accept invitation';
|
||||
toast.error(errorMessage);
|
||||
@@ -206,6 +212,83 @@ const AcceptInvitation = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (success) {
|
||||
const redirectPath = successUser?.role === 'admin' || successUser?.role === 'superadmin' ? '/admin' : '/dashboard';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||
{/* Success Animation */}
|
||||
<div className="mb-8">
|
||||
<div className="h-24 w-24 mx-auto rounded-full bg-gradient-to-br from-[#81B29A] to-[#6DA085] flex items-center justify-center animate-bounce">
|
||||
<CheckCircle className="h-12 w-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<h1 className="text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to LOAF! 🎉
|
||||
</h1>
|
||||
<p className="text-xl text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your account has been created successfully.
|
||||
</p>
|
||||
|
||||
{/* User Info Card */}
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-[#DDD8EB] to-[#F9F8FB] rounded-xl">
|
||||
<div className="grid md:grid-cols-2 gap-4 text-left">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Name
|
||||
</p>
|
||||
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{successUser?.first_name} {successUser?.last_name}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Email
|
||||
</p>
|
||||
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{successUser?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Role
|
||||
</p>
|
||||
<div>{getRoleBadge(successUser?.role)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Status
|
||||
</p>
|
||||
<Badge className="bg-[#81B29A] text-white px-4 py-2 rounded-full text-sm">
|
||||
{successUser?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Redirect Info */}
|
||||
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-xl">
|
||||
<p className="text-sm text-blue-800" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Loader2 className="h-4 w-4 inline mr-2 animate-spin" />
|
||||
Redirecting you to your dashboard in 3 seconds...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Manual Continue Button */}
|
||||
<Button
|
||||
onClick={() => navigate(redirectPath)}
|
||||
className="w-full h-14 rounded-xl bg-gradient-to-r from-[#81B29A] to-[#6DA085] hover:from-[#6DA085] hover:to-[#5A8F72] text-white text-lg font-semibold"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
|
||||
@@ -12,10 +12,10 @@ const BoardOfDirectors = () => {
|
||||
];
|
||||
|
||||
const boardMembers = [
|
||||
{ name: 'Danita Cole' },
|
||||
{ name: 'Roxanne Cherico' },
|
||||
{ name: 'Lucretia Copeland' },
|
||||
{ name: 'Julie Fischer' }
|
||||
{ name: 'Danita Cole', title: 'Director' },
|
||||
{ name: 'Roxanne Cherico', title: 'Director' },
|
||||
{ name: 'Lucretia Copeland', title: 'Director' },
|
||||
{ name: 'Julie Fischer', title: 'Director' }
|
||||
];
|
||||
|
||||
|
||||
@@ -112,50 +112,50 @@ const BoardOfDirectors = () => {
|
||||
Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors.
|
||||
</p>
|
||||
{/* card */}
|
||||
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>
|
||||
Nominations are due by November 1. Nomination Form:{' '}
|
||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
||||
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
||||
Click Here
|
||||
</a>
|
||||
</li>
|
||||
<li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li>
|
||||
<li>Officer positions are only available to current directors.</li>
|
||||
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>
|
||||
Nominations are due by November 1. Nomination Form:{' '}
|
||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
||||
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
||||
Click Here
|
||||
</a>
|
||||
</li>
|
||||
<li>Nominees must have been a member for at least 1 year, however it is possible to be elected prior to 1 year, but start the term on the 1 year anniversary.</li>
|
||||
<li>Officer positions are only available to current directors.</li>
|
||||
|
||||
<li>Each director shall serve a 2-year term.</li>
|
||||
<li>Each director shall serve a 2-year term.</li>
|
||||
|
||||
<li>The time commitment is approximately 1–2 hours per week.</li>
|
||||
<li>The time commitment is approximately 1–2 hours per week.</li>
|
||||
|
||||
<li>
|
||||
The tasks that directors perform depend on individual interests. Recent
|
||||
tasks include researching how to obtain an extra PO Box key, ordering
|
||||
Welcome Team name tags, taking pictures at events, researching new venues
|
||||
for holiday socials, and monitoring Facebook posts. For more information
|
||||
about director duties, see Article 2 of the bylaws in the Members Only
|
||||
section of the website:
|
||||
<a
|
||||
href="https://loaftx.org/bylaws/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#48286e] underline"
|
||||
>
|
||||
https://loaftx.org/bylaws/
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
The tasks that directors perform depend on individual interests. Recent
|
||||
tasks include researching how to obtain an extra PO Box key, ordering
|
||||
Welcome Team name tags, taking pictures at events, researching new venues
|
||||
for holiday socials, and monitoring Facebook posts. For more information
|
||||
about director duties, see Article 2 of the bylaws in the Members Only
|
||||
section of the website:
|
||||
<a
|
||||
href="https://loaftx.org/bylaws/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#48286e] underline"
|
||||
>
|
||||
https://loaftx.org/bylaws/
|
||||
</a>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
Directors must attend Board meetings held on the second Thursday of each
|
||||
month at 6:30pm via Zoom.
|
||||
</li>
|
||||
<li>
|
||||
Directors must attend Board meetings held on the second Thursday of each
|
||||
month at 6:30pm via Zoom.
|
||||
</li>
|
||||
|
||||
<li>
|
||||
We are a fun group, and we would love for you to join us in providing this
|
||||
service for our community.
|
||||
</li>
|
||||
</ol>
|
||||
</Card>
|
||||
<li>
|
||||
We are a fun group, and we would love for you to join us in providing this
|
||||
service for our community.
|
||||
</li>
|
||||
</ol>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -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>
|
||||
|
||||
81
src/pages/NotFound.js
Normal file
81
src/pages/NotFound.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { Home, ArrowLeft, Search } from 'lucide-react';
|
||||
|
||||
const NotFound = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[#F9F8FB] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||
{/* 404 Illustration */}
|
||||
<div className="mb-8">
|
||||
<div className="relative">
|
||||
<h1
|
||||
className="text-[180px] font-bold text-transparent bg-clip-text bg-gradient-to-br from-[#ddd8eb] to-[#f9f8fb] leading-none"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
404
|
||||
</h1>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Search className="h-24 w-24 text-[#664fa3] opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<h2
|
||||
className="text-3xl font-semibold text-[#422268] mb-4"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p
|
||||
className="text-lg text-[#664fa3] mb-8 max-w-md mx-auto"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Oops! The page you're looking for doesn't exist. It might have been moved or deleted.
|
||||
</p>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
variant="outline"
|
||||
className="rounded-xl border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f9f8fb] px-6 py-6"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5 mr-2" />
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate('/')}
|
||||
className="rounded-xl bg-gradient-to-r from-[#664fa3] to-[#422268] hover:from-[#422268] hover:to-[#664fa3] text-white px-6 py-6"
|
||||
>
|
||||
<Home className="h-5 w-5 mr-2" />
|
||||
Back to Home
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
|
||||
<p
|
||||
className="text-sm text-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Need help? Contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@loaftx.org"
|
||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||
>
|
||||
support@loaftx.org
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -253,7 +253,13 @@ const Plans = () => {
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||
</div>
|
||||
) : plans.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8 max-w-5xl mx-auto">
|
||||
<div className={`grid gap-6 sm:gap-8 mx-auto ${
|
||||
plans.length === 1
|
||||
? 'grid-cols-1 max-w-md'
|
||||
: plans.length === 2
|
||||
? 'grid-cols-1 sm:grid-cols-2 max-w-3xl'
|
||||
: 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 max-w-5xl'
|
||||
}`}>
|
||||
{plans.map((plan) => {
|
||||
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
||||
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminBylaws = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [bylaws, setBylaws] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -44,7 +46,7 @@ const AdminBylaws = () => {
|
||||
version: '',
|
||||
effective_date: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive',
|
||||
document_type: 'link',
|
||||
is_current: false
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
@@ -71,9 +73,10 @@ const AdminBylaws = () => {
|
||||
version: '',
|
||||
effective_date: new Date().toISOString().split('T')[0],
|
||||
document_url: '',
|
||||
document_type: 'google_drive',
|
||||
document_type: 'link',
|
||||
is_current: bylaws.length === 0 // Auto-check if this is the first bylaws
|
||||
});
|
||||
setUploadedFile(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -183,13 +186,15 @@ const AdminBylaws = () => {
|
||||
Manage LOAF governing bylaws and version history
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Version
|
||||
</Button>
|
||||
{hasPermission('bylaws.create') && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Version
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current Bylaws */}
|
||||
@@ -225,38 +230,44 @@ const AdminBylaws = () => {
|
||||
<ExternalLink className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(currentBylaws)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(currentBylaws)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{hasPermission('bylaws.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(currentBylaws)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('bylaws.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(currentBylaws)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-[#664fa3]">
|
||||
<span>Effective Date: <strong>{formatDate(currentBylaws.effective_date)}</strong></span>
|
||||
<span>•</span>
|
||||
<span>Document Type: <strong>{currentBylaws.document_type === 'google_drive' ? 'Google Drive' : currentBylaws.document_type.toUpperCase()}</strong></span>
|
||||
<span>Document Type: <strong>{currentBylaws.document_type === 'upload' ? 'PDF Upload' : 'Link'}</strong></span>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="p-12 text-center">
|
||||
<Scale className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No current bylaws set</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Bylaws
|
||||
</Button>
|
||||
{hasPermission('bylaws.create') && (
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Bylaws
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -289,22 +300,26 @@ const AdminBylaws = () => {
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(bylawsDoc)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(bylawsDoc)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
{hasPermission('bylaws.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(bylawsDoc)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('bylaws.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(bylawsDoc)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -363,14 +378,16 @@ const AdminBylaws = () => {
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||
setUploadedFile(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -391,6 +408,11 @@ const AdminBylaws = () => {
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedBylaws && !uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
Current file will be kept if no new file is selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -399,12 +421,11 @@ const AdminBylaws = () => {
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||
placeholder="https://drive.google.com/file/d/..."
|
||||
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,7 +4,7 @@ import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle } from 'lucide-react';
|
||||
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle,Globe } from 'lucide-react';
|
||||
|
||||
const AdminDashboard = () => {
|
||||
const [stats, setStats] = useState({
|
||||
@@ -42,8 +42,8 @@ const AdminDashboard = () => {
|
||||
}).map(u => ({
|
||||
...u,
|
||||
totalReminders: (u.email_verification_reminders_sent || 0) +
|
||||
(u.event_attendance_reminders_sent || 0) +
|
||||
(u.payment_reminders_sent || 0)
|
||||
(u.event_attendance_reminders_sent || 0) +
|
||||
(u.payment_reminders_sent || 0)
|
||||
})).sort((a, b) => b.totalReminders - a.totalReminders).slice(0, 5); // Top 5
|
||||
|
||||
setUsersNeedingAttention(needingAttention);
|
||||
@@ -56,173 +56,183 @@ const AdminDashboard = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage users, events, and membership applications.
|
||||
</p>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Admin Dashboard
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage users, events, and membership applications.
|
||||
</p>
|
||||
</div>
|
||||
<Link to={'/'}>
|
||||
<Button
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Globe />
|
||||
View Public Site
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Users className="h-6 w-6 text-[#664fa3]" />
|
||||
</div>
|
||||
{/* Stats Grid */}
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-total-users">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Users className="h-6 w-6 text-[#664fa3]" />
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.totalMembers}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.totalMembers}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-orange-100 p-3 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-pending-validations">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-orange-100 p-3 rounded-lg">
|
||||
<Clock className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.pendingValidations}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
|
||||
</Card>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.pendingValidations}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-[#81B29A]/20 p-3 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
|
||||
</div>
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="stat-active-members">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="bg-[#81B29A]/20 p-3 rounded-lg">
|
||||
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.activeMembers}
|
||||
</div>
|
||||
<p className="text-3xl font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{loading ? '-' : stats.activeMembers}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<Link to="/admin/members">
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
|
||||
<Users className="h-12 w-12 text-[#664fa3] mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Manage Members
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage paying members and their subscription status.
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-users-button"
|
||||
>
|
||||
Go to Members
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
<Link to="/admin/members">
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
|
||||
<Users className="h-12 w-12 text-[#664fa3] mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Manage Members
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View and manage paying members and their subscription status.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-users-button"
|
||||
>
|
||||
Go to Members
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
<Link to="/admin/validations">
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations">
|
||||
<Clock className="h-12 w-12 text-orange-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Validation Queue
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and validate pending membership applications.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-validations-button"
|
||||
>
|
||||
View Validations
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Link to="/admin/validations">
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-validations">
|
||||
<Clock className="h-12 w-12 text-orange-600 mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Validation Queue
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Review and validate pending membership applications.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
data-testid="manage-validations-button"
|
||||
>
|
||||
View Validations
|
||||
</Button>
|
||||
</Card>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Users Needing Attention Widget */}
|
||||
{usersNeedingAttention.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="bg-[#ff9e77]/20 p-3 rounded-lg">
|
||||
<AlertCircle className="h-6 w-6 text-[#ff9e77]" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Needing Personal Outreach
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
These members have received multiple reminder emails. Consider calling them directly.
|
||||
</p>
|
||||
</div>
|
||||
{/* Users Needing Attention Widget */}
|
||||
{usersNeedingAttention.length > 0 && (
|
||||
<div className="mt-12">
|
||||
<Card className="p-8 bg-white rounded-2xl border-2 border-[#ff9e77] shadow-lg">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="bg-[#ff9e77]/20 p-3 rounded-lg">
|
||||
<AlertCircle className="h-6 w-6 text-[#ff9e77]" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{usersNeedingAttention.map(user => (
|
||||
<Link key={user.id} to={`/admin/users/${user.id}`}>
|
||||
<div className="p-4 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb] hover:border-[#ff9e77] hover:shadow-md transition-all cursor-pointer">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h4>
|
||||
<Badge className="bg-[#ff9e77] text-white px-3 py-1 rounded-full text-xs">
|
||||
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone || 'N/A'}</p>
|
||||
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
|
||||
{user.email_verification_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Mail className="inline h-3 w-3 mr-1" />
|
||||
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{user.event_attendance_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Calendar className="inline h-3 w-3 mr-1" />
|
||||
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{user.payment_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full text-sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `tel:${user.phone}`;
|
||||
}}
|
||||
>
|
||||
Call Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-[#DDD8EB]/20 rounded-lg border border-[#ddd8eb]">
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Needing Personal Outreach
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email.
|
||||
A friendly phone call can help them complete the registration process and feel more welcomed to the community.
|
||||
These members have received multiple reminder emails. Consider calling them directly.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{usersNeedingAttention.map(user => (
|
||||
<Link key={user.id} to={`/admin/users/${user.id}`}>
|
||||
<div className="p-4 bg-[#F8F7FB] rounded-xl border border-[#ddd8eb] hover:border-[#ff9e77] hover:shadow-md transition-all cursor-pointer">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h4 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</h4>
|
||||
<Badge className="bg-[#ff9e77] text-white px-3 py-1 rounded-full text-xs">
|
||||
{user.totalReminders} reminder{user.totalReminders !== 1 ? 's' : ''}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p>Email: {user.email}</p>
|
||||
<p>Phone: {user.phone || 'N/A'}</p>
|
||||
<p className="capitalize">Status: {user.status.replace('_', ' ')}</p>
|
||||
{user.email_verification_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Mail className="inline h-3 w-3 mr-1" />
|
||||
{user.email_verification_reminders_sent} email verification reminder{user.email_verification_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{user.event_attendance_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Calendar className="inline h-3 w-3 mr-1" />
|
||||
{user.event_attendance_reminders_sent} event reminder{user.event_attendance_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
{user.payment_reminders_sent > 0 && (
|
||||
<p>
|
||||
<Clock className="inline h-3 w-3 mr-1" />
|
||||
{user.payment_reminders_sent} payment reminder{user.payment_reminders_sent !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full text-sm"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.location.href = `tel:${user.phone}`;
|
||||
}}
|
||||
>
|
||||
Call Member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 p-4 bg-[#DDD8EB]/20 rounded-lg border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>💡 Tip for helping older members:</strong> Many of our members are older ladies who may struggle with email.
|
||||
A friendly phone call can help them complete the registration process and feel more welcomed to the community.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminDonations = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [donations, setDonations] = useState([]);
|
||||
const [filteredDonations, setFilteredDonations] = useState([]);
|
||||
const [stats, setStats] = useState({});
|
||||
@@ -269,33 +271,35 @@ const AdminDonations = () => {
|
||||
className="pl-10 rounded-full border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={exporting}
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('all')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export All Donations</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('current')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export Current View</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{hasPermission('donations.export') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={exporting}
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-3 flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('all')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export All Donations</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('current')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export Current View</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filters Row */}
|
||||
|
||||
548
src/pages/admin/AdminEventAttendance.js
Normal file
548
src/pages/admin/AdminEventAttendance.js
Normal file
@@ -0,0 +1,548 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
import { Badge } from '../../components/ui/badge';
|
||||
import { Checkbox } from '../../components/ui/checkbox';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
MapPin,
|
||||
Download,
|
||||
Check,
|
||||
X,
|
||||
Search,
|
||||
Users,
|
||||
UserCheck,
|
||||
UserX,
|
||||
HelpCircle
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import moment from 'moment';
|
||||
|
||||
const AdminEventAttendance = () => {
|
||||
const { eventId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [event, setEvent] = useState(null);
|
||||
const [rsvps, setRsvps] = useState([]);
|
||||
const [filteredRsvps, setFilteredRsvps] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Filters and search
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Bulk selection
|
||||
const [selectedRsvps, setSelectedRsvps] = useState(new Set());
|
||||
const [selectAll, setSelectAll] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEventAndRsvps();
|
||||
}, [eventId]);
|
||||
|
||||
useEffect(() => {
|
||||
filterRsvps();
|
||||
}, [rsvps, activeTab, searchQuery]);
|
||||
|
||||
const fetchEventAndRsvps = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [eventRes, rsvpsRes] = await Promise.all([
|
||||
api.get(`/admin/events/${eventId}`),
|
||||
api.get(`/admin/events/${eventId}/rsvps`)
|
||||
]);
|
||||
setEvent(eventRes.data);
|
||||
setRsvps(rsvpsRes.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch event data:', error);
|
||||
toast.error('Failed to load event data');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filterRsvps = () => {
|
||||
let filtered = [...rsvps];
|
||||
|
||||
// Filter by RSVP status tab
|
||||
if (activeTab !== 'all') {
|
||||
filtered = filtered.filter(rsvp => rsvp.rsvp_status === activeTab);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(rsvp =>
|
||||
rsvp.user_name?.toLowerCase().includes(query) ||
|
||||
rsvp.user_email?.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredRsvps(filtered);
|
||||
};
|
||||
|
||||
const handleSelectAll = () => {
|
||||
if (selectAll) {
|
||||
setSelectedRsvps(new Set());
|
||||
} else {
|
||||
setSelectedRsvps(new Set(filteredRsvps.map(rsvp => rsvp.user_id)));
|
||||
}
|
||||
setSelectAll(!selectAll);
|
||||
};
|
||||
|
||||
const handleSelectRsvp = (userId) => {
|
||||
const newSelected = new Set(selectedRsvps);
|
||||
if (newSelected.has(userId)) {
|
||||
newSelected.delete(userId);
|
||||
} else {
|
||||
newSelected.add(userId);
|
||||
}
|
||||
setSelectedRsvps(newSelected);
|
||||
setSelectAll(newSelected.size === filteredRsvps.length);
|
||||
};
|
||||
|
||||
const handleBulkAttendance = async (attended) => {
|
||||
if (selectedRsvps.size === 0) {
|
||||
toast.error('Please select at least one RSVP');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const updates = Array.from(selectedRsvps).map(userId => ({
|
||||
user_id: userId,
|
||||
attended
|
||||
}));
|
||||
|
||||
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||
|
||||
toast.success(`Marked ${selectedRsvps.size} ${selectedRsvps.size === 1 ? 'person' : 'people'} as ${attended ? 'attended' : 'not attended'}`);
|
||||
|
||||
// Refresh data
|
||||
await fetchEventAndRsvps();
|
||||
|
||||
// Clear selection
|
||||
setSelectedRsvps(new Set());
|
||||
setSelectAll(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to update attendance:', error);
|
||||
toast.error('Failed to update attendance');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleIndividualAttendance = async (userId, attended) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
const updates = [{
|
||||
user_id: userId,
|
||||
attended
|
||||
}];
|
||||
|
||||
await api.put(`/admin/events/${eventId}/attendance`, { updates });
|
||||
|
||||
toast.success(`Attendance ${attended ? 'confirmed' : 'removed'}`);
|
||||
|
||||
// Refresh data
|
||||
await fetchEventAndRsvps();
|
||||
} catch (error) {
|
||||
console.error('Failed to update attendance:', error);
|
||||
toast.error('Failed to update attendance');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const exportToCSV = () => {
|
||||
if (filteredRsvps.length === 0) {
|
||||
toast.error('No RSVPs to export');
|
||||
return;
|
||||
}
|
||||
|
||||
// CSV header
|
||||
const headers = ['Name', 'Email', 'RSVP Status', 'Attended', 'Attended At'];
|
||||
|
||||
// CSV rows
|
||||
const rows = filteredRsvps.map(rsvp => [
|
||||
`"${rsvp.user_name}"`,
|
||||
`"${rsvp.user_email}"`,
|
||||
`"${rsvp.rsvp_status.toUpperCase()}"`,
|
||||
rsvp.attended ? 'Yes' : 'No',
|
||||
rsvp.attended_at ? `"${moment(rsvp.attended_at).format('YYYY-MM-DD HH:mm A')}"` : ''
|
||||
]);
|
||||
|
||||
// Combine headers and rows
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...rows.map(row => row.join(','))
|
||||
].join('\n');
|
||||
|
||||
// Create blob and download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `${event?.title.replace(/\s+/g, '_')}_RSVPs_${moment().format('YYYY-MM-DD')}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
toast.success('CSV exported successfully');
|
||||
};
|
||||
|
||||
const getStats = () => {
|
||||
const total = rsvps.length;
|
||||
const yesCount = rsvps.filter(r => r.rsvp_status === 'yes').length;
|
||||
const noCount = rsvps.filter(r => r.rsvp_status === 'no').length;
|
||||
const maybeCount = rsvps.filter(r => r.rsvp_status === 'maybe').length;
|
||||
const attendedCount = rsvps.filter(r => r.attended).length;
|
||||
|
||||
return { total, yesCount, noCount, maybeCount, attendedCount };
|
||||
};
|
||||
|
||||
const stats = getStats();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-[#664fa3]">Loading event data...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-[#664fa3] mb-4">Event not found</p>
|
||||
<Button onClick={() => navigate('/admin/events')} className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl">
|
||||
Back to Events
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
onClick={() => navigate('/admin/events')}
|
||||
variant="outline"
|
||||
className="border-[#ddd8eb] text-[#664fa3] rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Events
|
||||
</Button>
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Event Attendance
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Manage RSVPs and track attendance for this event
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={exportToCSV}
|
||||
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export to CSV
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Event Details Card */}
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-4 text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{moment(event.start_at).format('MMMM D, YYYY [at] h:mm A')}</span>
|
||||
</div>
|
||||
{event.location && (
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span>{event.location}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={`${event.published ? 'bg-[#81B29A]' : 'bg-[#ddd8eb]'} text-white px-3 py-1`}>
|
||||
{event.published ? 'Published' : 'Draft'}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-5 gap-4">
|
||||
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Users className="h-8 w-8 text-[#664fa3]" />
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total RSVPs</p>
|
||||
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserCheck className="h-8 w-8 text-[#81B29A]" />
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Yes</p>
|
||||
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.yesCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserX className="h-8 w-8 text-[#E07A5F]" />
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No</p>
|
||||
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.noCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<HelpCircle className="h-8 w-8 text-[#F2CC8F]" />
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Maybe</p>
|
||||
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.maybeCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="flex items-center gap-3">
|
||||
<Check className="h-8 w-8 text-[#664fa3]" />
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Attended</p>
|
||||
<p className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{stats.attendedCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters and Actions */}
|
||||
<Card className="p-6 bg-white border-[#ddd8eb] rounded-xl">
|
||||
<div className="space-y-4">
|
||||
{/* Tab Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => setActiveTab('all')}
|
||||
variant={activeTab === 'all' ? 'default' : 'outline'}
|
||||
className={`rounded-xl ${
|
||||
activeTab === 'all'
|
||||
? 'bg-[#664fa3] hover:bg-[#422268] text-white'
|
||||
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||
}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
All ({stats.total})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('yes')}
|
||||
variant={activeTab === 'yes' ? 'default' : 'outline'}
|
||||
className={`rounded-xl ${
|
||||
activeTab === 'yes'
|
||||
? 'bg-[#81B29A] hover:bg-[#6a9a83] text-white'
|
||||
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||
}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Yes ({stats.yesCount})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('no')}
|
||||
variant={activeTab === 'no' ? 'default' : 'outline'}
|
||||
className={`rounded-xl ${
|
||||
activeTab === 'no'
|
||||
? 'bg-[#E07A5F] hover:bg-[#d16b54] text-white'
|
||||
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||
}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
No ({stats.noCount})
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setActiveTab('maybe')}
|
||||
variant={activeTab === 'maybe' ? 'default' : 'outline'}
|
||||
className={`rounded-xl ${
|
||||
activeTab === 'maybe'
|
||||
? 'bg-[#F2CC8F] hover:bg-[#e8bf7a] text-[#422268]'
|
||||
: 'border-[#ddd8eb] text-[#664fa3] hover:bg-[#F8F7FB]'
|
||||
}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Maybe ({stats.maybeCount})
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search and Bulk Actions */}
|
||||
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||
<div className="flex-1 min-w-[200px] max-w-md relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#664fa3]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 border-[#ddd8eb] rounded-xl"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedRsvps.size > 0 && (
|
||||
<div className="flex gap-2">
|
||||
<Badge className="bg-[#664fa3] text-white px-3 py-1">
|
||||
{selectedRsvps.size} selected
|
||||
</Badge>
|
||||
<Button
|
||||
onClick={() => handleBulkAttendance(true)}
|
||||
disabled={saving}
|
||||
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-1" />
|
||||
Mark Attended
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleBulkAttendance(false)}
|
||||
disabled={saving}
|
||||
className="bg-[#E07A5F] hover:bg-[#d16b54] text-white rounded-xl"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Mark Not Attended
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* RSVP Table */}
|
||||
<Card className="bg-white border-[#ddd8eb] rounded-xl overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-[#F8F7FB] border-b border-[#ddd8eb]">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left">
|
||||
<Checkbox
|
||||
checked={selectAll}
|
||||
onCheckedChange={handleSelectAll}
|
||||
/>
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Name
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Email
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
RSVP Status
|
||||
</th>
|
||||
<th className="px-4 py-3 text-center text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Attendance
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Attended At
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRsvps.length > 0 ? (
|
||||
filteredRsvps.map((rsvp) => (
|
||||
<tr key={rsvp.user_id} className="border-b border-[#ddd8eb] hover:bg-[#F8F7FB] transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
<Checkbox
|
||||
checked={selectedRsvps.has(rsvp.user_id)}
|
||||
onCheckedChange={() => handleSelectRsvp(rsvp.user_id)}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{rsvp.user_name}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{rsvp.user_email}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<Badge
|
||||
className={`${
|
||||
rsvp.rsvp_status === 'yes'
|
||||
? 'bg-[#81B29A]'
|
||||
: rsvp.rsvp_status === 'no'
|
||||
? 'bg-[#E07A5F]'
|
||||
: 'bg-[#F2CC8F] text-[#422268]'
|
||||
} text-white text-xs px-2 py-1`}
|
||||
>
|
||||
{rsvp.rsvp_status.toUpperCase()}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{rsvp.attended ? (
|
||||
<Button
|
||||
onClick={() => handleIndividualAttendance(rsvp.user_id, false)}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
className="bg-[#81B29A] hover:bg-[#6a9a83] text-white rounded-lg min-w-[120px]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Check className="h-3 w-3 mr-1" />
|
||||
Attended
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => handleIndividualAttendance(rsvp.user_id, true)}
|
||||
disabled={saving}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#81B29A] hover:text-white hover:border-[#81B29A] rounded-lg min-w-[120px]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<X className="h-3 w-3 mr-1" />
|
||||
Not Attended
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{rsvp.attended_at ? moment(rsvp.attended_at).format('MMM D, YYYY h:mm A') : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="6" className="px-4 py-12 text-center">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{searchQuery ? 'No RSVPs match your search' : 'No RSVPs for this filter'}
|
||||
</p>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminEventAttendance;
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
@@ -8,16 +9,14 @@ import { Input } from '../../components/ui/input';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
|
||||
import { AttendanceDialog } from '../../components/AttendanceDialog';
|
||||
|
||||
const AdminEvents = () => {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
||||
const [editingEvent, setEditingEvent] = useState(null);
|
||||
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
@@ -342,19 +341,16 @@ const AdminEvents = () => {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-2 pt-4 border-t border-[#ddd8eb]">
|
||||
{/* Mark Attendance Button */}
|
||||
{/* Manage Attendance Button */}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedEvent(event);
|
||||
setAttendanceDialogOpen(true);
|
||||
}}
|
||||
onClick={() => navigate(`/admin/events/${event.id}/attendance`)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
||||
data-testid={`mark-attendance-${event.id}`}
|
||||
>
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Mark Attendance ({event.rsvp_count || 0} RSVPs)
|
||||
Manage Attendance ({event.rsvp_count || 0} RSVPs)
|
||||
</Button>
|
||||
|
||||
{/* Other Actions */}
|
||||
@@ -419,14 +415,6 @@ const AdminEvents = () => {
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attendance Dialog */}
|
||||
<AttendanceDialog
|
||||
event={selectedEvent}
|
||||
open={attendanceDialogOpen}
|
||||
onOpenChange={setAttendanceDialogOpen}
|
||||
onSuccess={fetchEvents}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminFinancials = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [reports, setReports] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -42,7 +44,7 @@ const AdminFinancials = () => {
|
||||
year: new Date().getFullYear(),
|
||||
title: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive'
|
||||
document_type: 'link'
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -67,8 +69,9 @@ const AdminFinancials = () => {
|
||||
year: new Date().getFullYear(),
|
||||
title: '',
|
||||
document_url: '',
|
||||
document_type: 'google_drive'
|
||||
document_type: 'link'
|
||||
});
|
||||
setUploadedFile(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -161,13 +164,15 @@ const AdminFinancials = () => {
|
||||
Manage annual financial reports
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Report
|
||||
</Button>
|
||||
{hasPermission('financials.create') && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Report
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reports List */}
|
||||
@@ -175,10 +180,12 @@ const AdminFinancials = () => {
|
||||
<Card className="p-12 text-center">
|
||||
<TrendingUp className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No financial reports yet</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Report
|
||||
</Button>
|
||||
{hasPermission('financials.create') && (
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Report
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
@@ -208,24 +215,30 @@ const AdminFinancials = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(report)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(report)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{(hasPermission('financials.edit') || hasPermission('financials.delete')) && (
|
||||
<div className="flex gap-2">
|
||||
{hasPermission('financials.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(report)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('financials.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(report)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
@@ -274,14 +287,16 @@ const AdminFinancials = () => {
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||
setUploadedFile(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_drive">Google Drive</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -302,6 +317,11 @@ const AdminFinancials = () => {
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedReport && !uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
Current file will be kept if no new file is selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -310,12 +330,11 @@ const AdminFinancials = () => {
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||
placeholder="https://drive.google.com/file/d/..."
|
||||
placeholder="https://docs.google.com/... or https://example.com/file.pdf"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_drive' && 'Paste the shareable link to your Google Drive file'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
Paste the shareable link to your document (Google Drive, Dropbox, PDF URL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -14,11 +16,12 @@ import {
|
||||
SelectValue,
|
||||
} from '../../components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '../../components/ui/dialog';
|
||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin } from 'lucide-react';
|
||||
import { Upload, Trash2, Edit, X, ImageIcon, Calendar, MapPin, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import moment from 'moment';
|
||||
|
||||
const AdminGallery = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [events, setEvents] = useState([]);
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [galleryImages, setGalleryImages] = useState([]);
|
||||
@@ -179,7 +182,33 @@ const AdminGallery = () => {
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{selectedEvent && (
|
||||
{/* Empty State Message */}
|
||||
{events.length === 0 && (
|
||||
<div className="mt-4 p-4 bg-[#f1eef9] border-2 border-[#DDD8EB] rounded-xl">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertCircle className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-semibold text-[#422268] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Events Available
|
||||
</h4>
|
||||
<p className="text-sm text-[#664fa3] mb-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You need to create an event before uploading gallery images. Events help organize photos by occasion.
|
||||
</p>
|
||||
<Link to="/admin/events">
|
||||
<Button
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl text-sm"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Create Your First Event
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedEvent && hasPermission('gallery.upload') && (
|
||||
<div className="pt-4">
|
||||
<input
|
||||
type="file"
|
||||
@@ -240,26 +269,32 @@ const AdminGallery = () => {
|
||||
</div>
|
||||
|
||||
{/* Overlay with Actions */}
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => openEditCaption(image)}
|
||||
size="sm"
|
||||
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Caption
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteImage(image.id)}
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{(hasPermission('gallery.edit') || hasPermission('gallery.delete')) && (
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity rounded-xl flex flex-col items-center justify-center gap-2">
|
||||
{hasPermission('gallery.edit') && (
|
||||
<Button
|
||||
onClick={() => openEditCaption(image)}
|
||||
size="sm"
|
||||
className="bg-white/90 hover:bg-white text-[#422268] rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Caption
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('gallery.delete') && (
|
||||
<Button
|
||||
onClick={() => handleDeleteImage(image.id)}
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white rounded-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Caption Preview */}
|
||||
{image.caption && (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -32,6 +33,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminNewsletters = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [newsletters, setNewsletters] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
@@ -44,7 +46,7 @@ const AdminNewsletters = () => {
|
||||
description: '',
|
||||
published_date: '',
|
||||
document_url: '',
|
||||
document_type: 'google_docs'
|
||||
document_type: 'link'
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
@@ -70,8 +72,9 @@ const AdminNewsletters = () => {
|
||||
description: '',
|
||||
published_date: new Date().toISOString().split('T')[0],
|
||||
document_url: '',
|
||||
document_type: 'google_docs'
|
||||
document_type: 'link'
|
||||
});
|
||||
setUploadedFile(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
@@ -189,13 +192,15 @@ const AdminNewsletters = () => {
|
||||
Create and manage newsletter archive
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Newsletter
|
||||
</Button>
|
||||
{hasPermission('newsletters.create') && (
|
||||
<Button
|
||||
onClick={handleCreate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#533a82] rounded-full flex items-center gap-2"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Add Newsletter
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Newsletters List */}
|
||||
@@ -203,10 +208,12 @@ const AdminNewsletters = () => {
|
||||
<Card className="p-12 text-center">
|
||||
<FileText className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] text-lg mb-4">No newsletters yet</p>
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Newsletter
|
||||
</Button>
|
||||
{hasPermission('newsletters.create') && (
|
||||
<Button onClick={handleCreate} className="bg-[#664fa3] text-white">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create First Newsletter
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
@@ -232,7 +239,7 @@ const AdminNewsletters = () => {
|
||||
{formatDate(newsletter.published_date)}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="border-[#664fa3] text-[#664fa3]">
|
||||
{newsletter.document_type === 'google_docs' ? 'Google Docs' : newsletter.document_type.toUpperCase()}
|
||||
{newsletter.document_type === 'upload' ? 'PDF Upload' : 'Link'}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -245,24 +252,30 @@ const AdminNewsletters = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(newsletter)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(newsletter)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{(hasPermission('newsletters.edit') || hasPermission('newsletters.delete')) && (
|
||||
<div className="flex gap-2">
|
||||
{hasPermission('newsletters.edit') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(newsletter)}
|
||||
className="border-[#664fa3] text-[#664fa3]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('newsletters.delete') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(newsletter)}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
@@ -322,14 +335,16 @@ const AdminNewsletters = () => {
|
||||
<Label htmlFor="document_type">Document Type *</Label>
|
||||
<Select
|
||||
value={formData.document_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, document_type: value })}
|
||||
onValueChange={(value) => {
|
||||
setFormData({ ...formData, document_type: value, document_url: '' });
|
||||
setUploadedFile(null);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="google_docs">Google Docs</SelectItem>
|
||||
<SelectItem value="pdf">PDF</SelectItem>
|
||||
<SelectItem value="link">Link</SelectItem>
|
||||
<SelectItem value="upload">Upload</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -350,6 +365,11 @@ const AdminNewsletters = () => {
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
{selectedNewsletter && !uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
Current file will be kept if no new file is selected
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
@@ -358,12 +378,11 @@ const AdminNewsletters = () => {
|
||||
id="document_url"
|
||||
value={formData.document_url}
|
||||
onChange={(e) => setFormData({ ...formData, document_url: e.target.value })}
|
||||
placeholder="https://docs.google.com/document/d/..."
|
||||
placeholder="https://docs.google.com/document/d/... or https://example.com/file.pdf"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-1">
|
||||
{formData.document_type === 'google_docs' && 'Paste the shareable link to your Google Doc'}
|
||||
{formData.document_type === 'pdf' && 'Paste the URL to your PDF file'}
|
||||
Paste the shareable link to your document (Google Docs, Dropbox, PDF URL, etc.)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminPlans = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [filteredPlans, setFilteredPlans] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -136,13 +138,15 @@ const AdminPlans = () => {
|
||||
Manage membership plans and pricing.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreatePlan}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Plan
|
||||
</Button>
|
||||
{hasPermission('subscriptions.plans') && (
|
||||
<Button
|
||||
onClick={handleCreatePlan}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Plan
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -286,27 +290,29 @@ const AdminPlans = () => {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
|
||||
<Button
|
||||
onClick={() => handleEditPlan(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteClick(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full"
|
||||
disabled={plan.subscriber_count > 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
{hasPermission('subscriptions.plans') && (
|
||||
<div className="flex flex-col sm:flex-row gap-2 pt-4 border-t border-[#ddd8eb]">
|
||||
<Button
|
||||
onClick={() => handleEditPlan(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleDeleteClick(plan)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1 border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-full"
|
||||
disabled={plan.subscriber_count > 0}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning for plans with subscribers */}
|
||||
{plan.subscriber_count > 0 && (
|
||||
@@ -328,7 +334,7 @@ const AdminPlans = () => {
|
||||
? 'Try adjusting your filters'
|
||||
: 'Create your first subscription plan to get started'}
|
||||
</p>
|
||||
{!searchQuery && activeFilter === 'all' && (
|
||||
{!searchQuery && activeFilter === 'all' && hasPermission('subscriptions.plans') && (
|
||||
<Button
|
||||
onClick={handleCreatePlan}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8"
|
||||
|
||||
@@ -12,11 +12,11 @@ import CreateStaffDialog from '../../components/CreateStaffDialog';
|
||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||
import PendingInvitationsTable from '../../components/PendingInvitationsTable';
|
||||
import { toast } from 'sonner';
|
||||
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye } from 'lucide-react';
|
||||
import { UserCog, Search, Shield, UserPlus, Mail, Edit, Eye, Trash2, UserCheck, UserX } from 'lucide-react';
|
||||
|
||||
const AdminStaff = () => {
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = useAuth();
|
||||
const { hasPermission, user } = useAuth();
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -71,6 +71,32 @@ const AdminStaff = () => {
|
||||
setFilteredUsers(filtered);
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (userId, currentStatus) => {
|
||||
const newStatus = currentStatus === 'active' ? 'inactive' : 'active';
|
||||
|
||||
try {
|
||||
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
||||
toast.success(`User ${newStatus === 'active' ? 'activated' : 'deactivated'} successfully`);
|
||||
fetchStaff(); // Refresh list
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to update user status');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteUser = async (userId, userName) => {
|
||||
if (!window.confirm(`Are you sure you want to delete ${userName}? This action cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.delete(`/admin/users/${userId}`);
|
||||
toast.success('User deleted successfully');
|
||||
fetchStaff(); // Refresh list
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete user');
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const config = {
|
||||
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
||||
@@ -92,7 +118,7 @@ const AdminStaff = () => {
|
||||
const getStatusBadge = (status) => {
|
||||
const config = {
|
||||
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white' }
|
||||
inactive: { label: 'Inactive', className: 'bg-gray-400 text-white ' }
|
||||
};
|
||||
|
||||
const statusConfig = config[status] || config.inactive;
|
||||
@@ -116,7 +142,7 @@ const AdminStaff = () => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
{hasPermission('users.invite') && (
|
||||
{hasPermission('users.create') && (
|
||||
<Button
|
||||
onClick={() => setInviteDialogOpen(true)}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
|
||||
@@ -250,7 +276,7 @@ const AdminStaff = () => {
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => navigate(`/admin/users/${user.id}`)}
|
||||
variant="outline"
|
||||
@@ -259,6 +285,41 @@ const AdminStaff = () => {
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Manage
|
||||
</Button>
|
||||
|
||||
{hasPermission('users.status') && (
|
||||
<Button
|
||||
onClick={() => handleToggleStatus(user.id, user.status)}
|
||||
variant="outline"
|
||||
className={`border-2 rounded-full px-4 py-2 ${
|
||||
user.status === 'active'
|
||||
? 'border-orange-500 text-orange-600 hover:bg-orange-50'
|
||||
: 'border-green-500 text-green-600 hover:bg-green-50'
|
||||
}`}
|
||||
>
|
||||
{user.status === 'active' ? (
|
||||
<>
|
||||
<UserX className="h-4 w-4 mr-2" />
|
||||
Deactivate
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<UserCheck className="h-4 w-4 mr-2" />
|
||||
Activate
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{hasPermission('users.delete') && (
|
||||
<Button
|
||||
onClick={() => handleDeleteUser(user.id, `${user.first_name} ${user.last_name}`)}
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-600 hover:bg-red-50 rounded-full px-4 py-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
import { Input } from '../../components/ui/input';
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
} from '../../components/ui/dropdown-menu';
|
||||
|
||||
const AdminSubscriptions = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [subscriptions, setSubscriptions] = useState([]);
|
||||
const [filteredSubscriptions, setFilteredSubscriptions] = useState([]);
|
||||
const [plans, setPlans] = useState([]);
|
||||
@@ -412,33 +414,35 @@ Proceed with activation?`;
|
||||
</div>
|
||||
|
||||
{/* Export Dropdown */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={exporting}
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-2 flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('all')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export All Subscriptions</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('current')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export Current View</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{hasPermission('subscriptions.export') && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
disabled={exporting}
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6a9680] rounded-full px-6 py-2 flex items-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'Export'}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-56 bg-white rounded-xl border-2 border-[#ddd8eb] shadow-lg">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('all')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export All Subscriptions</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleExport('current')}
|
||||
className="cursor-pointer hover:bg-[#f1eef9] rounded-lg p-3"
|
||||
>
|
||||
<FileDown className="h-4 w-4 mr-2 text-[#664fa3]" />
|
||||
<span className="text-[#422268]">Export Current View</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -503,16 +507,18 @@ Proceed with activation?`;
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(sub)}
|
||||
className="flex-1 text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
{sub.status === 'active' && (
|
||||
{hasPermission('subscriptions.edit') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(sub)}
|
||||
className="flex-1 text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -607,15 +613,17 @@ Proceed with activation?`;
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(sub)}
|
||||
className="text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
{sub.status === 'active' && (
|
||||
{hasPermission('subscriptions.edit') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleEdit(sub)}
|
||||
className="text-[#664fa3] hover:bg-[#DDD8EB]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAuth } from '../../context/AuthContext';
|
||||
import api from '../../utils/api';
|
||||
import { Card } from '../../components/ui/card';
|
||||
import { Button } from '../../components/ui/button';
|
||||
@@ -35,6 +36,7 @@ import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||
import RejectionDialog from '../../components/RejectionDialog';
|
||||
|
||||
const AdminValidations = () => {
|
||||
const { hasPermission } = useAuth();
|
||||
const [pendingUsers, setPendingUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -74,7 +76,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 +220,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 +320,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 +351,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,68 +410,89 @@ 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)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleBypassAndValidateRequest(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : user.status === 'payment_pending' ? (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleActivatePayment(user)}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Activate Payment
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
{hasPermission('subscriptions.activate') && (
|
||||
<Button
|
||||
onClick={() => handleActivatePayment(user)}
|
||||
size="sm"
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-1" />
|
||||
Activate Payment
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => handleValidateRequest(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleValidateRequest(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
|
||||
>
|
||||
{actionLoading === user.id ? 'Validating...' : 'Validate'}
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission('users.approve') && (
|
||||
<Button
|
||||
onClick={() => handleRejectUser(user)}
|
||||
disabled={actionLoading === user.id}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="border-2 border-red-500 text-red-500 hover:bg-red-50"
|
||||
>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Reject
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
93
src/pages/members/MemberCalendar.css
Normal file
93
src/pages/members/MemberCalendar.css
Normal file
@@ -0,0 +1,93 @@
|
||||
/* Member Calendar Custom Styles */
|
||||
|
||||
.member-calendar .rbc-header {
|
||||
padding: 12px 6px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
color: #422268;
|
||||
background-color: #f9f7fc;
|
||||
border-bottom: 2px solid #ddd8eb;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-today {
|
||||
background-color: #f1eef9;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-off-range-bg {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-event {
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-event:hover {
|
||||
opacity: 0.85;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button {
|
||||
color: #664fa3;
|
||||
border-color: #ddd8eb;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
padding: 6px 12px;
|
||||
background-color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button:hover {
|
||||
background-color: #f1eef9;
|
||||
border-color: #664fa3;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button:active,
|
||||
.member-calendar .rbc-toolbar button.rbc-active {
|
||||
background-color: #664fa3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-month-view {
|
||||
border: 1px solid #ddd8eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-day-bg {
|
||||
border-color: #ddd8eb;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-date-cell {
|
||||
padding: 8px;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
|
||||
/* Ensure toolbar buttons are clickable */
|
||||
.member-calendar .rbc-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button {
|
||||
outline: none;
|
||||
border: 1px solid #ddd8eb;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-btn-group button {
|
||||
margin: 0 2px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-btn-group button:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-btn-group button:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { Calendar, momentLocalizer } from 'react-big-calendar';
|
||||
import moment from 'moment';
|
||||
import 'react-big-calendar/lib/css/react-big-calendar.css';
|
||||
import './MemberCalendar.css';
|
||||
import api from '../../utils/api';
|
||||
import Navbar from '../../components/Navbar';
|
||||
import MemberFooter from '../../components/MemberFooter';
|
||||
@@ -26,6 +27,8 @@ export default function MemberCalendar() {
|
||||
const [selectedEvent, setSelectedEvent] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [rsvpLoading, setRsvpLoading] = useState(false);
|
||||
const [currentDate, setCurrentDate] = useState(new Date());
|
||||
const [currentView, setCurrentView] = useState('month');
|
||||
|
||||
useEffect(() => {
|
||||
fetchEvents();
|
||||
@@ -58,6 +61,14 @@ export default function MemberCalendar() {
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleNavigate = (newDate) => {
|
||||
setCurrentDate(newDate);
|
||||
};
|
||||
|
||||
const handleViewChange = (newView) => {
|
||||
setCurrentView(newView);
|
||||
};
|
||||
|
||||
const handleRSVP = async (status) => {
|
||||
if (!selectedEvent) return;
|
||||
|
||||
@@ -171,10 +182,13 @@ export default function MemberCalendar() {
|
||||
startAccessor="start"
|
||||
endAccessor="end"
|
||||
style={{ height: 700 }}
|
||||
date={currentDate}
|
||||
view={currentView}
|
||||
onNavigate={handleNavigate}
|
||||
onView={handleViewChange}
|
||||
onSelectEvent={handleSelectEvent}
|
||||
eventPropGetter={eventStyleGetter}
|
||||
views={['month', 'week', 'day', 'agenda']}
|
||||
defaultView="month"
|
||||
popup
|
||||
className="member-calendar"
|
||||
/>
|
||||
@@ -320,64 +334,6 @@ export default function MemberCalendar() {
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<style jsx global>{`
|
||||
.member-calendar .rbc-header {
|
||||
padding: 12px 6px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
color: #422268;
|
||||
background-color: #f9f7fc;
|
||||
border-bottom: 2px solid #ddd8eb;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-today {
|
||||
background-color: #f1eef9;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-off-range-bg {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-event {
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-event:hover {
|
||||
opacity: 0.85;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button {
|
||||
color: #664fa3;
|
||||
border-color: #ddd8eb;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button:hover {
|
||||
background-color: #f1eef9;
|
||||
border-color: #664fa3;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-toolbar button.rbc-active {
|
||||
background-color: #664fa3;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-month-view {
|
||||
border: 1px solid #ddd8eb;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-day-bg {
|
||||
border-color: #ddd8eb;
|
||||
}
|
||||
|
||||
.member-calendar .rbc-date-cell {
|
||||
padding: 8px;
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
`}</style>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,8 @@ const MembersDirectory = () => {
|
||||
const [selectedMember, setSelectedMember] = useState(null);
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const { toast } = useToast();
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const pageSize = 12;
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
@@ -33,6 +35,10 @@ const MembersDirectory = () => {
|
||||
filterMembers();
|
||||
}, [searchQuery, members]);
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchQuery, members]);
|
||||
|
||||
const fetchMembers = async () => {
|
||||
try {
|
||||
const response = await api.get('/members/directory');
|
||||
@@ -66,6 +72,14 @@ const MembersDirectory = () => {
|
||||
setFilteredMembers(filtered);
|
||||
};
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(filteredMembers.length / pageSize));
|
||||
|
||||
const pageStart = (currentPage - 1) * pageSize;
|
||||
|
||||
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
|
||||
|
||||
const totalMembers = members.length;
|
||||
|
||||
const getInitials = (firstName, lastName) => {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
};
|
||||
@@ -97,9 +111,15 @@ const MembersDirectory = () => {
|
||||
if (!dateString) return null;
|
||||
return new Date(dateString).toLocaleDateString('en-US', { month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
const Border = ({ yaxis = false }) => {
|
||||
return (
|
||||
yaxis ?
|
||||
<div className=' border-2 w-full border-[#664FA3] my-24' />
|
||||
: <div className=' border-2 w-full border-[#664FA3] mb-24' />
|
||||
)
|
||||
}
|
||||
const MemberCard = ({ member }) => (
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
|
||||
<Card className="p-6 bg-white rounded-3xl border border-[#ddd8eb] hover:shadow-lg transition-all h-full">
|
||||
{/* Profile Photo */}
|
||||
<div className="flex justify-center mb-4">
|
||||
{member.profile_photo_url ? (
|
||||
@@ -140,15 +160,17 @@ const MembersDirectory = () => {
|
||||
)}
|
||||
|
||||
{/* Member Since */}
|
||||
<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', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{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.created_at).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-3 mb-4">
|
||||
@@ -257,39 +279,48 @@ const MembersDirectory = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB]">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Directory
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Connect with fellow LOAF members in our community.
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto py-12">
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-8">
|
||||
<div className="relative max-w-xl">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name or bio..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 pr-4 py-6 text-lg border-[#ddd8eb] rounded-xl focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
||||
{/* Header and Search bar */}
|
||||
<div className='px-9'>
|
||||
|
||||
{/* Header */}
|
||||
<div className="m-8 mt-14 flex flex-col sm:flex-row justify-between items-center ">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-[#422268] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF Members
|
||||
</h1>
|
||||
<p className="text-lg " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className='text-foreground'>Number of current memebers in the directory: </span> <span className='text-[#664fa3] font-medium'>{totalMembers}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-24 mx-10">
|
||||
<div className="relative w-full ">
|
||||
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search by name or bio..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-12 pr-4 py-6 text-3xl font-medium bg-background border-foreground rounded-full focus:border-[#664fa3] focus:ring-[#664fa3]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
/>
|
||||
</div>
|
||||
{searchQuery && (
|
||||
<p className="mt-3 text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Found {filteredMembers.length} {filteredMembers.length === 1 ? 'member' : 'members'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{/* Border Decoration */}
|
||||
|
||||
<Border />
|
||||
|
||||
{/* Members Grid */}
|
||||
{loading ? (
|
||||
@@ -298,7 +329,7 @@ const MembersDirectory = () => {
|
||||
</div>
|
||||
) : filteredMembers.length > 0 ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredMembers.map((member) => (
|
||||
{paginatedMembers.map((member) => (
|
||||
<MemberCard key={member.id} member={member} />
|
||||
))}
|
||||
</div>
|
||||
@@ -316,6 +347,11 @@ const MembersDirectory = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
|
||||
{/* Border Decoration */}
|
||||
<Border yaxis="true" />
|
||||
|
||||
{/* Info Card */}
|
||||
{!loading && members.length > 0 && (
|
||||
<Card className="mt-12 p-6 bg-[#F8F7FB] border-[#ddd8eb]">
|
||||
@@ -463,67 +499,127 @@ const MembersDirectory = () => {
|
||||
{/* Social Media */}
|
||||
{(selectedMember.social_media_facebook || selectedMember.social_media_instagram ||
|
||||
selectedMember.social_media_twitter || selectedMember.social_media_linkedin) && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Connect on Social Media
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{selectedMember.social_media_facebook && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_facebook)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Facebook"
|
||||
>
|
||||
<Facebook className="h-6 w-6 text-[#1877F2]" />
|
||||
</a>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Connect on Social Media
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
{selectedMember.social_media_facebook && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_facebook)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Facebook"
|
||||
>
|
||||
<Facebook className="h-6 w-6 text-[#1877F2]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{selectedMember.social_media_instagram && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_instagram)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Instagram"
|
||||
>
|
||||
<Instagram className="h-6 w-6 text-[#E4405F]" />
|
||||
</a>
|
||||
)}
|
||||
{selectedMember.social_media_instagram && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_instagram)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Instagram"
|
||||
>
|
||||
<Instagram className="h-6 w-6 text-[#E4405F]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{selectedMember.social_media_twitter && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_twitter)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Twitter/X"
|
||||
>
|
||||
<Twitter className="h-6 w-6 text-[#1DA1F2]" />
|
||||
</a>
|
||||
)}
|
||||
{selectedMember.social_media_twitter && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_twitter)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="Twitter/X"
|
||||
>
|
||||
<Twitter className="h-6 w-6 text-[#1DA1F2]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{selectedMember.social_media_linkedin && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_linkedin)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<Linkedin className="h-6 w-6 text-[#0A66C2]" />
|
||||
</a>
|
||||
)}
|
||||
{selectedMember.social_media_linkedin && (
|
||||
<a
|
||||
href={getSocialMediaLink(selectedMember.social_media_linkedin)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-3 rounded-lg bg-[#F8F7FB] hover:bg-[#DDD8EB] transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<Linkedin className="h-6 w-6 text-[#0A66C2]" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && filteredMembers.length > 0 && (
|
||||
<div className="mt-10 flex flex-col items-center gap-4 pb-12">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Showing {pageStart + 1}–{Math.min(pageStart + pageSize, filteredMembers.length)} of {filteredMembers.length}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
onClick={() => setCurrentPage(1)}
|
||||
disabled={currentPage === 1}
|
||||
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
|
||||
>
|
||||
First Page
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentPage((page) => Math.max(1, page - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="bg-[#DDD8EB] rounded-full text-[#422268] hover:bg-[#664fa3] hover:text-white"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, index) => {
|
||||
const pageNumber = index + 1;
|
||||
const isActive = pageNumber === currentPage;
|
||||
return (
|
||||
<Button
|
||||
key={pageNumber}
|
||||
onClick={() => setCurrentPage(pageNumber)}
|
||||
className={
|
||||
isActive
|
||||
? "bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
|
||||
: "bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] hover:text-white rounded-full"
|
||||
}
|
||||
>
|
||||
{pageNumber}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
<Button
|
||||
onClick={() => setCurrentPage((page) => Math.min(totalPages, page + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
disabled={currentPage === totalPages}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-[#664fa3] rounded-full hover:text-white"
|
||||
>
|
||||
Last Page
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MemberFooter />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -4,14 +4,60 @@ const API_URL = process.env.REACT_APP_BACKEND_URL;
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: `${API_URL}/api`,
|
||||
timeout: 30000, // 30 second timeout for all requests
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
// Request interceptor - add auth token
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[API] Request error:', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
return config;
|
||||
});
|
||||
);
|
||||
|
||||
// Response interceptor - handle errors and retries
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
return response;
|
||||
},
|
||||
async (error) => {
|
||||
const config = error.config;
|
||||
|
||||
// Don't retry if we've already retried or if it's a client error (4xx)
|
||||
if (!config || config.__isRetry || (error.response && error.response.status < 500)) {
|
||||
console.error('[API] Request failed:', {
|
||||
url: config?.url,
|
||||
method: config?.method,
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
data: error.response?.data
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
|
||||
// Mark as retry to prevent infinite loops
|
||||
config.__isRetry = true;
|
||||
|
||||
// Retry after 1 second for server errors or network issues
|
||||
console.warn('[API] Retrying request after 1s:', {
|
||||
url: config.url,
|
||||
method: config.method,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve(api.request(config));
|
||||
}, 1000);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
Reference in New Issue
Block a user