dev #19

Merged
andika merged 24 commits from dev into loaf-prod 2026-01-26 11:22:19 +00:00
4 changed files with 278 additions and 218 deletions
Showing only changes of commit c73ebfb6c0 - Show all commits

View 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>
);

View File

@@ -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,
};

View File

@@ -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='flex items-start justify-between'>
<p className="text-3xl font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>
{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='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
</Card> Quick Overview
</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"
/>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="stat-pending-validations"> </div>
<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 */}

View File

@@ -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="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <div className='rounded-3xl bg-brand-lavender/10 p-8 mb-8'>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Members</p> Quick Overview
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> </div>
{users.length} <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-4 gap-4 mb-8">
</p>
</Card>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <StatCard
<p className="text-sm text-brand-purple dark:text-brand-lavender mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Active</p> title="Total Members"
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={users.length}
{users.filter(u => u.status === 'active').length} icon={Users}
</p> iconBgClass="bg-[var(--blue-light)] text-[var(--blue-dark)]"
</Card> dataTestId="stat-total-members"
<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> <StatCard
<p className="text-4xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> title="Active"
{users.filter(u => u.status === 'payment_pending').length} value={users.filter(u => u.status === 'active').length}
</p> icon={CheckCircle}
</Card> iconBgClass="text-[var(--green-light)]"
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> dataTestId="stat-active-members"
<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" }}> <StatCard
{users.filter(u => u.status === 'inactive').length} title="Payment Pending"
</p> value={users.filter(u => u.status === 'payment_pending').length}
</Card> 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> </div>
{/* Filters */} {/* Filters */}
@@ -394,137 +409,137 @@ const AdminMembers = () => {
data-testid={`member-card-${user.id}`} data-testid={`member-card-${user.id}`}
> >
<div className="flex justify-between items-start flex-wrap gap-4"> <div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex items-start gap-4 flex-1"> <div className="flex items-start gap-4 flex-1">
{/* Avatar */} {/* 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"> <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]} {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> </div>
{/* Info */} {/* Actions */}
<div className="flex-1 min-w-0"> <div className="flex flex-col gap-3">
<div className="flex items-center gap-3 mb-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] " style={{ fontFamily: "'Inter', sans-serif" }}> <Link to={`/admin/users/${user.id}`}>
{user.first_name} {user.last_name} <Button
</h3> variant="outline"
{getStatusBadge(user.status)} size="sm"
</div> className=""
<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> <Eye className="h-4 w-4 mr-1" />
<p>Phone: {user.phone}</p> View Profile
<p>Joined: {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}</p> </Button>
{user.referred_by_member_name && ( </Link>
<p>Referred by: {user.referred_by_member_name}</p>
{/* 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> </div>
{/* Reminder Info */} {/* Status Management */}
{(() => { <div className="flex items-center gap-2">
const reminderInfo = getReminderInfo(user); <span className="text-sm text-brand-purple dark:text-brand-lavender whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
if (reminderInfo.totalReminders > 0) { Change Status:
return ( </span>
<div className="mt-4 p-3 bg-[var(--lavender-500)] rounded-lg border border-[var(--neutral-800)]"> <Select
<div className="flex items-center gap-2 mb-2"> value={user.status}
<AlertCircle className="h-4 w-4 text-[var(--orange-light)]" /> onValueChange={(newStatus) => handleStatusChangeRequest(user.id, user.status, newStatus, user)}
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> disabled={statusChanging === user.id}
{reminderInfo.totalReminders} reminder{reminderInfo.totalReminders !== 1 ? 's' : ''} sent >
{reminderInfo.totalReminders >= 3 && ( <SelectTrigger className="w-[180px] h-9 border-[var(--neutral-800)]">
<Badge className="ml-2 bg-[var(--orange-light)] text-white px-2 py-0.5 rounded-full text-xs"> <SelectValue />
Needs attention </SelectTrigger>
</Badge> <SelectContent>
)} <SelectItem value="payment_pending">Payment Pending</SelectItem>
</span> <SelectItem value="active">Active</SelectItem>
</div> <SelectItem value="inactive">Inactive</SelectItem>
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <SelectItem value="canceled">Canceled</SelectItem>
{reminderInfo.emailReminders > 0 && ( <SelectItem value="expired">Expired</SelectItem>
<p> </SelectContent>
<Mail className="inline h-3 w-3 mr-1" /> </Select>
{reminderInfo.emailReminders} email verification </div>
</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>
</div> </div>
</Card>
{/* 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>