Solution: Updated frontend/src/components/ComprehensiveImportWizard.js:
- Increased timeout from 30s to 120s for large file uploads + R2 storage
- Added console error logging for debugging
Login Session Timeout Fix
1. frontend/src/utils/api.js
- Added BASENAME constant from environment variable
- Updated the 401 redirect to use ${BASENAME}/login?session=expired instead of just /login?session=expired
2. frontend/src/pages/Login.js
- Added basename constant from environment variable
- Updated URL cleanup to use ${basename}/login instead of just /login
909 lines
36 KiB
JavaScript
909 lines
36 KiB
JavaScript
import React, { useState, useMemo, useEffect, useCallback } from 'react';
|
|
import { useNavigate, useLocation, 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';
|
|
import { Input } from '../../components/ui/input';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
|
|
import { Checkbox } from '../../components/ui/checkbox';
|
|
import { Badge } from '../../components/ui/badge';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from '../../components/ui/dropdown-menu';
|
|
import { toast } from 'sonner';
|
|
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus, KeyRound, Loader2, X } from 'lucide-react';
|
|
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
|
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
|
import InviteMemberDialog from '../../components/InviteMemberDialog';
|
|
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
|
import ComprehensiveImportWizard from '../../components/ComprehensiveImportWizard';
|
|
import TemplateImportWizard from '../../components/TemplateImportWizard';
|
|
import StatusBadge from '../../components/StatusBadge';
|
|
import { StatCard } from '@/components/StatCard';
|
|
import { useMembers } from '../../hooks/use-users';
|
|
|
|
const AdminMembers = () => {
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const { hasPermission } = useAuth();
|
|
const {
|
|
users,
|
|
filteredUsers,
|
|
loading,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
filterValue: statusFilter,
|
|
setFilterValue: setStatusFilter,
|
|
refetch,
|
|
} = useMembers();
|
|
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
|
|
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
|
|
const [statusChanging, setStatusChanging] = useState(null);
|
|
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
|
|
const [pendingStatusChange, setPendingStatusChange] = useState(null);
|
|
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
|
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
|
|
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
|
const [comprehensiveImportOpen, setComprehensiveImportOpen] = useState(false);
|
|
const [templateImportOpen, setTemplateImportOpen] = useState(false);
|
|
const [exporting, setExporting] = useState(false);
|
|
|
|
// Bulk selection state
|
|
const [selectedUsers, setSelectedUsers] = useState(new Set());
|
|
const [bulkActionLoading, setBulkActionLoading] = useState(false);
|
|
const [bulkPasswordResetOpen, setBulkPasswordResetOpen] = useState(false);
|
|
|
|
// Import job filter state
|
|
const [importJobs, setImportJobs] = useState([]);
|
|
const [selectedImportJob, setSelectedImportJob] = useState(null);
|
|
const [importJobLoading, setImportJobLoading] = useState(false);
|
|
|
|
// Fetch import jobs on mount
|
|
useEffect(() => {
|
|
const fetchImportJobs = async () => {
|
|
try {
|
|
const response = await api.get('/admin/users/import-jobs');
|
|
// Filter to only show completed/partial jobs that have users
|
|
const jobsWithUsers = response.data.filter(
|
|
(job) => job.successful_rows > 0 && ['completed', 'partial'].includes(job.status)
|
|
);
|
|
setImportJobs(jobsWithUsers);
|
|
} catch (error) {
|
|
console.error('Failed to fetch import jobs:', error);
|
|
}
|
|
};
|
|
|
|
if (hasPermission('users.import')) {
|
|
fetchImportJobs();
|
|
}
|
|
}, [hasPermission]);
|
|
|
|
// Select all users from an import job
|
|
const selectUsersFromImportJob = useCallback(async (jobId) => {
|
|
if (!jobId) {
|
|
setSelectedImportJob(null);
|
|
return;
|
|
}
|
|
|
|
setImportJobLoading(true);
|
|
setSelectedImportJob(jobId);
|
|
|
|
try {
|
|
// Get import job details to get the user IDs
|
|
const response = await api.get(`/admin/users/import-jobs/${jobId}`);
|
|
const importedUserIds = response.data.imported_user_ids || [];
|
|
|
|
if (importedUserIds.length === 0) {
|
|
toast.info('No users found in this import job');
|
|
setSelectedImportJob(null);
|
|
return;
|
|
}
|
|
|
|
// Filter to only select users that are currently visible in filteredUsers
|
|
const visibleImportedUsers = filteredUsers.filter((user) =>
|
|
importedUserIds.includes(user.id)
|
|
);
|
|
|
|
if (visibleImportedUsers.length === 0) {
|
|
// If no visible users match, select from all users
|
|
const allImportedUsers = users.filter((user) =>
|
|
importedUserIds.includes(user.id)
|
|
);
|
|
|
|
if (allImportedUsers.length > 0) {
|
|
setSelectedUsers(new Set(allImportedUsers.map((u) => u.id)));
|
|
toast.success(
|
|
`Selected ${allImportedUsers.length} users from import job (some may be hidden by current filters)`
|
|
);
|
|
} else {
|
|
toast.info('No users from this import job found');
|
|
}
|
|
} else {
|
|
setSelectedUsers(new Set(visibleImportedUsers.map((u) => u.id)));
|
|
toast.success(`Selected ${visibleImportedUsers.length} users from import job`);
|
|
}
|
|
} catch (error) {
|
|
const message = error.response?.data?.detail || 'Failed to load import job';
|
|
toast.error(message);
|
|
setSelectedImportJob(null);
|
|
} finally {
|
|
setImportJobLoading(false);
|
|
}
|
|
}, [filteredUsers, users]);
|
|
|
|
// Check if all visible users are selected
|
|
const allSelected = useMemo(() => {
|
|
if (!filteredUsers || filteredUsers.length === 0) return false;
|
|
return filteredUsers.every((user) => selectedUsers.has(user.id));
|
|
}, [filteredUsers, selectedUsers]);
|
|
|
|
// Toggle single user selection
|
|
const toggleUserSelection = (userId) => {
|
|
setSelectedUsers((prev) => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(userId)) {
|
|
newSet.delete(userId);
|
|
} else {
|
|
newSet.add(userId);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
// Toggle all visible users
|
|
const toggleAllUsers = () => {
|
|
if (allSelected) {
|
|
setSelectedUsers(new Set());
|
|
} else {
|
|
setSelectedUsers(new Set(filteredUsers.map((user) => user.id)));
|
|
}
|
|
};
|
|
|
|
// Clear selection
|
|
const clearSelection = () => {
|
|
setSelectedUsers(new Set());
|
|
};
|
|
|
|
// Handle bulk password reset
|
|
const handleBulkPasswordReset = async () => {
|
|
if (selectedUsers.size === 0) {
|
|
toast.error('No users selected');
|
|
return;
|
|
}
|
|
|
|
setBulkActionLoading(true);
|
|
try {
|
|
const response = await api.post('/admin/users/bulk-password-reset', {
|
|
user_ids: Array.from(selectedUsers),
|
|
send_email: true,
|
|
});
|
|
|
|
toast.success(
|
|
`Password reset emails sent to ${response.data.successful} users`
|
|
);
|
|
|
|
if (response.data.failed > 0) {
|
|
toast.warning(`${response.data.failed} emails failed to send`);
|
|
}
|
|
|
|
setBulkPasswordResetOpen(false);
|
|
clearSelection();
|
|
} catch (error) {
|
|
const message = error.response?.data?.detail || 'Failed to send password reset emails';
|
|
toast.error(message);
|
|
} finally {
|
|
setBulkActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleActivatePayment = (user) => {
|
|
setSelectedUserForPayment(user);
|
|
setPaymentDialogOpen(true);
|
|
};
|
|
|
|
const handlePaymentSuccess = () => {
|
|
refetch(); // Refresh list
|
|
};
|
|
|
|
const handleStatusChangeRequest = (userId, currentStatus, newStatus, user) => {
|
|
// Skip confirmation if status didn't actually change
|
|
if (currentStatus === newStatus) return;
|
|
|
|
setPendingStatusChange({ userId, newStatus, user });
|
|
setConfirmDialogOpen(true);
|
|
};
|
|
|
|
const confirmStatusChange = async () => {
|
|
if (!pendingStatusChange) return;
|
|
|
|
const { userId, newStatus } = pendingStatusChange;
|
|
setStatusChanging(userId);
|
|
setConfirmDialogOpen(false);
|
|
|
|
try {
|
|
await api.put(`/admin/users/${userId}/status`, { status: newStatus });
|
|
toast.success('Member status updated successfully');
|
|
refetch(); // Refresh list
|
|
} catch (error) {
|
|
toast.error(error.response?.data?.detail || 'Failed to update status');
|
|
} finally {
|
|
setStatusChanging(null);
|
|
setPendingStatusChange(null);
|
|
}
|
|
};
|
|
|
|
const handleExport = async (filterType) => {
|
|
setExporting(true);
|
|
try {
|
|
let params = {};
|
|
if (filterType === 'current') {
|
|
if (statusFilter && statusFilter !== 'all') {
|
|
params.status = statusFilter;
|
|
}
|
|
if (searchQuery) {
|
|
params.search = searchQuery;
|
|
}
|
|
}
|
|
// filterType === 'all' will export all members without filters
|
|
|
|
const response = await api.get('/admin/users/export', {
|
|
params,
|
|
responseType: 'blob'
|
|
});
|
|
|
|
// Create download link
|
|
const url = window.URL.createObjectURL(new Blob([response.data]));
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.setAttribute('download', `members_export_${new Date().toISOString().split('T')[0]}.csv`);
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
|
|
toast.success('Members exported successfully');
|
|
} catch (error) {
|
|
toast.error('Failed to export members');
|
|
} finally {
|
|
setExporting(false);
|
|
}
|
|
};
|
|
|
|
const getStatusChangeMessage = () => {
|
|
if (!pendingStatusChange) return {};
|
|
|
|
const { newStatus, user } = pendingStatusChange;
|
|
const userName = `${user.first_name} ${user.last_name}`;
|
|
|
|
const messages = {
|
|
payment_pending: {
|
|
title: 'Revert to Payment Pending?',
|
|
description: `This will change ${userName}'s status back to Payment Pending. They will need to complete payment again to become active.`,
|
|
variant: 'warning',
|
|
confirmText: 'Yes, Revert Status',
|
|
},
|
|
active: {
|
|
title: 'Activate Member?',
|
|
description: `This will activate ${userName}'s membership. They will gain full access to member features and resources.`,
|
|
variant: 'success',
|
|
confirmText: 'Yes, Activate',
|
|
},
|
|
inactive: {
|
|
title: 'Deactivate Member?',
|
|
description: `This will deactivate ${userName}'s membership. They will lose access to member-only features but their data will be preserved.`,
|
|
variant: 'warning',
|
|
confirmText: 'Yes, Deactivate',
|
|
},
|
|
canceled: {
|
|
title: 'Cancel Membership?',
|
|
description: `This will mark ${userName}'s membership as canceled. This indicates they voluntarily ended their membership. Their subscription will not auto-renew.`,
|
|
variant: 'danger',
|
|
confirmText: 'Yes, Cancel Membership',
|
|
},
|
|
expired: {
|
|
title: 'Mark Membership as Expired?',
|
|
description: `This will mark ${userName}'s membership as expired. This indicates their subscription period has ended without renewal.`,
|
|
variant: 'warning',
|
|
confirmText: 'Yes, Mark as Expired',
|
|
},
|
|
};
|
|
|
|
return messages[newStatus] || {
|
|
title: 'Confirm Status Change',
|
|
description: `Are you sure you want to change ${userName}'s status to ${newStatus}?`,
|
|
variant: 'warning',
|
|
confirmText: 'Confirm',
|
|
};
|
|
};
|
|
|
|
const getReminderInfo = (user) => {
|
|
const emailReminders = user.email_verification_reminders_sent || 0;
|
|
const eventReminders = user.event_attendance_reminders_sent || 0;
|
|
const paymentReminders = user.payment_reminders_sent || 0;
|
|
const renewalReminders = user.renewal_reminders_sent || 0;
|
|
const totalReminders = emailReminders + eventReminders + paymentReminders + renewalReminders;
|
|
|
|
return {
|
|
emailReminders,
|
|
eventReminders,
|
|
paymentReminders,
|
|
renewalReminders,
|
|
totalReminders,
|
|
lastReminderAt: user.last_email_verification_reminder_at ||
|
|
user.last_event_attendance_reminder_at ||
|
|
user.last_payment_reminder_at ||
|
|
user.last_renewal_reminder_at
|
|
};
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="mb-8">
|
|
<div className="flex flex-col md:flex-row justify-between items-start mb-4">
|
|
<div>
|
|
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
Members Management
|
|
</h1>
|
|
<p className="text-lg text-brand-purple dark:text-brand-lavender" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Manage paying members and their subscriptions.
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-3 flex-wrap ">
|
|
{hasPermission('users.export') && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
className="btn-util-purple "
|
|
disabled={exporting}
|
|
>
|
|
{exporting ? (
|
|
<>
|
|
<Download className="h-5 w-5 mr-2 animate-bounce" />
|
|
Exporting...
|
|
</>
|
|
) : (
|
|
<>
|
|
<FileDown className="h-5 w-5 mr-2" />
|
|
Export
|
|
<ChevronDown className="h-4 w-4 ml-2" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent className="rounded-xl">
|
|
<DropdownMenuItem onClick={() => handleExport('all')} className="cursor-pointer">
|
|
Export All Members
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => handleExport('current')} className="cursor-pointer">
|
|
Export Current View
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{hasPermission('users.import') && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button className="btn-util-green">
|
|
<Upload className="h-5 w-5 mr-2" />
|
|
Import
|
|
<ChevronDown className="h-4 w-4 ml-2" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-64">
|
|
<DropdownMenuItem
|
|
onClick={() => setTemplateImportOpen(true)}
|
|
className="cursor-pointer"
|
|
>
|
|
<FileDown className="h-4 w-4 mr-2" />
|
|
<div>
|
|
<span className="font-medium">Template Import (Recommended)</span>
|
|
<p className="text-xs text-muted-foreground">
|
|
Download templates, fill your data, upload
|
|
</p>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setComprehensiveImportOpen(true)}
|
|
className="cursor-pointer"
|
|
>
|
|
<Upload className="h-4 w-4 mr-2" />
|
|
<div>
|
|
<span className="font-medium">WordPress Import</span>
|
|
<p className="text-xs text-muted-foreground">
|
|
For WordPress/PMS exports
|
|
</p>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
onClick={() => setImportDialogOpen(true)}
|
|
className="cursor-pointer"
|
|
>
|
|
<Users className="h-4 w-4 mr-2" />
|
|
<div>
|
|
<span className="font-medium">Basic User Import</span>
|
|
<p className="text-xs text-muted-foreground">
|
|
Simple WordPress users CSV
|
|
</p>
|
|
</div>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
)}
|
|
|
|
{hasPermission('users.invite') && (
|
|
<Button
|
|
onClick={() => setInviteDialogOpen(true)}
|
|
className="btn-util-purple "
|
|
>
|
|
<Mail className="h-5 w-5 mr-2" />
|
|
Invite Member
|
|
</Button>
|
|
)}
|
|
|
|
{hasPermission('users.create') && (
|
|
<Button
|
|
onClick={() => setCreateDialogOpen(true)}
|
|
className="btn-util-green "
|
|
>
|
|
<UserPlus className="h-5 w-5 mr-2" />
|
|
Create Member
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
|
|
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
|
|
Quick Overview
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4">
|
|
<StatCard
|
|
title="Active"
|
|
value={users.filter(u => u.status === 'active').length}
|
|
icon={CheckCircle}
|
|
iconBgClass="text-[var(--green-light)]"
|
|
dataTestId="stat-active-members"
|
|
/>
|
|
<StatCard
|
|
title="Payment Pending"
|
|
value={users.filter(u => u.status === 'payment_pending').length}
|
|
icon={CreditCard}
|
|
iconBgClass="text-brand-light-orange"
|
|
dataTestId="stat-payment-pending-members"
|
|
/>
|
|
<StatCard
|
|
title="Inactive"
|
|
value={users.filter(u => u.status === 'inactive').length}
|
|
icon={CircleMinus}
|
|
iconBgClass=" text-brand-pink"
|
|
dataTestId="stat-inactive-members"
|
|
/>
|
|
<StatCard
|
|
title="Total Members"
|
|
value={users.length}
|
|
icon={Users}
|
|
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
|
|
dataTestId="stat-total-members"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="relative">
|
|
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
|
<Input
|
|
placeholder="Search by name or email..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="pl-12 h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
|
data-testid="search-members-input"
|
|
/>
|
|
</div>
|
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
|
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]" data-testid="status-filter-select">
|
|
<SelectValue placeholder="Filter by status" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">All Statuses</SelectItem>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
|
<SelectItem value="pending_validation">Pending Validation</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
<SelectItem value="canceled">Canceled</SelectItem>
|
|
<SelectItem value="expired">Expired</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Import Job Quick Select */}
|
|
{hasPermission('users.import') && importJobs.length > 0 && (
|
|
<Card className="p-4 mb-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<Upload className="h-5 w-5 text-brand-purple" />
|
|
<span
|
|
className="font-semibold text-brand-purple"
|
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
|
>
|
|
Quick Select from Import
|
|
</span>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-3">
|
|
<Select
|
|
value={selectedImportJob || ''}
|
|
onValueChange={(value) => selectUsersFromImportJob(value || null)}
|
|
>
|
|
<SelectTrigger className="w-64 h-10 bg-white border-[var(--neutral-800)]">
|
|
<SelectValue placeholder="Select an import job..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="">-- None --</SelectItem>
|
|
{importJobs.map((job) => (
|
|
<SelectItem key={job.id} value={job.id}>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">
|
|
{job.filename || 'Import'} ({job.successful_rows} users)
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{new Date(job.started_at).toLocaleDateString()}
|
|
</span>
|
|
</div>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
{importJobLoading && (
|
|
<Loader2 className="h-5 w-5 animate-spin text-brand-purple" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Bulk Action Bar */}
|
|
{selectedUsers.size > 0 && (
|
|
<Card className="p-4 mb-4 bg-brand-purple text-white rounded-xl sticky top-4 z-10 shadow-lg">
|
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
|
<div className="flex items-center gap-3">
|
|
<span className="font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''} selected
|
|
{selectedImportJob && (
|
|
<span className="ml-2 text-sm font-normal opacity-80">
|
|
(from import job)
|
|
</span>
|
|
)}
|
|
</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
clearSelection();
|
|
setSelectedImportJob(null);
|
|
}}
|
|
className="text-white hover:bg-white/20"
|
|
>
|
|
<X className="h-4 w-4 mr-1" />
|
|
Clear
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{hasPermission('users.reset_password') && (
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setBulkPasswordResetOpen(true)}
|
|
disabled={bulkActionLoading}
|
|
className="bg-white text-brand-purple hover:bg-white/90"
|
|
>
|
|
{bulkActionLoading ? (
|
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
|
) : (
|
|
<KeyRound className="h-4 w-4 mr-2" />
|
|
)}
|
|
Send Password Reset
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Members List */}
|
|
{loading ? (
|
|
<div className="text-center py-20">
|
|
<p className="text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading members...</p>
|
|
</div>
|
|
) : filteredUsers.length > 0 ? (
|
|
<div className="space-y-4">
|
|
{/* Select All Row */}
|
|
{filteredUsers.length > 0 && hasPermission('users.reset_password') && (
|
|
<div className="flex items-center gap-3 px-2">
|
|
<Checkbox
|
|
id="select-all"
|
|
checked={allSelected}
|
|
onCheckedChange={toggleAllUsers}
|
|
/>
|
|
<label
|
|
htmlFor="select-all"
|
|
className="text-sm text-brand-purple cursor-pointer"
|
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
|
>
|
|
Select all ({filteredUsers.length} users)
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{filteredUsers.map((user) => {
|
|
const joinedDate = user.created_at;
|
|
const memberDate = user.member_since;
|
|
return (
|
|
<Card
|
|
key={user.id}
|
|
className={`p-6 bg-background rounded-2xl border hover:shadow-md transition-shadow ${
|
|
selectedUsers.has(user.id)
|
|
? 'border-brand-purple bg-[var(--lavender-500)]'
|
|
: 'border-[var(--neutral-800)]'
|
|
}`}
|
|
data-testid={`member-card-${user.id}`}
|
|
>
|
|
<div className="flex justify-between items-start flex-wrap gap-4">
|
|
<div className="flex items-start gap-4 flex-1">
|
|
{/* Selection Checkbox */}
|
|
{hasPermission('users.reset_password') && (
|
|
<div className="flex items-center pt-1">
|
|
<Checkbox
|
|
checked={selectedUsers.has(user.id)}
|
|
onCheckedChange={() => toggleUserSelection(user.id)}
|
|
aria-label={`Select ${user.first_name} ${user.last_name}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Avatar */}
|
|
<div className="h-14 w-14 rounded-full bg-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold text-lg flex-shrink-0">
|
|
{user.first_name?.[0]}{user.last_name?.[0]}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-3 mb-2 flex-wrap">
|
|
<h3 className="text-xl font-semibold text-[var(--purple-ink)] " style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{user.first_name} {user.last_name}
|
|
</h3>
|
|
<StatusBadge status={user.status} />
|
|
</div>
|
|
<div className="grid md:grid-cols-2 gap-2 text-sm text-brand-purple dark:text-brand-lavender " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
<p>Email: {user.email}</p>
|
|
<p>Phone: {user.phone}</p>
|
|
<p>Registered: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
|
|
<p>Member Since: {memberDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
|
|
{user.referred_by_member_name && (
|
|
<p>Referred by: {user.referred_by_member_name}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Reminder Info */}
|
|
{(() => {
|
|
const reminderInfo = getReminderInfo(user);
|
|
if (reminderInfo.totalReminders > 0) {
|
|
return (
|
|
<div className="mt-4 p-3 bg-[var(--lavender-500)] rounded-lg border border-[var(--neutral-800)]">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<AlertCircle className="h-4 w-4 text-[var(--orange-light)]" />
|
|
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
{reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent
|
|
{reminderInfo.totalReminders >= 3 && (
|
|
<Badge className="ml-2 bg-[var(--orange-light)] text-white px-2 py-0.5 rounded-full text-xs">
|
|
Needs attention
|
|
</Badge>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{reminderInfo.emailReminders > 0 && (
|
|
<p>
|
|
<Mail className="inline h-3 w-3 mr-1" />
|
|
{reminderInfo.emailReminders} email verification
|
|
</p>
|
|
)}
|
|
{reminderInfo.eventReminders > 0 && (
|
|
<p>
|
|
<Calendar className="inline h-3 w-3 mr-1" />
|
|
{reminderInfo.eventReminders} event attendance
|
|
</p>
|
|
)}
|
|
{reminderInfo.paymentReminders > 0 && (
|
|
<p>
|
|
<Clock className="inline h-3 w-3 mr-1" />
|
|
{reminderInfo.paymentReminders} payment
|
|
</p>
|
|
)}
|
|
{reminderInfo.renewalReminders > 0 && (
|
|
<p>
|
|
<CheckCircle className="inline h-3 w-3 mr-1" />
|
|
{reminderInfo.renewalReminders} renewal
|
|
</p>
|
|
)}
|
|
</div>
|
|
{reminderInfo.lastReminderAt && (
|
|
<p className="mt-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Last reminder: {new Date(reminderInfo.lastReminderAt).toLocaleDateString()} at {new Date(reminderInfo.lastReminderAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})()}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex gap-2 flex-wrap">
|
|
<Link to={`/admin/users/${user.id}`}>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className=""
|
|
>
|
|
<Eye className="h-4 w-4 mr-1" />
|
|
View Profile
|
|
</Button>
|
|
</Link>
|
|
|
|
{/* Show Activate Payment button for payment_pending users */}
|
|
{user.status === 'payment_pending' && (
|
|
<Button
|
|
onClick={() => handleActivatePayment(user)}
|
|
size="sm"
|
|
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
|
>
|
|
<CheckCircle className="h-4 w-4 mr-1" />
|
|
Activate Payment
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Status Management */}
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm text-brand-purple dark:text-brand-lavender whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
Change Status:
|
|
</span>
|
|
<Select
|
|
value={user.status}
|
|
onValueChange={(newStatus) => handleStatusChangeRequest(user.id, user.status, newStatus, user)}
|
|
disabled={statusChanging === user.id}
|
|
>
|
|
<SelectTrigger className="w-[180px] h-9 border-[var(--neutral-800)]">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="payment_pending">Payment Pending</SelectItem>
|
|
<SelectItem value="active">Active</SelectItem>
|
|
<SelectItem value="inactive">Inactive</SelectItem>
|
|
<SelectItem value="canceled">Canceled</SelectItem>
|
|
<SelectItem value="expired">Expired</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-20">
|
|
<Users className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
|
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
No Members Found
|
|
</h3>
|
|
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
|
{searchQuery || statusFilter !== 'all'
|
|
? 'Try adjusting your filters'
|
|
: 'No members yet'}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Payment Activation Dialog */}
|
|
<PaymentActivationDialog
|
|
open={paymentDialogOpen}
|
|
onOpenChange={setPaymentDialogOpen}
|
|
user={selectedUserForPayment}
|
|
onSuccess={handlePaymentSuccess}
|
|
/>
|
|
|
|
{/* Status Change Confirmation Dialog */}
|
|
<ConfirmationDialog
|
|
open={confirmDialogOpen}
|
|
onOpenChange={setConfirmDialogOpen}
|
|
onConfirm={confirmStatusChange}
|
|
loading={statusChanging !== null}
|
|
{...getStatusChangeMessage()}
|
|
/>
|
|
|
|
{/* Create/Invite/Import Dialogs */}
|
|
<CreateMemberDialog
|
|
open={createDialogOpen}
|
|
onOpenChange={setCreateDialogOpen}
|
|
onSuccess={refetch}
|
|
/>
|
|
|
|
<InviteMemberDialog
|
|
open={inviteDialogOpen}
|
|
onOpenChange={setInviteDialogOpen}
|
|
onSuccess={refetch}
|
|
/>
|
|
|
|
<WordPressImportWizard
|
|
open={importDialogOpen}
|
|
onOpenChange={setImportDialogOpen}
|
|
onSuccess={refetch}
|
|
/>
|
|
|
|
<ComprehensiveImportWizard
|
|
open={comprehensiveImportOpen}
|
|
onOpenChange={setComprehensiveImportOpen}
|
|
onSuccess={refetch}
|
|
/>
|
|
|
|
<TemplateImportWizard
|
|
open={templateImportOpen}
|
|
onOpenChange={setTemplateImportOpen}
|
|
onSuccess={refetch}
|
|
/>
|
|
|
|
{/* Bulk Password Reset Confirmation Dialog */}
|
|
<ConfirmationDialog
|
|
open={bulkPasswordResetOpen}
|
|
onOpenChange={setBulkPasswordResetOpen}
|
|
onConfirm={handleBulkPasswordReset}
|
|
title="Send Password Reset Emails"
|
|
description={
|
|
<div className="space-y-4">
|
|
<p>
|
|
You are about to send password reset emails to{' '}
|
|
<strong>{selectedUsers.size} user{selectedUsers.size !== 1 ? 's' : ''}</strong>.
|
|
</p>
|
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
|
<p className="text-sm text-blue-800">
|
|
<strong>What happens:</strong>
|
|
</p>
|
|
<ul className="text-sm text-blue-700 mt-2 space-y-1 list-disc list-inside">
|
|
<li>Each user will receive an email with a password reset link</li>
|
|
<li>The <code className="bg-blue-100 px-1 rounded">force_password_change</code> flag will be set</li>
|
|
<li>Users must set a new password on their next login</li>
|
|
</ul>
|
|
</div>
|
|
<p className="text-sm text-muted-foreground">
|
|
This action cannot be undone. Continue?
|
|
</p>
|
|
</div>
|
|
}
|
|
confirmText={bulkActionLoading ? 'Sending...' : 'Send Emails'}
|
|
variant="info"
|
|
loading={bulkActionLoading}
|
|
/>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default AdminMembers;
|