Initial Commit

This commit is contained in:
Koncept Kit
2025-12-05 16:40:33 +07:00
parent 0834f12410
commit 94c7d5aec0
91 changed files with 20446 additions and 0 deletions

View File

@@ -0,0 +1,469 @@
import React, { useEffect, useState } from 'react';
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 {
Table,
TableHeader,
TableBody,
TableHead,
TableRow,
TableCell,
} from '../../components/ui/table';
import {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
} from '../../components/ui/pagination';
import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
const AdminApprovals = () => {
const [pendingUsers, setPendingUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState(null);
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
// Filtering state
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
// Pagination state
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage, setItemsPerPage] = useState(10);
// Sorting state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
useEffect(() => {
fetchPendingUsers();
}, []);
useEffect(() => {
filterAndSortUsers();
}, [pendingUsers, searchQuery, statusFilter, sortBy, sortOrder]);
useEffect(() => {
setCurrentPage(1);
}, [searchQuery, statusFilter]);
const fetchPendingUsers = async () => {
try {
const response = await api.get('/admin/users');
const pending = response.data.filter(user =>
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(user.status)
);
setPendingUsers(pending);
} catch (error) {
toast.error('Failed to fetch pending users');
} finally {
setLoading(false);
}
};
const filterAndSortUsers = () => {
let filtered = [...pendingUsers];
// Apply status filter
if (statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
// Apply search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query) ||
user.phone?.toLowerCase().includes(query)
);
}
// Apply sorting
filtered.sort((a, b) => {
let aVal = a[sortBy];
let bVal = b[sortBy];
if (sortBy === 'created_at') {
aVal = new Date(aVal);
bVal = new Date(bVal);
} else if (sortBy === 'first_name') {
aVal = `${a.first_name} ${a.last_name}`;
bVal = `${b.first_name} ${b.last_name}`;
}
if (sortOrder === 'asc') {
return aVal > bVal ? 1 : -1;
} else {
return aVal < bVal ? 1 : -1;
}
});
setFilteredUsers(filtered);
};
const handleApprove = async (userId) => {
setActionLoading(userId);
try {
await api.put(`/admin/users/${userId}/approve`);
toast.success('User validated and approved! Payment email sent.');
fetchPendingUsers();
} catch (error) {
toast.error('Failed to approve user');
} finally {
setActionLoading(null);
}
};
const handleBypassAndApprove = async (userId) => {
if (!window.confirm(
'This will bypass email verification and approve the user. ' +
'Are you sure you want to proceed?'
)) {
return;
}
setActionLoading(userId);
try {
await api.put(`/admin/users/${userId}/approve?bypass_email_verification=true`);
toast.success('User email verified and approved! Payment email sent.');
fetchPendingUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to approve user');
} finally {
setActionLoading(null);
}
};
const handleActivatePayment = (user) => {
setSelectedUserForPayment(user);
setPaymentDialogOpen(true);
};
const handlePaymentSuccess = () => {
fetchPendingUsers(); // Refresh list
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Awaiting Email', className: 'bg-[#F2CC8F] text-[#3D405B]' },
pending_approval: { label: 'Pending', className: 'bg-[#A3B1C6] text-white' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-[#E07A5F] text-white' }
};
const statusConfig = config[status];
return (
<Badge className={`${statusConfig.className} px-2 py-1 rounded-full text-xs`}>
{statusConfig.label}
</Badge>
);
};
const handleSort = (column) => {
if (sortBy === column) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortBy(column);
setSortOrder('asc');
}
};
// Pagination calculations
const totalPages = Math.ceil(filteredUsers.length / itemsPerPage);
const paginatedUsers = filteredUsers.slice(
(currentPage - 1) * itemsPerPage,
currentPage * itemsPerPage
);
const renderSortIcon = (column) => {
if (sortBy !== column) return null;
return sortOrder === 'asc' ?
<ArrowUp className="h-4 w-4 inline ml-1" /> :
<ArrowDown className="h-4 w-4 inline ml-1" />;
};
return (
<>
{/* Header */}
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Approval Queue
</h1>
<p className="text-lg text-[#6B708D]">
Review and approve pending membership applications.
</p>
</div>
{/* Stats Card */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div>
<p className="text-sm text-[#6B708D] mb-2">Total Pending</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Awaiting Email</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'pending_email').length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Pending Approval</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'pending_approval').length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Pre-Approved</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'pre_approved').length}
</p>
</div>
<div>
<p className="text-sm text-[#6B708D] mb-2">Payment Pending</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{pendingUsers.filter(u => u.status === 'payment_pending').length}
</p>
</div>
</div>
</Card>
{/* Filter Card */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid md:grid-cols-3 gap-4">
<div className="relative md:col-span-2">
<Search className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#6B708D]" />
<Input
placeholder="Search by name, email, or phone..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Table */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading pending applications...</p>
</div>
) : filteredUsers.length > 0 ? (
<>
<Card className="bg-white rounded-2xl border border-[#EAE0D5] overflow-hidden">
<Table>
<TableHeader>
<TableRow>
<TableHead
className="cursor-pointer hover:bg-[#F2CC8F]/20"
onClick={() => handleSort('first_name')}
>
Name {renderSortIcon('first_name')}
</TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead>
<TableHead
className="cursor-pointer hover:bg-[#F2CC8F]/20"
onClick={() => handleSort('status')}
>
Status {renderSortIcon('status')}
</TableHead>
<TableHead
className="cursor-pointer hover:bg-[#F2CC8F]/20"
onClick={() => handleSort('created_at')}
>
Registered {renderSortIcon('created_at')}
</TableHead>
<TableHead>Referred By</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.first_name} {user.last_name}
</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell>{getStatusBadge(user.status)}</TableCell>
<TableCell>
{new Date(user.created_at).toLocaleDateString()}
</TableCell>
<TableCell>
{user.referred_by_member_name || '-'}
</TableCell>
<TableCell>
<div className="flex gap-2">
{user.status === 'pending_email' ? (
<Button
onClick={() => handleBypassAndApprove(user.id)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#E07A5F] text-white hover:bg-[#D0694E]"
>
{actionLoading === user.id ? 'Approving...' : 'Bypass & Approve'}
</Button>
) : user.status === 'payment_pending' ? (
<Button
onClick={() => handleActivatePayment(user)}
size="sm"
className="bg-[#E07A5F] text-white hover:bg-[#D0694E]"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
) : (
<Button
onClick={() => handleApprove(user.id)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[#81B29A] text-white hover:bg-[#6FA087]"
>
{actionLoading === user.id ? 'Validating...' : 'Approve'}
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>
{/* Pagination Controls */}
<div className="mt-8 flex flex-col md:flex-row justify-between items-center gap-4">
{/* Page size selector */}
<div className="flex items-center gap-2">
<p className="text-sm text-[#6B708D]">Show</p>
<Select
value={itemsPerPage.toString()}
onValueChange={(val) => {
setItemsPerPage(parseInt(val));
setCurrentPage(1);
}}
>
<SelectTrigger className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="25">25</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<p className="text-sm text-[#6B708D]">
entries (showing {(currentPage - 1) * itemsPerPage + 1}-
{Math.min(currentPage * itemsPerPage, filteredUsers.length)} of {filteredUsers.length})
</p>
</div>
{/* Pagination */}
{totalPages > 1 && (
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
className={currentPage === 1 ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
{[...Array(totalPages)].map((_, i) => {
const showPage = i < 2 || i >= totalPages - 2 ||
Math.abs(i - currentPage + 1) <= 1;
if (!showPage && i === 2) {
return (
<PaginationItem key={i}>
<PaginationEllipsis />
</PaginationItem>
);
}
if (!showPage) return null;
return (
<PaginationItem key={i}>
<PaginationLink
onClick={() => setCurrentPage(i + 1)}
isActive={currentPage === i + 1}
className="cursor-pointer"
>
{i + 1}
</PaginationLink>
</PaginationItem>
);
})}
<PaginationItem>
<PaginationNext
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
className={currentPage === totalPages ? 'pointer-events-none opacity-50' : 'cursor-pointer'}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
)}
</div>
</>
) : (
<div className="text-center py-20">
<Clock className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Pending Approvals
</h3>
<p className="text-[#6B708D]">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'All applications have been reviewed!'}
</p>
</div>
)}
{/* Payment Activation Dialog */}
<PaymentActivationDialog
open={paymentDialogOpen}
onOpenChange={setPaymentDialogOpen}
user={selectedUserForPayment}
onSuccess={handlePaymentSuccess}
/>
</>
);
};
export default AdminApprovals;

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { Users, Calendar, Clock, CheckCircle } from 'lucide-react';
const AdminDashboard = () => {
const [stats, setStats] = useState({
totalMembers: 0,
pendingApprovals: 0,
activeMembers: 0
});
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboardStats();
}, []);
const fetchDashboardStats = async () => {
try {
const usersResponse = await api.get('/admin/users');
const users = usersResponse.data;
setStats({
totalMembers: users.filter(u => u.role === 'member').length,
pendingApprovals: users.filter(u =>
['pending_email', 'pending_approval', 'pre_approved', 'payment_pending'].includes(u.status)
).length,
activeMembers: users.filter(u => u.status === 'active' && u.role === 'member').length
});
} catch (error) {
console.error('Failed to fetch stats:', error);
} finally {
setLoading(false);
}
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Admin Dashboard
</h1>
<p className="text-lg text-[#6B708D]">
Manage users, events, and membership applications.
</p>
</div>
{/* Stats Grid */}
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="stat-total-users">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#A3B1C6]/20 p-3 rounded-lg">
<Users className="h-6 w-6 text-[#A3B1C6]" />
</div>
</div>
<p className="text-3xl font-semibold fraunces text-[#3D405B] mb-1">
{loading ? '-' : stats.totalMembers}
</p>
<p className="text-sm text-[#6B708D]">Total Members</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="stat-pending-approvals">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#F2CC8F]/20 p-3 rounded-lg">
<Clock className="h-6 w-6 text-[#E07A5F]" />
</div>
</div>
<p className="text-3xl font-semibold fraunces text-[#3D405B] mb-1">
{loading ? '-' : stats.pendingApprovals}
</p>
<p className="text-sm text-[#6B708D]">Pending Approvals</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]" data-testid="stat-active-members">
<div className="flex items-center justify-between mb-4">
<div className="bg-[#81B29A]/20 p-3 rounded-lg">
<CheckCircle className="h-6 w-6 text-[#81B29A]" />
</div>
</div>
<p className="text-3xl font-semibold fraunces text-[#3D405B] mb-1">
{loading ? '-' : stats.activeMembers}
</p>
<p className="text-sm text-[#6B708D]">Active Members</p>
</Card>
</div>
{/* Quick Actions */}
<div className="grid md:grid-cols-2 gap-8">
<Link to="/admin/users">
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-users">
<Users className="h-12 w-12 text-[#E07A5F] mb-4" />
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-2">
Manage Users
</h3>
<p className="text-[#6B708D]">
View and manage all registered users and their membership status.
</p>
<Button
className="mt-4 bg-[#F2CC8F] text-[#3D405B] hover:bg-[#E5B875] rounded-full"
data-testid="manage-users-button"
>
Go to Users
</Button>
</Card>
</Link>
<Link to="/admin/approvals">
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer" data-testid="quick-action-approvals">
<Clock className="h-12 w-12 text-[#E07A5F] mb-4" />
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-2">
Approval Queue
</h3>
<p className="text-[#6B708D]">
Review and approve pending membership applications.
</p>
<Button
className="mt-4 bg-[#F2CC8F] text-[#3D405B] hover:bg-[#E5B875] rounded-full"
data-testid="manage-approvals-button"
>
View Approvals
</Button>
</Card>
</Link>
</div>
</>
);
};
export default AdminDashboard;

View File

@@ -0,0 +1,430 @@
import React, { useEffect, useState } from 'react';
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../components/ui/dialog';
import { toast } from 'sonner';
import { Calendar, MapPin, Users, Plus, Edit, Trash2, Eye, EyeOff } from 'lucide-react';
import { AttendanceDialog } from '../../components/AttendanceDialog';
const AdminEvents = () => {
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [editingEvent, setEditingEvent] = useState(null);
const [attendanceDialogOpen, setAttendanceDialogOpen] = useState(false);
const [selectedEvent, setSelectedEvent] = useState(null);
const [formData, setFormData] = useState({
title: '',
description: '',
start_at: '',
end_at: '',
location: '',
capacity: '',
published: false
});
useEffect(() => {
fetchEvents();
}, []);
const fetchEvents = async () => {
try {
const response = await api.get('/admin/events');
setEvents(response.data);
} catch (error) {
toast.error('Failed to fetch events');
} finally {
setLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
try {
const eventData = {
...formData,
capacity: formData.capacity ? parseInt(formData.capacity) : null,
start_at: new Date(formData.start_at).toISOString(),
end_at: new Date(formData.end_at).toISOString()
};
if (editingEvent) {
await api.put(`/admin/events/${editingEvent.id}`, eventData);
toast.success('Event updated successfully');
} else {
await api.post('/admin/events', eventData);
toast.success('Event created successfully');
}
setIsCreateDialogOpen(false);
setEditingEvent(null);
resetForm();
fetchEvents();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save event');
}
};
const handleEdit = (event) => {
setEditingEvent(event);
setFormData({
title: event.title,
description: event.description || '',
start_at: new Date(event.start_at).toISOString().slice(0, 16),
end_at: new Date(event.end_at).toISOString().slice(0, 16),
location: event.location,
capacity: event.capacity || '',
published: event.published
});
setIsCreateDialogOpen(true);
};
const handleDelete = async (eventId) => {
if (!window.confirm('Are you sure you want to delete this event?')) {
return;
}
try {
await api.delete(`/admin/events/${eventId}`);
toast.success('Event deleted successfully');
fetchEvents();
} catch (error) {
toast.error('Failed to delete event');
}
};
const togglePublish = async (event) => {
try {
await api.put(`/admin/events/${event.id}`, {
published: !event.published
});
toast.success(event.published ? 'Event unpublished' : 'Event published');
fetchEvents();
} catch (error) {
toast.error('Failed to update event');
}
};
const resetForm = () => {
setFormData({
title: '',
description: '',
start_at: '',
end_at: '',
location: '',
capacity: '',
published: false
});
};
const handleDialogClose = () => {
setIsCreateDialogOpen(false);
setEditingEvent(null);
resetForm();
};
return (
<>
{/* Header */}
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Event Management
</h1>
<p className="text-lg text-[#6B708D]">
Create and manage community events.
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button
onClick={() => {
resetForm();
setEditingEvent(null);
}}
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-6"
data-testid="create-event-button"
>
<Plus className="mr-2 h-5 w-5" />
Create Event
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-2xl fraunces text-[#3D405B]">
{editingEvent ? 'Edit Event' : 'Create New Event'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Title *
</label>
<Input
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Description
</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={4}
className="w-full border-2 border-[#EAE0D5] focus:border-[#E07A5F] rounded-lg p-3"
/>
</div>
<div className="grid md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Start Date & Time *
</label>
<Input
type="datetime-local"
value={formData.start_at}
onChange={(e) => setFormData({ ...formData, start_at: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
End Date & Time *
</label>
<Input
type="datetime-local"
value={formData.end_at}
onChange={(e) => setFormData({ ...formData, end_at: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Location *
</label>
<Input
value={formData.location}
onChange={(e) => setFormData({ ...formData, location: e.target.value })}
required
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div>
<label className="block text-sm font-medium text-[#3D405B] mb-2">
Capacity (optional)
</label>
<Input
type="number"
value={formData.capacity}
onChange={(e) => setFormData({ ...formData, capacity: e.target.value })}
placeholder="Leave empty for unlimited"
className="border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="published"
checked={formData.published}
onChange={(e) => setFormData({ ...formData, published: e.target.checked })}
className="w-4 h-4 text-[#E07A5F] border-[#EAE0D5] rounded focus:ring-[#E07A5F]"
/>
<label htmlFor="published" className="text-sm font-medium text-[#3D405B]">
Publish event (make visible to members)
</label>
</div>
<div className="flex gap-3 pt-4">
<Button
type="submit"
className="flex-1 bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full"
>
{editingEvent ? 'Update Event' : 'Create Event'}
</Button>
<Button
type="button"
variant="outline"
onClick={handleDialogClose}
className="flex-1 border-2 border-[#6B708D] text-[#6B708D] hover:bg-[#6B708D] hover:text-white rounded-full"
>
Cancel
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
{/* Events List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading events...</p>
</div>
) : events.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{events.map((event) => (
<Card
key={event.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-lg transition-all"
data-testid={`event-card-${event.id}`}
>
{/* Event Header */}
<div className="flex justify-between items-start mb-4">
<div className="bg-[#F2CC8F]/20 p-3 rounded-lg">
<Calendar className="h-6 w-6 text-[#E07A5F]" />
</div>
<Badge
className={`${
event.published
? 'bg-[#81B29A] text-white'
: 'bg-[#6B708D] text-white'
} px-3 py-1 rounded-full`}
>
{event.published ? 'Published' : 'Draft'}
</Badge>
</div>
{/* Event Details */}
<h3 className="text-xl font-semibold fraunces text-[#3D405B] mb-3">
{event.title}
</h3>
{event.description && (
<p className="text-[#6B708D] mb-4 line-clamp-2 text-sm">
{event.description}
</p>
)}
<div className="space-y-2 mb-4">
<div className="flex items-center gap-2 text-sm text-[#6B708D]">
<Calendar className="h-4 w-4" />
<span>
{new Date(event.start_at).toLocaleDateString()} at{' '}
{new Date(event.start_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit'
})}
</span>
</div>
<div className="flex items-center gap-2 text-sm text-[#6B708D]">
<MapPin className="h-4 w-4" />
<span className="truncate">{event.location}</span>
</div>
<div className="flex items-center gap-2 text-sm text-[#6B708D]">
<Users className="h-4 w-4" />
<span>{event.rsvp_count || 0} attending</span>
</div>
</div>
{/* Actions */}
<div className="space-y-2 pt-4 border-t border-[#EAE0D5]">
{/* Mark Attendance Button */}
<Button
onClick={() => {
setSelectedEvent(event);
setAttendanceDialogOpen(true);
}}
variant="outline"
size="sm"
className="w-full border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
data-testid={`mark-attendance-${event.id}`}
>
<Users className="h-4 w-4 mr-2" />
Mark Attendance ({event.rsvp_count || 0} RSVPs)
</Button>
{/* Other Actions */}
<div className="flex gap-2">
<Button
onClick={() => togglePublish(event)}
variant="outline"
size="sm"
className="flex-1 border-[#E07A5F] text-[#E07A5F] hover:bg-[#E07A5F] hover:text-white"
data-testid={`toggle-publish-${event.id}`}
>
{event.published ? (
<>
<EyeOff className="h-4 w-4 mr-1" />
Unpublish
</>
) : (
<>
<Eye className="h-4 w-4 mr-1" />
Publish
</>
)}
</Button>
<Button
onClick={() => handleEdit(event)}
variant="outline"
size="sm"
className="border-[#6B708D] text-[#6B708D] hover:bg-[#6B708D] hover:text-white"
data-testid={`edit-event-${event.id}`}
>
<Edit className="h-4 w-4" />
</Button>
<Button
onClick={() => handleDelete(event.id)}
variant="outline"
size="sm"
className="border-red-500 text-red-500 hover:bg-red-500 hover:text-white"
data-testid={`delete-event-${event.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<Calendar className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Events Yet
</h3>
<p className="text-[#6B708D] mb-6">
Create your first event to get started!
</p>
<Button
onClick={() => setIsCreateDialogOpen(true)}
className="bg-[#E07A5F] text-white hover:bg-[#D0694E] rounded-full px-8"
>
<Plus className="mr-2 h-5 w-5" />
Create Event
</Button>
</div>
)}
{/* Attendance Dialog */}
<AttendanceDialog
event={selectedEvent}
open={attendanceDialogOpen}
onOpenChange={setAttendanceDialogOpen}
onSuccess={fetchEvents}
/>
</>
);
};
export default AdminEvents;

View File

@@ -0,0 +1,294 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
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 { toast } from 'sonner';
import { Users, Search, User, CreditCard, Eye, CheckCircle } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog';
const AdminMembers = () => {
const navigate = useNavigate();
const location = useLocation();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('active');
const [paymentDialogOpen, setPaymentDialogOpen] = useState(false);
const [selectedUserForPayment, setSelectedUserForPayment] = useState(null);
const tabs = [
{ name: 'All Users', path: '/admin/users' },
{ name: 'Staff', path: '/admin/staff' },
{ name: 'Members', path: '/admin/members' }
];
useEffect(() => {
fetchMembers();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, statusFilter]);
const fetchMembers = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only members
const members = response.data.filter(user => user.role === 'member');
setUsers(members);
} catch (error) {
toast.error('Failed to fetch members');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (statusFilter && statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const handleActivatePayment = (user) => {
setSelectedUserForPayment(user);
setPaymentDialogOpen(true);
};
const handlePaymentSuccess = () => {
fetchMembers(); // Refresh list
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Pending Email', className: 'bg-[#F2CC8F] text-[#3D405B]' },
pending_approval: { label: 'Pending Approval', className: 'bg-[#A3B1C6] text-white' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-[#E07A5F] text-white' },
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-[#6B708D] text-white' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Members Management
</h1>
<p className="text-lg text-[#6B708D]">
Manage paying members and their subscriptions.
</p>
</div>
{/* Tab Navigation */}
<div className="border-b border-[#EAE0D5] mb-8">
<nav className="flex gap-8">
{tabs.map((tab) => (
<button
key={tab.path}
onClick={() => navigate(tab.path)}
className={`
pb-4 px-2 font-medium transition-colors relative
${location.pathname === tab.path
? 'text-[#E07A5F]'
: 'text-[#6B708D] hover:text-[#3D405B]'
}
`}
>
{tab.name}
{location.pathname === tab.path && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#E07A5F]" />
)}
</button>
))}
</nav>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Members</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Active</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'active').length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Payment Pending</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'payment_pending').length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Inactive</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'inactive').length}
</p>
</Card>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid 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-[#6B708D]" />
<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-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="search-members-input"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]" 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_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Members List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading members...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-md transition-shadow"
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-[#F2CC8F] flex items-center justify-center text-[#3D405B] 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 fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h3>
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#6B708D]">
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.referred_by_member_name && (
<p>Referred by: {user.referred_by_member_name}</p>
)}
</div>
</div>
</div>
{/* Actions */}
<div className="flex gap-2">
<Link to={`/admin/users/${user.id}`}>
<Button
variant="outline"
size="sm"
className="border-[#A3B1C6] text-[#A3B1C6] hover:bg-[#A3B1C6] 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-[#E07A5F] text-white hover:bg-[#D0694E]"
>
<CheckCircle className="h-4 w-4 mr-1" />
Activate Payment
</Button>
)}
{/* 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"
>
<CreditCard className="h-4 w-4 mr-1" />
Subscription
</Button>
)}
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<Users className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Members Found
</h3>
<p className="text-[#6B708D]">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'No members yet'}
</p>
</div>
)}
{/* Payment Activation Dialog */}
<PaymentActivationDialog
open={paymentDialogOpen}
onOpenChange={setPaymentDialogOpen}
user={selectedUserForPayment}
onSuccess={handlePaymentSuccess}
/>
</>
);
};
export default AdminMembers;

View File

@@ -0,0 +1,365 @@
import React, { useEffect, useState } from 'react';
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 PlanDialog from '../../components/PlanDialog';
import { toast } from 'sonner';
import {
CreditCard,
Plus,
Edit,
Trash2,
Users,
Search,
DollarSign
} from 'lucide-react';
const AdminPlans = () => {
const [plans, setPlans] = useState([]);
const [filteredPlans, setFilteredPlans] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState('all');
const [planDialogOpen, setPlanDialogOpen] = useState(false);
const [selectedPlan, setSelectedPlan] = useState(null);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [planToDelete, setPlanToDelete] = useState(null);
useEffect(() => {
fetchPlans();
}, []);
useEffect(() => {
filterPlans();
}, [plans, searchQuery, activeFilter]);
const fetchPlans = async () => {
try {
const response = await api.get('/admin/subscriptions/plans');
setPlans(response.data);
} catch (error) {
toast.error('Failed to fetch plans');
} finally {
setLoading(false);
}
};
const filterPlans = () => {
let filtered = plans;
if (activeFilter !== 'all') {
filtered = filtered.filter(plan =>
activeFilter === 'active' ? plan.active : !plan.active
);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(plan =>
plan.name.toLowerCase().includes(query) ||
plan.description?.toLowerCase().includes(query)
);
}
setFilteredPlans(filtered);
};
const handleCreatePlan = () => {
setSelectedPlan(null);
setPlanDialogOpen(true);
};
const handleEditPlan = (plan) => {
setSelectedPlan(plan);
setPlanDialogOpen(true);
};
const handleDeleteClick = (plan) => {
setPlanToDelete(plan);
setDeleteDialogOpen(true);
};
const handleDeleteConfirm = async () => {
try {
await api.delete(`/admin/subscriptions/plans/${planToDelete.id}`);
toast.success('Plan deleted successfully');
fetchPlans();
setDeleteDialogOpen(false);
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete plan');
}
};
const formatPrice = (cents) => {
return `$${(cents / 100).toFixed(2)}`;
};
const getBillingCycleLabel = (cycle) => {
const labels = {
monthly: 'Monthly',
quarterly: 'Quarterly',
yearly: 'Yearly',
lifetime: 'Lifetime'
};
return labels[cycle] || cycle;
};
return (
<>
<div className="mb-8">
<div className="flex justify-between items-start mb-4">
<div>
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Subscription Plans
</h1>
<p className="text-lg text-[#6B708D]">
Manage membership plans and pricing.
</p>
</div>
<Button
onClick={handleCreatePlan}
className="bg-[#E07A5F] hover:bg-[#D0694E] text-white rounded-full px-6"
>
<Plus className="h-4 w-4 mr-2" />
Create Plan
</Button>
</div>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Plans</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{plans.length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Active Plans</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{plans.filter(p => p.active).length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Subscribers</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{plans.reduce((sum, p) => sum + (p.subscriber_count || 0), 0)}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Revenue (Annual Est.)</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{formatPrice(
plans.reduce((sum, p) => {
const annualPrice = p.billing_cycle === 'yearly'
? p.price_cents
: p.price_cents * 12;
return sum + (annualPrice * (p.subscriber_count || 0));
}, 0)
)}
</p>
</Card>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid 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-[#6B708D]" />
<Input
placeholder="Search plans..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-12 h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
/>
</div>
<Select value={activeFilter} onValueChange={setActiveFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Plans</SelectItem>
<SelectItem value="active">Active Only</SelectItem>
<SelectItem value="inactive">Inactive Only</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Plans Grid */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading plans...</p>
</div>
) : filteredPlans.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredPlans.map((plan) => (
<Card
key={plan.id}
className={`p-6 bg-white rounded-2xl border-2 transition-all hover:shadow-lg ${
plan.active
? 'border-[#EAE0D5] hover:border-[#E07A5F]'
: 'border-[#6B708D] opacity-60'
}`}
>
{/* Header with badges */}
<div className="flex justify-between items-start mb-4">
<Badge
className={`${
plan.active
? 'bg-[#81B29A] text-white'
: 'bg-[#6B708D] text-white'
}`}
>
{plan.active ? 'Active' : 'Inactive'}
</Badge>
{plan.subscriber_count > 0 && (
<Badge className="bg-[#F2CC8F] text-[#3D405B]">
<Users className="h-3 w-3 mr-1" />
{plan.subscriber_count}
</Badge>
)}
</div>
{/* Plan Name */}
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-2">
{plan.name}
</h3>
{/* Description */}
{plan.description && (
<p className="text-sm text-[#6B708D] mb-4 line-clamp-2">
{plan.description}
</p>
)}
{/* Price */}
<div className="mb-4">
<div className="text-3xl font-bold fraunces text-[#E07A5F]">
{formatPrice(plan.price_cents)}
</div>
<p className="text-sm text-[#6B708D]">
{getBillingCycleLabel(plan.billing_cycle)}
</p>
</div>
{/* Stripe Integration Status */}
<div className="mb-6">
{plan.stripe_price_id ? (
<Badge className="bg-[#81B29A] text-white text-xs">
<DollarSign className="h-3 w-3 mr-1" />
Stripe Integrated
</Badge>
) : (
<Badge className="bg-[#F2CC8F] text-[#3D405B] text-xs">
Manual/Test Plan
</Badge>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-4 border-t border-[#EAE0D5]">
<Button
onClick={() => handleEditPlan(plan)}
variant="outline"
size="sm"
className="flex-1 border-[#A3B1C6] text-[#A3B1C6] hover:bg-[#A3B1C6] hover:text-white rounded-full"
>
<Edit className="h-4 w-4 mr-1" />
Edit
</Button>
<Button
onClick={() => handleDeleteClick(plan)}
variant="outline"
size="sm"
className="flex-1 border-[#E07A5F] text-[#E07A5F] hover:bg-[#E07A5F] hover:text-white rounded-full"
disabled={plan.subscriber_count > 0}
>
<Trash2 className="h-4 w-4 mr-1" />
Delete
</Button>
</div>
{/* Warning for plans with subscribers */}
{plan.subscriber_count > 0 && (
<p className="text-xs text-[#6B708D] mt-2 text-center">
Cannot delete plan with active subscribers
</p>
)}
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<CreditCard className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Plans Found
</h3>
<p className="text-[#6B708D] mb-6">
{searchQuery || activeFilter !== 'all'
? 'Try adjusting your filters'
: 'Create your first subscription plan to get started'}
</p>
{!searchQuery && activeFilter === 'all' && (
<Button
onClick={handleCreatePlan}
className="bg-[#E07A5F] hover:bg-[#D0694E] text-white rounded-full px-8"
>
<Plus className="h-4 w-4 mr-2" />
Create First Plan
</Button>
)}
</div>
)}
{/* Plan Dialog */}
<PlanDialog
open={planDialogOpen}
onOpenChange={setPlanDialogOpen}
plan={selectedPlan}
onSuccess={fetchPlans}
/>
{/* Delete Confirmation Dialog */}
{deleteDialogOpen && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="p-8 bg-white rounded-2xl max-w-md mx-4">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
Delete Plan
</h2>
<p className="text-[#6B708D] mb-6">
Are you sure you want to delete "{planToDelete?.name}"? This action
will deactivate the plan and it won't be available for new subscriptions.
</p>
<div className="flex gap-4">
<Button
onClick={() => setDeleteDialogOpen(false)}
variant="outline"
className="flex-1"
>
Cancel
</Button>
<Button
onClick={handleDeleteConfirm}
className="flex-1 bg-[#E07A5F] hover:bg-[#D0694E] text-white"
>
Delete Plan
</Button>
</div>
</Card>
</div>
)}
</>
);
};
export default AdminPlans;

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState } from 'react';
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 { toast } from 'sonner';
import { UserCog, Search, Shield } from 'lucide-react';
const AdminStaff = () => {
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [roleFilter, setRoleFilter] = useState('all');
// Staff roles (non-guest, non-member)
const STAFF_ROLES = ['admin'];
useEffect(() => {
fetchStaff();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, roleFilter]);
const fetchStaff = async () => {
try {
const response = await api.get('/admin/users');
// Filter to only staff roles
const staffUsers = response.data.filter(user =>
STAFF_ROLES.includes(user.role)
);
setUsers(staffUsers);
} catch (error) {
toast.error('Failed to fetch staff');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (roleFilter && roleFilter !== 'all') {
filtered = filtered.filter(user => user.role === roleFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const getRoleBadge = (role) => {
const config = {
superadmin: { label: 'Superadmin', className: 'bg-[#E07A5F] text-white' },
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
moderator: { label: 'Moderator', className: 'bg-[#A3B1C6] text-white' },
staff: { label: 'Staff', className: 'bg-[#F2CC8F] text-[#3D405B]' },
media: { label: 'Media', className: 'bg-[#6B708D] text-white' }
};
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
return (
<Badge className={`${roleConfig.className} px-3 py-1 rounded-full text-sm`}>
<Shield className="h-3 w-3 mr-1 inline" />
{roleConfig.label}
</Badge>
);
};
const getStatusBadge = (status) => {
const config = {
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-[#6B708D] text-white' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
Staff Management
</h1>
<p className="text-lg text-[#6B708D]">
Manage internal team members and their roles.
</p>
</div>
{/* Stats */}
<div className="grid md:grid-cols-4 gap-4 mb-8">
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Total Staff</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Admins</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => ['admin', 'superadmin'].includes(u.role)).length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Moderators</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.role === 'moderator').length}
</p>
</Card>
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5]">
<p className="text-sm text-[#6B708D] mb-2">Active</p>
<p className="text-3xl font-semibold fraunces text-[#3D405B]">
{users.filter(u => u.status === 'active').length}
</p>
</Card>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid 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-[#6B708D]" />
<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-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="search-staff-input"
/>
</div>
<Select value={roleFilter} onValueChange={setRoleFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]" data-testid="role-filter-select">
<SelectValue placeholder="Filter by role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Roles</SelectItem>
<SelectItem value="superadmin">Superadmin</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
<SelectItem value="moderator">Moderator</SelectItem>
<SelectItem value="staff">Staff</SelectItem>
<SelectItem value="media">Media</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Staff List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading staff...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-md transition-shadow"
data-testid={`staff-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-[#F2CC8F] flex items-center justify-center text-[#3D405B] 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 fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h3>
{getRoleBadge(user.role)}
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#6B708D]">
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.last_login && (
<p>Last Login: {new Date(user.last_login).toLocaleDateString()}</p>
)}
</div>
</div>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<UserCog className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Staff Found
</h3>
<p className="text-[#6B708D]">
{searchQuery || roleFilter !== 'all'
? 'Try adjusting your filters'
: 'No staff members yet'}
</p>
</div>
)}
</>
);
};
export default AdminStaff;

View File

@@ -0,0 +1,151 @@
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import api from '../../utils/api';
import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Badge } from '../../components/ui/badge';
import { ArrowLeft, Mail, Phone, MapPin, Calendar } from 'lucide-react';
import { toast } from 'sonner';
const AdminUserView = () => {
const { userId } = useParams();
const navigate = useNavigate();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchUserProfile();
}, [userId]);
const fetchUserProfile = async () => {
try {
const response = await api.get(`/admin/users/${userId}`);
setUser(response.data);
} catch (error) {
toast.error('Failed to load user profile');
navigate('/admin/members');
} finally {
setLoading(false);
}
};
if (loading) return <div>Loading...</div>;
if (!user) return null;
return (
<>
{/* Back Button */}
<Button
variant="ghost"
onClick={() => navigate(-1)}
className="mb-6"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
{/* User Profile Header */}
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="flex items-start gap-6">
{/* Avatar */}
<div className="h-24 w-24 rounded-full bg-[#F2CC8F] flex items-center justify-center text-[#3D405B] font-semibold text-3xl">
{user.first_name?.[0]}{user.last_name?.[0]}
</div>
{/* User Info */}
<div className="flex-1">
<div className="flex items-center gap-4 mb-4">
<h1 className="text-3xl font-semibold fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h1>
{/* Status & Role Badges */}
<Badge>{user.status}</Badge>
<Badge>{user.role}</Badge>
</div>
{/* Contact Info */}
<div className="grid md:grid-cols-2 gap-4 text-[#6B708D]">
<div className="flex items-center gap-2">
<Mail className="h-4 w-4" />
<span>{user.email}</span>
</div>
<div className="flex items-center gap-2">
<Phone className="h-4 w-4" />
<span>{user.phone}</span>
</div>
<div className="flex items-center gap-2">
<MapPin className="h-4 w-4" />
<span>{user.city}, {user.state} {user.zipcode}</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4" />
<span>Joined {new Date(user.created_at).toLocaleDateString()}</span>
</div>
</div>
</div>
</div>
</Card>
{/* Additional Details */}
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5]">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6">
Additional Information
</h2>
<div className="grid md:grid-cols-2 gap-6">
<div>
<label className="text-sm font-medium text-[#6B708D]">Address</label>
<p className="text-[#3D405B] mt-1">{user.address}</p>
</div>
<div>
<label className="text-sm font-medium text-[#6B708D]">Date of Birth</label>
<p className="text-[#3D405B] mt-1">
{new Date(user.date_of_birth).toLocaleDateString()}
</p>
</div>
{user.partner_first_name && (
<div>
<label className="text-sm font-medium text-[#6B708D]">Partner</label>
<p className="text-[#3D405B] mt-1">
{user.partner_first_name} {user.partner_last_name}
</p>
</div>
)}
{user.referred_by_member_name && (
<div>
<label className="text-sm font-medium text-[#6B708D]">Referred By</label>
<p className="text-[#3D405B] mt-1">{user.referred_by_member_name}</p>
</div>
)}
{user.lead_sources && user.lead_sources.length > 0 && (
<div className="md:col-span-2">
<label className="text-sm font-medium text-[#6B708D]">Lead Sources</label>
<div className="flex flex-wrap gap-2 mt-2">
{user.lead_sources.map((source, idx) => (
<Badge key={idx} variant="outline">{source}</Badge>
))}
</div>
</div>
)}
</div>
</Card>
{/* Subscription Info (if applicable) */}
{user.role === 'member' && (
<Card className="p-8 bg-white rounded-2xl border border-[#EAE0D5] mt-8">
<h2 className="text-2xl font-semibold fraunces text-[#3D405B] mb-6">
Subscription Information
</h2>
{/* TODO: Fetch and display subscription data */}
<p className="text-[#6B708D]">Subscription details coming soon...</p>
</Card>
)}
</>
);
};
export default AdminUserView;

View File

@@ -0,0 +1,200 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
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 { toast } from 'sonner';
import { Users, Search, CheckCircle, Clock } from 'lucide-react';
const AdminUsers = () => {
const navigate = useNavigate();
const location = useLocation();
const [users, setUsers] = useState([]);
const [filteredUsers, setFilteredUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const tabs = [
{ name: 'All Users', path: '/admin/users' },
{ name: 'Staff', path: '/admin/staff' },
{ name: 'Members', path: '/admin/members' }
];
useEffect(() => {
fetchUsers();
}, []);
useEffect(() => {
filterUsers();
}, [users, searchQuery, statusFilter]);
const fetchUsers = async () => {
try {
const response = await api.get('/admin/users');
setUsers(response.data);
} catch (error) {
toast.error('Failed to fetch users');
} finally {
setLoading(false);
}
};
const filterUsers = () => {
let filtered = users;
if (statusFilter && statusFilter !== 'all') {
filtered = filtered.filter(user => user.status === statusFilter);
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(user =>
user.first_name.toLowerCase().includes(query) ||
user.last_name.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query)
);
}
setFilteredUsers(filtered);
};
const getStatusBadge = (status) => {
const config = {
pending_email: { label: 'Pending Email', className: 'bg-[#F2CC8F] text-[#3D405B]' },
pending_approval: { label: 'Pending Approval', className: 'bg-[#A3B1C6] text-white' },
pre_approved: { label: 'Pre-Approved', className: 'bg-[#81B29A] text-white' },
payment_pending: { label: 'Payment Pending', className: 'bg-[#E07A5F] text-white' },
active: { label: 'Active', className: 'bg-[#81B29A] text-white' },
inactive: { label: 'Inactive', className: 'bg-[#6B708D] text-white' }
};
const statusConfig = config[status] || config.inactive;
return (
<Badge className={`${statusConfig.className} px-3 py-1 rounded-full text-sm`}>
{statusConfig.label}
</Badge>
);
};
return (
<>
<div className="mb-8">
<h1 className="text-4xl md:text-5xl font-semibold fraunces text-[#3D405B] mb-4">
User Management
</h1>
<p className="text-lg text-[#6B708D]">
View and manage all registered users.
</p>
</div>
{/* Tab Navigation */}
<div className="border-b border-[#EAE0D5] mb-8">
<nav className="flex gap-8">
{tabs.map((tab) => (
<button
key={tab.path}
onClick={() => navigate(tab.path)}
className={`
pb-4 px-2 font-medium transition-colors relative
${location.pathname === tab.path
? 'text-[#E07A5F]'
: 'text-[#6B708D] hover:text-[#3D405B]'
}
`}
>
{tab.name}
{location.pathname === tab.path && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-[#E07A5F]" />
)}
</button>
))}
</nav>
</div>
{/* Filters */}
<Card className="p-6 bg-white rounded-2xl border border-[#EAE0D5] mb-8">
<div className="grid 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-[#6B708D]" />
<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-[#EAE0D5] focus:border-[#E07A5F]"
data-testid="search-users-input"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-14 rounded-xl border-2 border-[#EAE0D5]" data-testid="status-filter-select">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Pending Email</SelectItem>
<SelectItem value="pending_approval">Pending Approval</SelectItem>
<SelectItem value="pre_approved">Pre-Approved</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="active">Active</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem>
</SelectContent>
</Select>
</div>
</Card>
{/* Users List */}
{loading ? (
<div className="text-center py-20">
<p className="text-[#6B708D]">Loading users...</p>
</div>
) : filteredUsers.length > 0 ? (
<div className="space-y-4">
{filteredUsers.map((user) => (
<Card
key={user.id}
className="p-6 bg-white rounded-2xl border border-[#EAE0D5] hover:shadow-md transition-shadow"
data-testid={`user-card-${user.id}`}
>
<div className="flex justify-between items-start flex-wrap gap-4">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h3 className="text-xl font-semibold fraunces text-[#3D405B]">
{user.first_name} {user.last_name}
</h3>
{getStatusBadge(user.status)}
</div>
<div className="grid md:grid-cols-2 gap-2 text-sm text-[#6B708D]">
<p>Email: {user.email}</p>
<p>Phone: {user.phone}</p>
<p>Role: <span className="capitalize">{user.role}</span></p>
<p>Joined: {new Date(user.created_at).toLocaleDateString()}</p>
{user.referred_by_member_name && (
<p className="col-span-2">Referred by: {user.referred_by_member_name}</p>
)}
</div>
</div>
</div>
</Card>
))}
</div>
) : (
<div className="text-center py-20">
<Users className="h-20 w-20 text-[#EAE0D5] mx-auto mb-6" />
<h3 className="text-2xl font-semibold fraunces text-[#3D405B] mb-4">
No Users Found
</h3>
<p className="text-[#6B708D]">
{searchQuery || statusFilter !== 'all'
? 'Try adjusting your filters'
: 'No users registered yet'}
</p>
</div>
)}
</>
);
};
export default AdminUsers;