feat: implement StatCard component and integrate it into AdminDashboard and AdminMembers for improved stats display

This commit is contained in:
2026-01-20 14:43:17 -06:00
parent 3822ba8ffb
commit c73ebfb6c0
4 changed files with 278 additions and 218 deletions

View File

@@ -14,12 +14,13 @@ import {
DropdownMenuTrigger,
} 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 } from 'lucide-react';
import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircle, Clock, Mail, UserPlus, Upload, Download, FileDown, ChevronDown, CircleMinus } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard';
import { StatCard } from '@/components/StatCard';
const AdminMembers = () => {
const navigate = useNavigate();
@@ -319,31 +320,45 @@ const AdminMembers = () => {
</div>
{/* Stats */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{users.length}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p>
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{users.filter(u => u.status === 'active').length}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{users.filter(u => u.status === 'payment_pending').length}
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Inactive</p>
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{users.filter(u => u.status === 'inactive').length}
</p>
</Card>
<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 mb-8">
<StatCard
title="Total Members"
value={users.length}
icon={Users}
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
dataTestId="stat-total-members"
/>
<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"
/>
</div>
</div>
{/* Filters */}
@@ -394,137 +409,137 @@ const AdminMembers = () => {
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">
{/* 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 className="flex items-start gap-4 flex-1">
{/* 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>
{getStatusBadge(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>Joined: {joinedDate ? 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>
{/* 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>
{getStatusBadge(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>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p>
{user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p>
{/* 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>
{/* 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;
})()}
{/* 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>
{/* 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>
</Card>
);
})}
</div>