RBAC, Permissions, and Export/Import

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

View File

@@ -1,18 +1,30 @@
import React, { useEffect, useState } 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 { Badge } from '../../components/ui/badge';
import { Input } from '../../components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../../components/ui/select';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu';
import { toast } from 'sonner';
import { Users, Search, User, CreditCard, Eye, CheckCircle } from 'lucide-react';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown } from 'lucide-react';
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';
const AdminMembers = () => {
const navigate = useNavigate();
const location = useLocation();
const { hasPermission } = useAuth();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
@@ -20,6 +32,13 @@ const AdminMembers = () => {
const [statusFilter, setStatusFilter] = useState('active');
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 [exporting, setExporting] = useState(false);
useEffect(() => {
fetchMembers();
@@ -70,14 +89,127 @@ const AdminMembers = () => {
fetchMembers(); // 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');
fetchMembers(); // 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 getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
pending_approval: { label: 'Pending Approval', className: 'bg-gray-200 text-gray-700' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
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' },
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' },
canceled: { label: 'Canceled', className: 'bg-red-100 text-red-700' },
expired: { label: 'Expired', className: 'bg-red-500 text-white' },
abandoned: { label: 'Abandoned', className: 'bg-gray-300 text-gray-600' }
};
const statusConfig = config[status] || config.inactive;
@@ -88,15 +220,102 @@ const AdminMembers = () => {
);
};
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">
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Management
</h1>
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manage paying members and their subscriptions.
</p>
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Members Management
</h1>
<p className="text-lg text-[#664fa3]" 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="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
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') && (
<Button
onClick={() => setImportDialogOpen(true)}
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
>
<Upload className="h-5 w-5 mr-2" />
Import
</Button>
)}
{hasPermission('users.invite') && (
<Button
onClick={() => setInviteDialogOpen(true)}
className="bg-[#664fa3] hover:bg-[#422268] text-white rounded-xl h-12 px-6"
>
<Mail className="h-5 w-5 mr-2" />
Invite Member
</Button>
)}
{hasPermission('users.create') && (
<Button
onClick={() => setCreateDialogOpen(true)}
className="bg-[#81B29A] hover:bg-[#6DA085] text-white rounded-xl h-12 px-6"
>
<UserPlus className="h-5 w-5 mr-2" />
Create Member
</Button>
)}
</div>
</div>
</div>
{/* Stats */}
@@ -148,9 +367,12 @@ const AdminMembers = () => {
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
</SelectContent>
</Select>
</div>
@@ -192,45 +414,112 @@ const AdminMembers = () => {
<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-[#F8F7FB] rounded-lg border border-[#ddd8eb]">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="h-4 w-4 text-[#ff9e77]" />
<span className="text-sm font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
{reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent
{reminderInfo.totalReminders >= 3 && (
<Badge className="ml-2 bg-[#ff9e77] 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-[#664fa3]" 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-[#664fa3]" 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 gap-2">
<Link to={`/admin/users/${user.id}`}>
<Button
variant="outline"
size="sm"
className="border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
>
<Eye className="h-4 w-4 mr-1" />
View Profile
</Button>
</Link>
<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="border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
>
<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-[#DDD8EB] text-[#422268] hover:bg-white"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
{/* Show Activate Payment button for payment_pending users */}
{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>
)}
</div>
{/* Show Subscription button for active users */}
{user.status === 'active' && (
<Button
variant="outline"
size="sm"
className="border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
{/* Status Management */}
<div className="flex items-center gap-2">
<span className="text-sm text-[#664fa3] 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}
>
<CreditCard className="h-4 w-4 mr-1" />
Subscription
</Button>
)}
<SelectTrigger className="w-[180px] h-9 border-[#ddd8eb]">
<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>
@@ -257,6 +546,34 @@ const AdminMembers = () => {
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={fetchMembers}
/>
<InviteStaffDialog
open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen}
onSuccess={fetchMembers}
/>
<ImportMembersDialog
open={importDialogOpen}
onOpenChange={setImportDialogOpen}
onSuccess={fetchMembers}
/>
</>
);
};