feat: implement StatCard component and integrate it into AdminDashboard and AdminMembers for improved stats display
This commit is contained in:
36
src/components/StatCard.jsx
Normal file
36
src/components/StatCard.jsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Card } from "./ui/card";
|
||||||
|
|
||||||
|
export const StatCard = ({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
iconBgClass,
|
||||||
|
dataTestId,
|
||||||
|
}) => (
|
||||||
|
<Card
|
||||||
|
className="p-6 flex flex-col justify-between bg-background rounded-2xl border border-[var(--neutral-800)]"
|
||||||
|
data-testid={dataTestId}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-4 mb-4 ">
|
||||||
|
<div className={`${iconBgClass} p-3 rounded-lg `}>
|
||||||
|
<Icon className="size-8" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
<p
|
||||||
|
className="text-6xl font-semibold text-[var(--purple-ink)] mb-1"
|
||||||
|
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-sm text-brand-purple "
|
||||||
|
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
@@ -1,50 +1,65 @@
|
|||||||
import * as React from "react"
|
import * as React from "react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
className={cn(
|
||||||
{...props} />
|
"rounded-xl border bg-card text-card-foreground shadow",
|
||||||
))
|
className,
|
||||||
Card.displayName = "Card"
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Card.displayName = "Card";
|
||||||
|
|
||||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
CardHeader.displayName = "CardHeader"
|
));
|
||||||
|
CardHeader.displayName = "CardHeader";
|
||||||
|
|
||||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
CardTitle.displayName = "CardTitle"
|
));
|
||||||
|
CardTitle.displayName = "CardTitle";
|
||||||
|
|
||||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("text-sm text-muted-foreground", className)}
|
className={cn("text-sm text-muted-foreground", className)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
CardDescription.displayName = "CardDescription"
|
));
|
||||||
|
CardDescription.displayName = "CardDescription";
|
||||||
|
|
||||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||||
))
|
));
|
||||||
CardContent.displayName = "CardContent"
|
CardContent.displayName = "CardContent";
|
||||||
|
|
||||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("flex items-center p-6 pt-0", className)}
|
className={cn("flex items-center p-6 pt-0", className)}
|
||||||
{...props} />
|
{...props}
|
||||||
))
|
/>
|
||||||
CardFooter.displayName = "CardFooter"
|
));
|
||||||
|
CardFooter.displayName = "CardFooter";
|
||||||
|
|
||||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,13 +4,16 @@ import api from '../../utils/api';
|
|||||||
import { Card } from '../../components/ui/card';
|
import { Card } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
import { Badge } from '../../components/ui/badge';
|
import { Badge } from '../../components/ui/badge';
|
||||||
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle, Globe } from 'lucide-react';
|
import { Users, Calendar, Clock, CheckCircle, Mail, AlertCircle, Globe, CircleMinus } from 'lucide-react';
|
||||||
|
import { StatCard } from '../../components/StatCard';
|
||||||
|
|
||||||
|
|
||||||
const AdminDashboard = () => {
|
const AdminDashboard = () => {
|
||||||
const [stats, setStats] = useState({
|
const [stats, setStats] = useState({
|
||||||
totalMembers: 0,
|
totalMembers: 0,
|
||||||
pendingValidations: 0,
|
pendingValidations: 0,
|
||||||
activeMembers: 0
|
activeMembers: 0,
|
||||||
|
inactiveMembers: 0
|
||||||
});
|
});
|
||||||
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
|
const [usersNeedingAttention, setUsersNeedingAttention] = useState([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -29,7 +32,8 @@ const AdminDashboard = () => {
|
|||||||
pendingValidations: users.filter(u =>
|
pendingValidations: users.filter(u =>
|
||||||
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
['pending_email', 'pending_validation', 'pre_validated', 'payment_pending'].includes(u.status)
|
||||||
).length,
|
).length,
|
||||||
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
|
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length,
|
||||||
|
inactiveMembers: users.filter(u => u.status === 'inactive' && u.role === 'member').length
|
||||||
});
|
});
|
||||||
|
|
||||||
// Find users who have received 3+ reminders (may need personal outreach)
|
// Find users who have received 3+ reminders (may need personal outreach)
|
||||||
@@ -76,52 +80,42 @@ const AdminDashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats Grid */}
|
{/* Stats Grid */}
|
||||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-total-users">
|
<div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
|
||||||
<div className='flex items-start justify-between'>
|
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
Quick Overview
|
||||||
{loading ? '-' : stats.totalMembers}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
|
||||||
<Users className="h-6 w-6 text-brand-purple" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-4 gap-8 ">
|
||||||
|
<StatCard
|
||||||
|
title="Total Members"
|
||||||
|
value={loading ? '-' : stats.totalMembers}
|
||||||
|
icon={Users}
|
||||||
|
iconBgClass="text-brand-purple"
|
||||||
|
dataTestId="stat-total-users"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Pending Validations"
|
||||||
|
value={loading ? '-' : stats.pendingValidations}
|
||||||
|
icon={Clock}
|
||||||
|
iconBgClass="text-brand-light-orange"
|
||||||
|
dataTestId="stat-total-users"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Active Members"
|
||||||
|
value={loading ? '-' : stats.activeMembers}
|
||||||
|
icon={CheckCircle}
|
||||||
|
iconBgClass="text-[var(--green-light)]"
|
||||||
|
dataTestId="stat-total-users"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
title="Inactive Members"
|
||||||
|
value={loading ? '-' : stats.inactiveMembers}
|
||||||
|
icon={CircleMinus}
|
||||||
|
iconBgClass="text-brand-pink"
|
||||||
|
dataTestId="stat-total-users"
|
||||||
|
/>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-pending-validations">
|
|
||||||
<div className='flex items-start justify-between'>
|
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{loading ? '-' : stats.pendingValidations}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className=" p-3 rounded-lg">
|
|
||||||
<Clock className="h-6 w-6 text-orange-600" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validations</p>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-active-members">
|
|
||||||
<div className='flex items-start justify-between'>
|
|
||||||
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
|
|
||||||
{loading ? '-' : stats.activeMembers}
|
|
||||||
</p>
|
|
||||||
<div className="flex items-start justify-between mb-4">
|
|
||||||
<div className="bg-[var(--green-light)]/20 p-3 rounded-lg">
|
|
||||||
<CheckCircle className="h-6 w-6 text-[var(--green-light)]" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active Members</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '../../components/ui/dropdown-menu';
|
} from '../../components/ui/dropdown-menu';
|
||||||
import { toast } from 'sonner';
|
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 PaymentActivationDialog from '../../components/PaymentActivationDialog';
|
||||||
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
import ConfirmationDialog from '../../components/ConfirmationDialog';
|
||||||
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
import CreateMemberDialog from '../../components/CreateMemberDialog';
|
||||||
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
import InviteStaffDialog from '../../components/InviteStaffDialog';
|
||||||
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
import WordPressImportWizard from '../../components/WordPressImportWizard';
|
||||||
|
import { StatCard } from '@/components/StatCard';
|
||||||
|
|
||||||
const AdminMembers = () => {
|
const AdminMembers = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -319,31 +320,45 @@ const AdminMembers = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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 mb-8">
|
<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" }}>
|
<StatCard
|
||||||
{users.length}
|
title="Total Members"
|
||||||
</p>
|
value={users.length}
|
||||||
</Card>
|
icon={Users}
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
|
||||||
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p>
|
dataTestId="stat-total-members"
|
||||||
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
/>
|
||||||
{users.filter(u => u.status === 'active').length}
|
<StatCard
|
||||||
</p>
|
title="Active"
|
||||||
</Card>
|
value={users.filter(u => u.status === 'active').length}
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
icon={CheckCircle}
|
||||||
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p>
|
iconBgClass="text-[var(--green-light)]"
|
||||||
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
dataTestId="stat-active-members"
|
||||||
{users.filter(u => u.status === 'payment_pending').length}
|
/>
|
||||||
</p>
|
<StatCard
|
||||||
</Card>
|
title="Payment Pending"
|
||||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
value={users.filter(u => u.status === 'payment_pending').length}
|
||||||
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Inactive</p>
|
icon={CreditCard}
|
||||||
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
iconBgClass="text-brand-light-orange"
|
||||||
{users.filter(u => u.status === 'inactive').length}
|
dataTestId="stat-payment-pending-members"
|
||||||
</p>
|
/>
|
||||||
</Card>
|
<StatCard
|
||||||
|
title="Inactive"
|
||||||
|
value={users.filter(u => u.status === 'inactive').length}
|
||||||
|
icon={CircleMinus}
|
||||||
|
iconBgClass=" text-brand-pink"
|
||||||
|
dataTestId="stat-inactive-members"
|
||||||
|
/>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters */}
|
{/* Filters */}
|
||||||
|
|||||||
Reference in New Issue
Block a user