keeai / frontend /src /pages /AdminMembers.jsx
Seth0330's picture
Update frontend/src/pages/AdminMembers.jsx
31cd05d verified
// frontend/src/pages/AdminMembers.jsx
import React, { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
LayoutDashboard,
Users2,
FileText,
Trophy,
ShoppingBag,
Menu,
ArrowLeft,
Search,
Users,
ChevronDown,
ChevronRight,
Edit2,
Trash2,
Mail,
Phone,
Calendar,
CreditCard,
AlertCircle,
CheckCircle2,
Clock,
User,
X,
} from "lucide-react";
import api from "../api/client";
import { differenceInDays, format } from "date-fns";
import UserMenu from "../components/UserMenu";
import dojoLogo from "../assets/dojo-logo.png";
export default function AdminMembers() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [mobileNavOpen, setMobileNavOpen] = useState(false);
const [activeTab, setActiveTab] = useState("all");
const [searchQuery, setSearchQuery] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [expandedMembership, setExpandedMembership] = useState(null);
const [editingMembership, setEditingMembership] = useState(null);
const [confirmingRequest, setConfirmingRequest] = useState(null); // { id, action: 'approve' | 'reject' }
const [rejectNotes, setRejectNotes] = useState({}); // { requestId: notes }
const [requestMessage, setRequestMessage] = useState(null); // { type: 'success' | 'error', text: string, requestId: number }
const storedAdmin = JSON.parse(sessionStorage.getItem("admin") || "{}");
const adminName = storedAdmin.name || "Admin";
const adminEmail = storedAdmin.email || "admin@dojo.com";
function handleAdminLogout() {
sessionStorage.removeItem("admin");
navigate("/login");
}
// Fetch data
const { data: membershipsData, isLoading: membershipsLoading } = useQuery({
queryKey: ["admin-memberships"],
queryFn: async () => {
const res = await api.get("/admin/memberships");
return res.data?.items || res.data || [];
},
initialData: [],
});
const { data: students = [], isLoading: studentsLoading } = useQuery({
queryKey: ["admin-students"],
queryFn: async () => {
const res = await api.get("/admin/students");
return res.data || [];
},
initialData: [],
});
const { data: invites = [] } = useQuery({
queryKey: ["admin-invites"],
queryFn: async () => {
const res = await api.get("/admin/invites");
return res.data || [];
},
initialData: [],
});
const { data: requests = [] } = useQuery({
queryKey: ["admin-requests"],
queryFn: async () => {
const res = await api.get("/admin/requests");
return res.data || [];
},
initialData: [],
});
// Mutations
const updateMembershipMutation = useMutation({
mutationFn: async ({ id, data }) => {
const res = await api.put(`/admin/memberships/${id}`, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-memberships"] });
setEditingMembership(null);
},
});
const deleteMembershipMutation = useMutation({
mutationFn: async (id) => {
await api.delete(`/admin/memberships/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-memberships"] });
},
});
const updateStudentMutation = useMutation({
mutationFn: async ({ id, data }) => {
const res = await api.put(`/admin/students/${id}`, data);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-students"] });
setEditingStudent(null);
},
});
const deleteStudentMutation = useMutation({
mutationFn: async (id) => {
await api.delete(`/admin/students/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-students"] });
},
});
const deleteInviteMutation = useMutation({
mutationFn: async (id) => {
await api.delete(`/admin/invites/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-invites"] });
},
});
const approveRequestMutation = useMutation({
mutationFn: async ({ requestId, notes }) => {
const res = await api.post(`/admin/requests/${requestId}/approve`, {
approved_by: adminName,
notes: notes || "",
});
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-requests"] });
queryClient.invalidateQueries({ queryKey: ["admin-memberships"] });
},
});
const rejectRequestMutation = useMutation({
mutationFn: async ({ requestId, notes }) => {
const res = await api.post(`/admin/requests/${requestId}/reject`, {
rejected_by: adminName,
notes: notes || "",
});
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["admin-requests"] });
},
});
const memberships = Array.isArray(membershipsData) ? membershipsData : [];
const getStudentsForMembership = (membershipId) => {
return students.filter((s) => s.membership_id === membershipId);
};
// Filter memberships - search across all fields including member details and student names
const filteredMemberships = memberships.filter((m) => {
if (statusFilter !== "all" && m.status !== statusFilter) {
return false;
}
if (searchQuery === "") {
return true;
}
const query = searchQuery.toLowerCase();
const memberStudents = getStudentsForMembership(m.id);
// Search in member fields
const memberNameMatch = m.user_name?.toLowerCase().includes(query);
const memberEmailMatch = m.user_email?.toLowerCase().includes(query);
const memberFirstNameMatch = m.member_first_name?.toLowerCase().includes(query);
const memberLastNameMatch = m.member_last_name?.toLowerCase().includes(query);
const phoneMatch = m.phone_number?.toLowerCase().includes(query);
const emergencyContactPersonMatch = m.emergency_contact_person?.toLowerCase().includes(query);
const emergencyContactNumberMatch = m.emergency_contact_number?.toLowerCase().includes(query);
const planNameMatch = m.plan_name?.toLowerCase().includes(query);
// Search in student fields
const studentMatch = memberStudents.some((student) => {
const studentFullName = `${student.first_name} ${student.last_name}`.toLowerCase();
const studentEmail = (student.membership_email || "").toLowerCase();
return (
studentFullName.includes(query) ||
studentEmail.includes(query) ||
(student.gender && student.gender.toLowerCase().includes(query))
);
});
return (
memberNameMatch ||
memberEmailMatch ||
memberFirstNameMatch ||
memberLastNameMatch ||
phoneMatch ||
emergencyContactPersonMatch ||
emergencyContactNumberMatch ||
planNameMatch ||
studentMatch
);
});
// At-risk members
const atRiskMembers = memberships.filter((m) => {
if (m.status === "expired") return true;
if (m.status !== "active") return false;
const days = differenceInDays(new Date(m.renewal_date), new Date());
return days >= 0 && days <= 7;
});
const statusColors = {
active: "bg-green-100 text-green-700",
pending: "bg-amber-100 text-amber-700",
expired: "bg-red-100 text-red-700",
cancelled: "bg-stone-100 text-stone-700",
};
const isLoading = membershipsLoading || studentsLoading;
const navItems = [
{ label: "Dashboard", to: "/admin", icon: LayoutDashboard },
{ label: "Classes", to: "/admin/classes", icon: Users2 },
{ label: "Exams", to: "/admin/exams", icon: FileText },
{ label: "Competitions", to: "/admin/competitions", icon: Trophy },
{ label: "Shop", to: "/admin/products", icon: ShoppingBag },
];
const handleNavClick = (to) => {
navigate(to);
setMobileNavOpen(false);
};
const isActivePath = (to) => {
return window.location.pathname === to;
};
return (
<div className="min-h-screen bg-stone-50 flex flex-col">
{/* TOP HEADER */}
<header className="border-b border-stone-100 bg-white flex items-center justify-between px-4 sm:px-6 lg:px-10 py-3">
<div className="flex items-center gap-3">
<button
type="button"
className="inline-flex md:hidden items-center justify-center h-9 w-9 rounded-full border border-stone-200 text-stone-700 hover:bg-stone-50"
onClick={() => setMobileNavOpen(true)}
aria-label="Open navigation"
>
<Menu className="w-4 h-4" />
</button>
<img
src={dojoLogo}
alt="Dojo logo"
className="h-9 w-9 rounded-full border border-stone-200 bg-white object-cover"
/>
<div className="hidden sm:block">
<div className="text-sm font-semibold text-stone-900">Karate Dojo</div>
<div className="text-xs text-stone-500">Admin Portal</div>
</div>
</div>
<UserMenu name={adminName} email={adminEmail} onLogout={handleAdminLogout} />
</header>
{/* BODY */}
<div className="flex flex-1">
{/* DESKTOP SIDEBAR */}
<aside className="hidden md:flex w-64 flex-col bg-white">
<nav className="flex-1 px-3 pt-4 pb-2 space-y-1">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = isActivePath(item.to);
return (
<button
key={item.to}
type="button"
onClick={() => handleNavClick(item.to)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-left ${
isActive
? "bg-rose-50 text-rose-700 font-semibold"
: "text-stone-600 hover:bg-stone-50"
}`}
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-xl border ${
isActive
? "border-rose-100 bg-rose-50 text-rose-600"
: "border-stone-200 bg-white text-stone-500"
}`}
>
<Icon className="w-4 h-4" />
</span>
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className="px-4 pb-5">
<div className="rounded-2xl border border-rose-100 bg-rose-50 px-3 py-2">
<div className="text-[11px] font-semibold text-rose-700">Admin Mode</div>
<div className="text-[11px] text-rose-500">Full Access</div>
</div>
</div>
</aside>
{/* MAIN CONTENT */}
<div className="flex-1 border-l border-stone-100">
<main className="px-4 sm:px-6 lg:px-10 py-6 sm:py-8 pb-10">
{/* Header */}
<div className="mb-6">
<Link
to="/admin"
className="inline-flex items-center gap-2 text-sm text-stone-600 hover:text-stone-900 mb-4"
>
<ArrowLeft className="w-4 h-4" />
Back to Dashboard
</Link>
<h1 className="text-2xl sm:text-3xl font-bold text-stone-900">Members & Students</h1>
<p className="text-stone-600 mt-1">Manage all memberships, students, and invitations</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white border border-stone-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-green-100 rounded-full flex items-center justify-center">
<CheckCircle2 className="w-5 h-5 text-green-600" />
</div>
<div>
<div className="text-2xl font-bold text-stone-900">
{memberships.filter((m) => m.status === "active").length}
</div>
<div className="text-xs text-stone-500">Active</div>
</div>
</div>
</div>
<div className="bg-white border border-stone-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-amber-100 rounded-full flex items-center justify-center">
<Clock className="w-5 h-5 text-amber-600" />
</div>
<div>
<div className="text-2xl font-bold text-stone-900">
{invites.filter((i) => i.status === "pending").length}
</div>
<div className="text-xs text-stone-500">Pending Invites</div>
</div>
</div>
</div>
<div className="bg-white border border-stone-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-blue-100 rounded-full flex items-center justify-center">
<Users className="w-5 h-5 text-blue-600" />
</div>
<div>
<div className="text-2xl font-bold text-stone-900">{students.length}</div>
<div className="text-xs text-stone-500">Total Students</div>
</div>
</div>
</div>
<div className="bg-white border border-stone-200 rounded-lg p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
<div>
<div className="text-2xl font-bold text-stone-900">{atRiskMembers.length}</div>
<div className="text-xs text-stone-500">At Risk</div>
</div>
</div>
</div>
</div>
{/* Tabs */}
<div className="mb-6">
<div className="flex gap-2 border-b border-stone-200">
<button
type="button"
onClick={() => setActiveTab("all")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "all"
? "border-red-600 text-red-600"
: "border-transparent text-stone-600 hover:text-stone-900"
}`}
>
All Members ({memberships.length})
</button>
<button
type="button"
onClick={() => setActiveTab("invites")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "invites"
? "border-red-600 text-red-600"
: "border-transparent text-stone-600 hover:text-stone-900"
}`}
>
Pending Invites ({invites.filter((i) => i.status === "pending").length})
</button>
<button
type="button"
onClick={() => setActiveTab("requests")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "requests"
? "border-red-600 text-red-600"
: "border-transparent text-stone-600 hover:text-stone-900"
}`}
>
Requests ({requests.filter((r) => r.status === "pending").length})
</button>
<button
type="button"
onClick={() => setActiveTab("at-risk")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "at-risk"
? "border-red-600 text-red-600"
: "border-transparent text-stone-600 hover:text-stone-900"
}`}
>
At Risk ({atRiskMembers.length})
</button>
</div>
</div>
{/* All Members Tab */}
{activeTab === "all" && (
<div className="bg-white border border-stone-200 rounded-lg shadow-sm">
<div className="px-4 sm:px-6 py-4 border-b border-stone-100">
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h2 className="text-base font-semibold text-stone-900">All Members</h2>
<p className="text-sm text-stone-500 mt-1">
Click on a member to view and manage their students
</p>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<div className="relative flex-1 sm:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-stone-400" />
<input
type="text"
placeholder="Search members..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-9 pr-3 py-2 text-sm border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="w-32 px-3 py-2 text-sm border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="expired">Expired</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
</div>
</div>
<div className="px-4 sm:px-6 py-4">
{isLoading ? (
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-20 bg-stone-100 rounded animate-pulse" />
))}
</div>
) : filteredMemberships.length === 0 ? (
<div className="text-center py-12 text-stone-500">
<Users className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No members found</p>
</div>
) : (
<div className="space-y-3">
{filteredMemberships.map((membership) => {
const memberStudents = getStudentsForMembership(membership.id);
const isExpanded = expandedMembership === membership.id;
const daysUntilRenewal = membership.renewal_date
? differenceInDays(new Date(membership.renewal_date), new Date())
: null;
return (
<div key={membership.id} className="border border-stone-200 rounded-lg overflow-hidden">
<div
className="p-4 flex items-center justify-between cursor-pointer hover:bg-stone-50"
onClick={() => setExpandedMembership(isExpanded ? null : membership.id)}
>
<div className="flex items-center gap-4">
<button
type="button"
className="h-8 w-8 flex items-center justify-center text-stone-500 hover:text-stone-700"
onClick={(e) => {
e.stopPropagation();
setExpandedMembership(isExpanded ? null : membership.id);
}}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
<div>
<div className="font-semibold text-stone-900">
{membership.user_name || membership.user_email}
</div>
<div className="text-sm text-stone-500 flex items-center gap-2">
<Mail className="w-3 h-3" />
{membership.user_email}
</div>
{memberStudents.length > 0 && (
<div className="text-xs text-stone-500 mt-1 flex items-center gap-1 flex-wrap">
{memberStudents.map((student, idx) => (
<React.Fragment key={student.id}>
<Link
to={`/admin/students/${student.id}`}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 hover:text-stone-900"
title="View Profile"
>
<User className="w-3 h-3" />
<span>
{student.first_name} {student.last_name}
</span>
</Link>
{idx < memberStudents.length - 1 && <span></span>}
</React.Fragment>
))}
</div>
)}
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right hidden md:block">
<div className="text-sm font-medium text-stone-900">
{membership.plan_name}
</div>
<div className="text-xs text-stone-500">
${membership.price}/{membership.billing_period}
</div>
</div>
<div
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium border ${
statusColors[membership.status] || "bg-stone-100 text-stone-700"
}`}
>
<Users className="w-3 h-3" />
{memberStudents.length}/{membership.max_students || 1}
</div>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
statusColors[membership.status] || "bg-stone-100 text-stone-700"
}`}
>
{membership.status}
</span>
</div>
</div>
{isExpanded && (
<div className="border-t bg-stone-50 p-4">
{/* Membership Details */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4 text-sm">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-stone-400" />
<span className="text-stone-500">Start:</span>
<span className="font-medium text-stone-900">
{membership.start_date
? format(new Date(membership.start_date), "MMM d, yyyy")
: "-"}
</span>
</div>
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-stone-400" />
<span className="text-stone-500">Renewal:</span>
<span className="font-medium text-stone-900">
{membership.renewal_date
? format(new Date(membership.renewal_date), "MMM d, yyyy")
: "-"}
</span>
</div>
<div className="flex items-center gap-2">
<CreditCard className="w-4 h-4 text-stone-400" />
<span className="text-stone-500">Amount:</span>
<span className="font-medium text-stone-900">${membership.price}</span>
</div>
{daysUntilRenewal !== null && (
<div className="flex items-center gap-2">
<Clock className="w-4 h-4 text-stone-400" />
<span
className={`font-medium text-stone-900 ${
daysUntilRenewal <= 7 ? "text-red-600" : ""
}`}
>
{daysUntilRenewal} days until renewal
</span>
</div>
)}
</div>
{/* Member Contact Details */}
{(membership.member_first_name ||
membership.member_last_name ||
membership.phone_number ||
membership.emergency_contact_person ||
membership.emergency_contact_number) && (
<div className="mb-4 p-3 bg-white rounded-lg border border-stone-200">
<h4 className="text-sm font-semibold text-stone-900 mb-3">
Member Contact Information
</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
{membership.member_first_name && (
<div>
<span className="text-stone-500">First Name:</span>
<span className="font-medium text-stone-900 ml-2">
{membership.member_first_name}
</span>
</div>
)}
{membership.member_last_name && (
<div>
<span className="text-stone-500">Last Name:</span>
<span className="font-medium text-stone-900 ml-2">
{membership.member_last_name}
</span>
</div>
)}
{membership.phone_number && (
<div className="flex items-center gap-2">
<Phone className="w-4 h-4 text-stone-400" />
<span className="text-stone-500">Phone:</span>
<span className="font-medium text-stone-900">
{membership.phone_number}
</span>
</div>
)}
{membership.emergency_contact_person && (
<div>
<span className="text-stone-500">Emergency Contact:</span>
<span className="font-medium text-stone-900 ml-2">
{membership.emergency_contact_person}
</span>
</div>
)}
{membership.emergency_contact_number && (
<div className="flex items-center gap-2">
<Phone className="w-4 h-4 text-stone-400" />
<span className="text-stone-500">Emergency Phone:</span>
<span className="font-medium text-stone-900">
{membership.emergency_contact_number}
</span>
</div>
)}
</div>
</div>
)}
{/* Actions */}
<div className="flex gap-2 mb-4">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setEditingMembership(
editingMembership?.id === membership.id ? null : membership
);
}}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border border-stone-200 rounded-lg hover:bg-stone-50 text-stone-900"
>
<Edit2 className="w-4 h-4" />
{editingMembership?.id === membership.id ? "Cancel Edit" : "Edit Membership"}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (confirm("Delete this membership?")) {
deleteMembershipMutation.mutate(membership.id);
}
}}
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border border-stone-200 rounded-lg text-red-600 hover:bg-red-50"
>
<Trash2 className="w-4 h-4" />
Delete
</button>
</div>
{/* Edit Membership Form - Inline */}
{editingMembership?.id === membership.id && (
<div className="bg-white border border-stone-200 rounded-lg p-4 mb-4">
<div className="mb-4">
<h4 className="text-lg font-semibold text-stone-900">Edit Membership</h4>
<p className="text-sm text-stone-500 mt-1">Update membership details</p>
</div>
<form
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.target);
updateMembershipMutation.mutate({
id: editingMembership.id,
data: {
status: formData.get("status"),
start_date: formData.get("start_date") || null,
renewal_date: formData.get("renewal_date") || null,
price: formData.get("price")
? parseFloat(formData.get("price"))
: undefined,
member_first_name: formData.get("member_first_name") || null,
member_last_name: formData.get("member_last_name") || null,
phone_number: formData.get("phone_number") || null,
emergency_contact_person:
formData.get("emergency_contact_person") || null,
emergency_contact_number:
formData.get("emergency_contact_number") || null,
},
});
}}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Status
</label>
<select
name="status"
defaultValue={editingMembership.status}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="expired">Expired</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Start Date
</label>
<input
type="date"
name="start_date"
defaultValue={
editingMembership.start_date
? editingMembership.start_date.split("T")[0]
: ""
}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Renewal Date
</label>
<input
type="date"
name="renewal_date"
defaultValue={
editingMembership.renewal_date
? editingMembership.renewal_date.split("T")[0]
: ""
}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Price
</label>
<input
type="number"
name="price"
step="0.01"
defaultValue={editingMembership.price}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
{/* Member Contact Information */}
<div className="border-t border-stone-200 pt-4">
<h5 className="text-sm font-semibold text-stone-900 mb-3">
Member Contact Information
</h5>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Member First Name
</label>
<input
type="text"
name="member_first_name"
defaultValue={editingMembership.member_first_name || ""}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Member Last Name
</label>
<input
type="text"
name="member_last_name"
defaultValue={editingMembership.member_last_name || ""}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Phone Number
</label>
<input
type="tel"
name="phone_number"
defaultValue={editingMembership.phone_number || ""}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-stone-700 mb-1">
Emergency Contact Person
</label>
<input
type="text"
name="emergency_contact_person"
defaultValue={editingMembership.emergency_contact_person || ""}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
<div className="col-span-2">
<label className="block text-sm font-medium text-stone-700 mb-1">
Emergency Contact Number
</label>
<input
type="tel"
name="emergency_contact_number"
defaultValue={editingMembership.emergency_contact_number || ""}
className="w-full px-3 py-2 border border-stone-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500"
/>
</div>
</div>
</div>
<div className="flex gap-2 justify-end pt-4">
<button
type="button"
onClick={() => setEditingMembership(null)}
className="px-4 py-2 text-sm border border-stone-200 rounded-lg hover:bg-stone-50"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700"
>
Save
</button>
</div>
</form>
</div>
)}
{/* Students Table */}
<div className="bg-white rounded-lg border">
<div className="p-3 border-b bg-stone-100">
<h4 className="font-medium text-stone-900">
Students ({memberStudents.length}/{membership.max_students || 1})
</h4>
</div>
{memberStudents.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-stone-200 bg-stone-50">
<th className="px-4 py-2 text-left font-medium text-stone-700">
Name
</th>
<th className="px-4 py-2 text-left font-medium text-stone-700">
Date of Birth
</th>
<th className="px-4 py-2 text-left font-medium text-stone-700">
Gender
</th>
<th className="px-4 py-2 text-left font-medium text-stone-700">
Medical Notes
</th>
<th className="px-4 py-2 text-right font-medium text-stone-700">
Actions
</th>
</tr>
</thead>
<tbody>
{memberStudents.map((student) => (
<tr key={student.id} className="border-b border-stone-100">
<td className="px-4 py-2 font-medium text-stone-900">
{student.first_name} {student.last_name}
</td>
<td className="px-4 py-2 text-stone-600">
{student.date_of_birth
? format(new Date(student.date_of_birth), "MMM d, yyyy")
: "-"}
</td>
<td className="px-4 py-2 text-stone-600">
{student.gender || "-"}
</td>
<td className="px-4 py-2 text-xs text-stone-500">
{student.medical_notes || "-"}
</td>
<td className="px-4 py-2 text-right">
<div className="flex justify-end gap-1">
<Link
to={`/admin/students/${student.id}`}
onClick={(e) => e.stopPropagation()}
className="p-1 text-stone-600 hover:text-stone-900"
title="View Profile"
>
<User className="w-4 h-4" />
</Link>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
if (confirm("Delete this student?")) {
deleteStudentMutation.mutate(student.id);
}
}}
className="p-1 text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<div className="text-center py-6 text-stone-500 text-sm">
No students added yet
</div>
)}
</div>
</div>
)}
</div>
);
})}
</div>
)}
</div>
</div>
)}
{/* Pending Invites Tab */}
{activeTab === "invites" && (
<div className="bg-white border border-stone-200 rounded-lg shadow-sm">
<div className="px-4 sm:px-6 py-4 border-b border-stone-100">
<h2 className="text-base font-semibold text-stone-900">Pending Invitations</h2>
<p className="text-sm text-stone-500 mt-1">Invites waiting for payment</p>
</div>
<div className="px-4 sm:px-6 py-4">
{invites.filter((i) => i.status === "pending").length === 0 ? (
<div className="text-center py-12 text-stone-500">
<Mail className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No pending invites</p>
</div>
) : (
<div className="space-y-3">
{invites
.filter((i) => i.status === "pending")
.map((invite) => (
<div key={invite.id} className="border border-stone-200 rounded-lg p-4">
<div className="flex flex-col sm:flex-row justify-between items-start gap-4">
<div>
<div className="font-semibold text-stone-900">{invite.email}</div>
<div className="text-sm text-stone-500 mt-1">
{invite.plan_name} • ${invite.plan_price} • Up to {invite.max_students}{" "}
student(s)
</div>
{invite.class_details && (
<div className="text-xs text-stone-400 mt-1">{invite.class_details}</div>
)}
<div className="text-xs text-stone-400 mt-2">
Sent{" "}
{invite.invite_date
? format(new Date(invite.invite_date), "MMM d, yyyy")
: "-"}{" "}
by {invite.invited_by || "admin"}
</div>
</div>
<div className="flex gap-2">
<span className="inline-flex items-center rounded-full px-2 py-1 text-xs font-medium bg-amber-100 text-amber-700">
Pending
</span>
<button
type="button"
onClick={() => {
if (confirm("Delete this invite?")) {
deleteInviteMutation.mutate(invite.id);
}
}}
className="inline-flex items-center justify-center p-2 text-red-600 hover:bg-red-50 rounded-lg border border-stone-200"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Requests Tab */}
{activeTab === "requests" && (
<div className="bg-white border border-stone-200 rounded-lg shadow-sm">
<div className="px-4 sm:px-6 py-4 border-b border-stone-100">
<h2 className="text-base font-semibold text-stone-900">Membership Requests</h2>
<p className="text-sm text-stone-500 mt-1">Pause and cancellation requests awaiting approval</p>
</div>
<div className="px-4 sm:px-6 py-4">
{requests.filter((r) => r.status === "pending").length === 0 ? (
<div className="text-center py-12 text-stone-500">
<AlertCircle className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No pending requests</p>
</div>
) : (
<div className="space-y-3">
{requests
.filter((r) => r.status === "pending")
.map((request) => (
<div key={request.id} className="border border-stone-200 rounded-lg p-4">
<div className="flex flex-col sm:flex-row justify-between items-start gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
request.request_type === "pause"
? "bg-blue-50 text-blue-700"
: "bg-amber-50 text-amber-700"
}`}>
{request.request_type === "pause" ? "Pause Request" : "Cancellation Request"}
</span>
</div>
<div className="font-semibold text-stone-900">{request.member_name}</div>
<div className="text-sm text-stone-500 mt-1">{request.member_email}</div>
<div className="text-sm text-stone-500 mt-1">
Plan: {request.plan_name} • Status: {request.membership_status}
</div>
{request.renewal_date && (
<div className="text-xs text-stone-400 mt-1">
Renewal: {format(new Date(request.renewal_date), "MMM d, yyyy")}
</div>
)}
<div className="text-xs text-stone-400 mt-2">
Requested {request.requested_at
? format(new Date(request.requested_at), "MMM d, yyyy 'at' h:mm a")
: "-"}
</div>
</div>
<div className="flex flex-col gap-3">
{/* Inline confirmation messages */}
{requestMessage && requestMessage.requestId === request.id && (
<div className={`p-3 rounded-lg text-sm ${
requestMessage.type === "success"
? "bg-green-50 text-green-900 border border-green-200"
: "bg-red-50 text-red-900 border border-red-200"
}`}>
{requestMessage.text}
</div>
)}
{/* Approve confirmation */}
{confirmingRequest?.id === request.id && confirmingRequest?.action === "approve" ? (
<div className="p-3 bg-green-50 rounded-lg border border-green-200">
<p className="text-sm text-green-900 mb-3">
Approve this {request.request_type} request?
</p>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
approveRequestMutation.mutate(
{ requestId: request.id },
{
onSuccess: () => {
setRequestMessage({
type: "success",
text: `${request.request_type === "pause" ? "Pause" : "Cancellation"} request approved successfully.`,
requestId: request.id,
});
setConfirmingRequest(null);
setTimeout(() => {
setRequestMessage(null);
}, 4000);
},
onError: (error) => {
setRequestMessage({
type: "error",
text: error.response?.data?.detail || "Failed to approve request. Please try again.",
requestId: request.id,
});
setConfirmingRequest(null);
setTimeout(() => {
setRequestMessage(null);
}, 4000);
},
}
);
}}
disabled={approveRequestMutation.isPending}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg disabled:opacity-50"
>
{approveRequestMutation.isPending ? "Approving..." : "Yes, Approve"}
</button>
<button
type="button"
onClick={() => {
setConfirmingRequest(null);
setRequestMessage(null);
}}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-stone-700 bg-white hover:bg-stone-50 rounded-lg border border-stone-200"
>
Cancel
</button>
</div>
</div>
) : confirmingRequest?.id === request.id && confirmingRequest?.action === "reject" ? (
<div className="p-3 bg-red-50 rounded-lg border border-red-200">
<p className="text-sm text-red-900 mb-2">
Reject this {request.request_type} request?
</p>
<textarea
value={rejectNotes[request.id] || ""}
onChange={(e) => setRejectNotes({ ...rejectNotes, [request.id]: e.target.value })}
placeholder="Rejection reason (optional)"
rows={2}
className="w-full px-3 py-2 border border-stone-200 rounded-lg text-sm mb-3 focus:outline-none focus:ring-2 focus:ring-red-500"
/>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
rejectRequestMutation.mutate(
{ requestId: request.id, notes: rejectNotes[request.id] || "" },
{
onSuccess: () => {
setRequestMessage({
type: "success",
text: `${request.request_type === "pause" ? "Pause" : "Cancellation"} request rejected successfully.`,
requestId: request.id,
});
setConfirmingRequest(null);
const newNotes = { ...rejectNotes };
delete newNotes[request.id];
setRejectNotes(newNotes);
setTimeout(() => {
setRequestMessage(null);
}, 4000);
},
onError: (error) => {
setRequestMessage({
type: "error",
text: error.response?.data?.detail || "Failed to reject request. Please try again.",
requestId: request.id,
});
setConfirmingRequest(null);
setTimeout(() => {
setRequestMessage(null);
}, 4000);
},
}
);
}}
disabled={rejectRequestMutation.isPending}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 rounded-lg disabled:opacity-50"
>
{rejectRequestMutation.isPending ? "Rejecting..." : "Yes, Reject"}
</button>
<button
type="button"
onClick={() => {
setConfirmingRequest(null);
const newNotes = { ...rejectNotes };
delete newNotes[request.id];
setRejectNotes(newNotes);
setRequestMessage(null);
}}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-stone-700 bg-white hover:bg-stone-50 rounded-lg border border-stone-200"
>
Cancel
</button>
</div>
</div>
) : (
<div className="flex gap-2">
<button
type="button"
onClick={() => {
setConfirmingRequest({ id: request.id, action: "approve" });
setRequestMessage(null);
}}
disabled={approveRequestMutation.isPending || rejectRequestMutation.isPending}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-green-700 bg-green-50 hover:bg-green-100 rounded-lg border border-green-200 disabled:opacity-50"
>
<CheckCircle2 className="w-4 h-4 mr-1" />
Approve
</button>
<button
type="button"
onClick={() => {
setConfirmingRequest({ id: request.id, action: "reject" });
setRejectNotes({ ...rejectNotes, [request.id]: "" });
setRequestMessage(null);
}}
disabled={approveRequestMutation.isPending || rejectRequestMutation.isPending}
className="inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-red-700 bg-red-50 hover:bg-red-100 rounded-lg border border-red-200 disabled:opacity-50"
>
<X className="w-4 h-4 mr-1" />
Reject
</button>
</div>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* At Risk Tab */}
{activeTab === "at-risk" && (
<div className="bg-white border border-stone-200 rounded-lg shadow-sm">
<div className="px-4 sm:px-6 py-4 border-b border-stone-100">
<h2 className="text-base font-semibold text-stone-900">At-Risk Members</h2>
<p className="text-sm text-stone-500 mt-1">
Members expiring within 7 days or already expired
</p>
</div>
<div className="px-4 sm:px-6 py-4">
{atRiskMembers.length === 0 ? (
<div className="text-center py-12 text-stone-500">
<CheckCircle2 className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p>No at-risk members</p>
</div>
) : (
<div className="space-y-3">
{atRiskMembers.map((membership) => {
const daysUntilRenewal = membership.renewal_date
? differenceInDays(new Date(membership.renewal_date), new Date())
: null;
return (
<div
key={membership.id}
className="border border-red-200 rounded-lg p-4 bg-red-50"
>
<div className="flex flex-col sm:flex-row justify-between items-start gap-4">
<div>
<div className="font-semibold text-stone-900">
{membership.user_name || membership.user_email}
</div>
<div className="text-sm text-stone-600">{membership.user_email}</div>
<div className="text-sm text-stone-500 mt-1">{membership.plan_name}</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<div
className={`font-semibold ${
membership.status === "expired" ? "text-red-600" : "text-amber-600"
}`}
>
{membership.status === "expired"
? "Expired"
: `${daysUntilRenewal} days left`}
</div>
<div className="text-xs text-stone-500">
{membership.renewal_date
? format(new Date(membership.renewal_date), "MMM d, yyyy")
: "-"}
</div>
</div>
<span
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
statusColors[membership.status] || "bg-stone-100 text-stone-700"
}`}
>
{membership.status}
</span>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
)}
</main>
</div>
</div>
{/* MOBILE DRAWER NAV */}
{mobileNavOpen && (
<>
<div
className="fixed inset-0 z-40 bg-black/20 md:hidden"
onClick={() => setMobileNavOpen(false)}
/>
<div className="fixed inset-y-0 left-0 z-50 w-72 max-w-full bg-white border-r border-stone-100 shadow-lg flex flex-col md:hidden">
<div className="px-5 pt-5 pb-4 flex items-center gap-3 border-b border-stone-100">
<img
src={dojoLogo}
alt="Dojo logo"
className="h-9 w-9 rounded-full border border-stone-200 bg-white object-cover"
/>
<div>
<div className="text-sm font-semibold text-stone-900">Karate Dojo</div>
<div className="text-xs text-stone-500">Admin Portal</div>
</div>
</div>
<nav className="flex-1 px-3 pt-4 pb-2 space-y-1 overflow-y-auto">
{navItems.map((item) => {
const Icon = item.icon;
const isActive = isActivePath(item.to);
return (
<button
key={item.to}
type="button"
onClick={() => handleNavClick(item.to)}
className={`w-full flex items-center gap-3 rounded-xl px-3 py-2 text-sm text-left ${
isActive
? "bg-rose-50 text-rose-700 font-semibold"
: "text-stone-600 hover:bg-stone-50"
}`}
>
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-xl border ${
isActive
? "border-rose-100 bg-rose-50 text-rose-600"
: "border-stone-200 bg-white text-stone-500"
}`}
>
<Icon className="w-4 h-4" />
</span>
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className="px-4 pb-5">
<div className="rounded-2xl border border-rose-100 bg-rose-50 px-3 py-2">
<div className="text-[11px] font-semibold text-rose-700">Admin Mode</div>
<div className="text-[11px] text-rose-500">Full Access</div>
</div>
</div>
</div>
</>
)}
</div>
);
}