| |
| 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); |
| const [rejectNotes, setRejectNotes] = useState({}); |
| const [requestMessage, setRequestMessage] = useState(null); |
|
|
| 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"); |
| } |
|
|
| |
| 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: [], |
| }); |
|
|
| |
| 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); |
| }; |
|
|
| |
| 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); |
|
|
| |
| 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); |
|
|
| |
| 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 |
| ); |
| }); |
|
|
| |
| 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> |
| ); |
| } |
|
|