From 0753edb2757b9e54d345880bc386a4632f4c4f32 Mon Sep 17 00:00:00 2001 From: Ivan Date: Mon, 2 Mar 2026 03:55:02 +0000 Subject: [PATCH] feat: redesign clients page as CRM with membership tracking Co-Authored-By: Claude Opus 4.6 --- apps/web/app/(admin)/clients/page.tsx | 713 ++++++++++++++++++-------- 1 file changed, 504 insertions(+), 209 deletions(-) diff --git a/apps/web/app/(admin)/clients/page.tsx b/apps/web/app/(admin)/clients/page.tsx index 62ab193..e694dda 100644 --- a/apps/web/app/(admin)/clients/page.tsx +++ b/apps/web/app/(admin)/clients/page.tsx @@ -1,14 +1,33 @@ "use client"; -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Card, CardContent } from "@/components/ui/card"; -import { ClientTable } from "@/components/clients/client-table"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { ClientForm } from "@/components/clients/client-form"; import { ClientDetailDialog } from "@/components/clients/client-detail-dialog"; import { AssignMembershipDialog } from "@/components/memberships/assign-membership-dialog"; -import { StatCard, StatCardSkeleton } from "@/components/dashboard/stat-card"; +import { StatCardSkeleton } from "@/components/dashboard/stat-card"; +import { cn, formatDate } from "@/lib/utils"; +import { + Users, + CreditCard, + AlertTriangle, + UserX, + Search, + Eye, + Phone, + Mail, + Calendar, + Plus, + ChevronLeft, + ChevronRight, + X, +} from "lucide-react"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- interface Client { id: string; @@ -44,6 +63,7 @@ interface Client { totalSpent: number; balance: number; }; + lastBookingDate?: string | null; } interface ClientsResponse { @@ -65,23 +85,107 @@ interface MembershipPlan { discountPercent: number | string | null; } -const membershipFilters = [ - { value: "", label: "All" }, - { value: "with", label: "With membership" }, - { value: "without", label: "Without membership" }, -]; +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +type MembershipStatus = "active" | "expiring" | "expired" | "none"; + +function getMembershipStatus(client: Client): MembershipStatus { + const membership = client.memberships?.[0]; + if (!membership) return "none"; + + if (membership.status !== "ACTIVE") { + // Check if it truly expired vs just inactive + const end = new Date(membership.endDate); + if (end < new Date()) return "expired"; + return "none"; + } + + const end = new Date(membership.endDate); + const now = new Date(); + if (end < now) return "expired"; + + const daysUntilExpiry = Math.ceil( + (end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24) + ); + if (daysUntilExpiry <= 30) return "expiring"; + + return "active"; +} + +function getStatusBadge(status: MembershipStatus) { + switch (status) { + case "active": + return { + label: "Active", + className: "bg-green-100 text-green-700 border-green-300", + }; + case "expiring": + return { + label: "Expiring Soon", + className: "bg-amber-100 text-amber-700 border-amber-300", + }; + case "expired": + return { + label: "Expired", + className: "bg-red-100 text-red-700 border-red-300", + }; + case "none": + default: + return { + label: "None", + className: "bg-gray-100 text-gray-600 border-gray-200", + }; + } +} + +function getInitials(firstName: string, lastName: string) { + return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase(); +} + +function formatShortDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +// --------------------------------------------------------------------------- +// Filter options +// --------------------------------------------------------------------------- + +const FILTER_OPTIONS = [ + { value: "all", label: "All" }, + { value: "active", label: "Active Members" }, + { value: "expiring", label: "Expiring Soon" }, + { value: "expired", label: "Expired" }, + { value: "none", label: "No Membership" }, +] as const; + +type FilterValue = (typeof FILTER_OPTIONS)[number]["value"]; const ITEMS_PER_PAGE = 10; +// --------------------------------------------------------------------------- +// Page Component +// --------------------------------------------------------------------------- + export default function ClientsPage() { - // Clients state + // Data state const [clients, setClients] = useState([]); const [loadingClients, setLoadingClients] = useState(true); const [searchQuery, setSearchQuery] = useState(""); - const [membershipFilter, setMembershipFilter] = useState(""); + const [filterValue, setFilterValue] = useState("all"); const [currentPage, setCurrentPage] = useState(1); const [totalClients, setTotalClients] = useState(0); + // Stats state + const [allClientsForStats, setAllClientsForStats] = useState([]); + const [loadingStats, setLoadingStats] = useState(true); + // Modal state const [showCreateForm, setShowCreateForm] = useState(false); const [editingClient, setEditingClient] = useState(null); @@ -89,20 +193,36 @@ export default function ClientsPage() { const [showAssignMembership, setShowAssignMembership] = useState(false); const [formLoading, setFormLoading] = useState(false); - // Stats state - const [stats, setStats] = useState({ - totalClients: 0, - withMembership: 0, - newThisMonth: 0, - }); - const [loadingStats, setLoadingStats] = useState(true); - // Membership plans for assignment dialog const [membershipPlans, setMembershipPlans] = useState([]); const [error, setError] = useState(null); - // Fetch clients + // --------------------------------------------------------------------------- + // Derived stats + // --------------------------------------------------------------------------- + + const stats = useMemo(() => { + const total = allClientsForStats.length; + + let activeMemberships = 0; + let expiringThisMonth = 0; + let noMembership = 0; + + for (const c of allClientsForStats) { + const status = getMembershipStatus(c); + if (status === "active") activeMemberships++; + if (status === "expiring") expiringThisMonth++; + if (status === "none") noMembership++; + } + + return { total, activeMemberships, expiringThisMonth, noMembership }; + }, [allClientsForStats]); + + // --------------------------------------------------------------------------- + // Fetchers + // --------------------------------------------------------------------------- + const fetchClients = useCallback(async () => { setLoadingClients(true); try { @@ -112,29 +232,19 @@ export default function ClientsPage() { params.append("offset", ((currentPage - 1) * ITEMS_PER_PAGE).toString()); const response = await fetch(`/api/clients?${params.toString()}`); - if (!response.ok) throw new Error("Error loading players"); + if (!response.ok) throw new Error("Error loading clients"); const data: ClientsResponse = await response.json(); - // Filter by membership status client-side for simplicity - let filteredData = data.data; - if (membershipFilter === "with") { - filteredData = data.data.filter( - (c) => - c.memberships && - c.memberships.length > 0 && - c.memberships[0].status === "ACTIVE" - ); - } else if (membershipFilter === "without") { - filteredData = data.data.filter( - (c) => - !c.memberships || - c.memberships.length === 0 || - c.memberships[0].status !== "ACTIVE" + // Apply membership filter client-side + let filtered = data.data; + if (filterValue !== "all") { + filtered = data.data.filter( + (c) => getMembershipStatus(c) === filterValue ); } - setClients(filteredData); + setClients(filtered); setTotalClients(data.pagination.total); } catch (err) { console.error("Error fetching clients:", err); @@ -142,39 +252,15 @@ export default function ClientsPage() { } finally { setLoadingClients(false); } - }, [searchQuery, currentPage, membershipFilter]); + }, [searchQuery, currentPage, filterValue]); - // Fetch stats const fetchStats = useCallback(async () => { setLoadingStats(true); try { - // Fetch all clients to calculate stats const response = await fetch("/api/clients?limit=1000"); if (!response.ok) throw new Error("Error loading statistics"); - const data: ClientsResponse = await response.json(); - const allClients = data.data; - - // Calculate stats - const now = new Date(); - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - - const withMembership = allClients.filter( - (c) => - c.memberships && - c.memberships.length > 0 && - c.memberships[0].status === "ACTIVE" - ).length; - - const newThisMonth = allClients.filter( - (c) => new Date(c.createdAt) >= startOfMonth - ).length; - - setStats({ - totalClients: data.pagination.total, - withMembership, - newThisMonth, - }); + setAllClientsForStats(data.data); } catch (err) { console.error("Error fetching stats:", err); } finally { @@ -182,23 +268,25 @@ export default function ClientsPage() { } }, []); - // Fetch membership plans const fetchMembershipPlans = useCallback(async () => { try { const response = await fetch("/api/membership-plans"); if (!response.ok) throw new Error("Error loading plans"); const data = await response.json(); - setMembershipPlans(data.filter((p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false)); + setMembershipPlans( + data.filter( + (p: MembershipPlan & { isActive?: boolean }) => p.isActive !== false + ) + ); } catch (err) { console.error("Error fetching membership plans:", err); } }, []); - // Fetch client details const fetchClientDetails = async (clientId: string) => { try { const response = await fetch(`/api/clients/${clientId}`); - if (!response.ok) throw new Error("Error loading player details"); + if (!response.ok) throw new Error("Error loading client details"); const data = await response.json(); setSelectedClient(data); } catch (err) { @@ -207,6 +295,10 @@ export default function ClientsPage() { } }; + // --------------------------------------------------------------------------- + // Effects + // --------------------------------------------------------------------------- + useEffect(() => { fetchClients(); fetchStats(); @@ -224,9 +316,12 @@ export default function ClientsPage() { useEffect(() => { setCurrentPage(1); - }, [debouncedSearch, membershipFilter]); + }, [debouncedSearch, filterValue]); + + // --------------------------------------------------------------------------- + // Handlers + // --------------------------------------------------------------------------- - // Handle create client const handleCreateClient = async (data: { firstName: string; lastName: string; @@ -241,12 +336,10 @@ export default function ClientsPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); - if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || "Error creating player"); + throw new Error(errorData.error || "Error creating client"); } - setShowCreateForm(false); await Promise.all([fetchClients(), fetchStats()]); } catch (err) { @@ -256,7 +349,6 @@ export default function ClientsPage() { } }; - // Handle update client const handleUpdateClient = async (data: { firstName: string; lastName: string; @@ -265,7 +357,6 @@ export default function ClientsPage() { avatar?: string; }) => { if (!editingClient) return; - setFormLoading(true); try { const response = await fetch(`/api/clients/${editingClient.id}`, { @@ -273,16 +364,12 @@ export default function ClientsPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); - if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || "Error updating player"); + throw new Error(errorData.error || "Error updating client"); } - setEditingClient(null); await fetchClients(); - - // Update selected client if viewing details if (selectedClient?.id === editingClient.id) { await fetchClientDetails(editingClient.id); } @@ -293,7 +380,6 @@ export default function ClientsPage() { } }; - // Handle delete client const handleDeleteClient = async (client: Client) => { if ( !confirm( @@ -302,17 +388,14 @@ export default function ClientsPage() { ) { return; } - try { const response = await fetch(`/api/clients/${client.id}`, { method: "DELETE", }); - if (!response.ok) { const errorData = await response.json(); - throw new Error(errorData.error || "Error deactivating player"); + throw new Error(errorData.error || "Error deactivating client"); } - await Promise.all([fetchClients(), fetchStats()]); } catch (err) { console.error("Error deleting client:", err); @@ -320,7 +403,6 @@ export default function ClientsPage() { } }; - // Handle assign membership const handleAssignMembership = async (data: { clientId: string; planId: string; @@ -334,16 +416,12 @@ export default function ClientsPage() { headers: { "Content-Type": "application/json" }, body: JSON.stringify(data), }); - if (!response.ok) { const errorData = await response.json(); throw new Error(errorData.error || "Error assigning membership"); } - setShowAssignMembership(false); await Promise.all([fetchClients(), fetchStats()]); - - // Update selected client if viewing details if (selectedClient) { await fetchClientDetails(selectedClient.id); } @@ -354,39 +432,112 @@ export default function ClientsPage() { } }; - // Handle row click to view details const handleRowClick = (client: Client) => { fetchClientDetails(client.id); }; - // Calculate pagination + // Pagination const totalPages = Math.ceil(totalClients / ITEMS_PER_PAGE); + // --------------------------------------------------------------------------- + // Render: Stat Card (inline for CRM style) + // --------------------------------------------------------------------------- + + function CRMStatCard({ + title, + value, + icon, + iconBg, + iconColor, + }: { + title: string; + value: number; + icon: React.ReactNode; + iconBg: string; + iconColor: string; + }) { + return ( + + +
+
+ {icon} +
+
+

{title}

+

{value}

+
+
+
+
+ ); + } + + // --------------------------------------------------------------------------- + // Render: Table skeleton + // --------------------------------------------------------------------------- + + function TableSkeleton() { + return ( + <> + {[...Array(5)].map((_, i) => ( + + +
+
+
+
+ + +
+
+
+
+ + +
+ + +
+ + +
+ + +
+ + +
+ + + ))} + + ); + } + + // --------------------------------------------------------------------------- + // Render + // --------------------------------------------------------------------------- + return (
{/* Header */}
-

Players

+

Clients

- Manage your club's players + Manage your club members and memberships

@@ -394,103 +545,56 @@ export default function ClientsPage() { {error && (
- - - +

{error}

)} - {/* Stats Cards */} -
+ {/* Stats Row */} +
{loadingStats ? ( <> + ) : ( <> - - - - } + } + iconBg="bg-primary-100" + iconColor="text-primary-600" /> - - - - } + } + iconBg="bg-green-100" + iconColor="text-green-600" /> - - - - } + } + iconBg="bg-amber-100" + iconColor="text-amber-600" + /> + } + iconBg="bg-gray-100" + iconColor="text-gray-500" /> )} @@ -501,46 +605,237 @@ export default function ClientsPage() {
{/* Search */} -
+
+ setSearchQuery(e.target.value)} - className="w-full" + className="pl-9 w-full" />
- {/* Membership Filter */} -
- {membershipFilters.map((filter) => ( - + {/* Membership Filter Dropdown */} +
{/* Clients Table */} - setEditingClient(client)} - onDelete={handleDeleteClient} - isLoading={loadingClients} - currentPage={currentPage} - totalPages={totalPages} - onPageChange={setCurrentPage} - /> +
+ + + + + + + + + + + + + + {loadingClients ? ( + + ) : clients.length === 0 ? ( + + + + ) : ( + clients.map((client) => { + const membership = client.memberships?.[0]; + const mStatus = getMembershipStatus(client); + const badge = getStatusBadge(mStatus); + + // Determine last visit from lastBookingDate or fallback + const lastVisit = client.lastBookingDate || null; + + return ( + handleRowClick(client)} + > + {/* Name */} + + + {/* Contact */} + + + {/* Membership Plan */} + + + {/* Status Badge */} + + + {/* Expires */} + + + {/* Last Visit */} + + + {/* Actions */} + + + ); + }) + )} + +
+ Name + + Contact + + Membership + + Status + + Expires + + Last Visit + + Actions +
+
+ +

No clients found

+

+ {searchQuery || filterValue !== "all" + ? "Try adjusting your search or filters" + : "Add your first client to get started"} +

+
+
+
+ {client.avatar ? ( + {`${client.firstName} + ) : ( +
+ + {getInitials(client.firstName, client.lastName)} + +
+ )} + + {client.firstName} {client.lastName} + +
+
+
+ {client.phone && ( +
+ + {client.phone} +
+ )} + {client.email && ( +
+ + + {client.email} + +
+ )} + {!client.phone && !client.email && ( + + — + + )} +
+
+ {membership && membership.status === "ACTIVE" + ? membership.plan.name + : membership && mStatus === "expired" + ? membership.plan.name + : "None"} + + + {badge.label} + + + {membership && + (membership.status === "ACTIVE" || + mStatus === "expired") ? ( +
+ + {formatShortDate(membership.endDate)} +
+ ) : ( + + )} +
+ {lastVisit ? ( + formatShortDate(lastVisit) + ) : ( + Never + )} + +
e.stopPropagation()} + > + +
+
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {currentPage} of {totalPages} +
+
+ + +
+
+ )}
{/* Create Client Form Modal */}