From 02c9681aca340b3dbdef100124ba6aa1fc8e25fd Mon Sep 17 00:00:00 2001 From: echo Date: Wed, 18 Mar 2026 02:04:09 +0100 Subject: [PATCH] performance improvments --- apps/admin/data/fitai.db | Bin 172032 -> 172032 bytes .../src/app/api/admin/analytics/route.ts | 142 +++++++ apps/admin/src/app/api/admin/stats/route.ts | 3 +- apps/admin/src/app/layout.tsx | 21 +- apps/admin/src/app/page.tsx | 123 +++--- apps/admin/src/app/recommendations/page.tsx | 140 ++----- .../analytics/AnalyticsDashboard.tsx | 101 ++--- .../components/providers/QueryProvider.tsx | 26 ++ apps/admin/src/components/ui/Sidebar.tsx | 26 +- apps/admin/src/components/ui/skeleton.tsx | 57 +++ apps/admin/src/components/users/UserGrid.tsx | 18 +- .../src/components/users/UserManagement.tsx | 242 +++-------- apps/admin/src/hooks/use-api.ts | 380 ++++++++++++++++++ 13 files changed, 805 insertions(+), 474 deletions(-) create mode 100644 apps/admin/src/app/api/admin/analytics/route.ts create mode 100644 apps/admin/src/components/providers/QueryProvider.tsx create mode 100644 apps/admin/src/components/ui/skeleton.tsx create mode 100644 apps/admin/src/hooks/use-api.ts diff --git a/apps/admin/data/fitai.db b/apps/admin/data/fitai.db index 2e3ebc26980116ac38da6e7f4bc1275c78eaf765..4300f66a2145519d70846a0560d76e3becdef597 100644 GIT binary patch delta 134 zcmZoTz}0YoYl1Xm-9#B@#=4COEBP5~H**O{@^QxMOUv`}DzdOlzBpfDvX7i0e-b0F zFI13~fq?-mK1a@wEhUGHhaOv*8{kaTKxu555n44>}I`4z><-4l)h! j4YIQ#AeapYJP8ONO#}dwwVg17>79q^odLJ$odOpE!ow^2 diff --git a/apps/admin/src/app/api/admin/analytics/route.ts b/apps/admin/src/app/api/admin/analytics/route.ts new file mode 100644 index 0000000..638587f --- /dev/null +++ b/apps/admin/src/app/api/admin/analytics/route.ts @@ -0,0 +1,142 @@ +import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { getDatabase } from "@/lib/database"; +import { ensureUserSynced } from "@/lib/sync-user"; +import { successResponse } from "@/lib/api/responses"; +import { db as rawDb, sql } from "@fitai/database"; + +interface UserGrowthPoint { + label: string; + value: number; +} + +interface MembershipDistributionPoint { + label: string; + value: number; + color: string; +} + +interface RevenuePoint { + label: string; + value: number; + color: string; +} + +interface AnalyticsData { + userGrowth: UserGrowthPoint[]; + membershipDistribution: MembershipDistributionPoint[]; + revenue: RevenuePoint[]; +} + +export async function GET(req: NextRequest) { + try { + const { userId } = await auth(); + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const database = await getDatabase(); + const user = await ensureUserSynced(userId, database); + + if (!user || (user.role !== "admin" && user.role !== "superAdmin")) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const url = new URL(req.url); + const months = parseInt(url.searchParams.get("months") || "6"); + + const allUsers = await database.getAllUsers(); + const allClients = await database.getAllClients(); + + const paymentsRaw = await rawDb.all( + sql`SELECT * FROM payments WHERE status = 'completed' AND paid_at IS NOT NULL`, + ); + const payments: any[] = paymentsRaw || []; + + const now = new Date(); + const userGrowth: UserGrowthPoint[] = []; + + for (let i = months - 1; i >= 0; i--) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1); + const monthLabel = date.toLocaleDateString("en-US", { month: "short" }); + + const usersCreatedByMonth = allUsers.filter((u: any) => { + const createdAt = new Date(u.createdAt); + return ( + createdAt.getFullYear() === date.getFullYear() && + createdAt.getMonth() === date.getMonth() + ); + }); + + userGrowth.push({ + label: monthLabel, + value: usersCreatedByMonth.length, + }); + } + + let runningTotal = 0; + for (const point of userGrowth) { + runningTotal += point.value; + point.value = runningTotal; + } + + const membershipCounts: Record = { + basic: 0, + premium: 0, + vip: 0, + }; + + for (const client of allClients) { + const membershipType = (client as any).membershipType || "basic"; + if (membershipCounts[membershipType] !== undefined) { + membershipCounts[membershipType]++; + } + } + + const membershipDistribution: MembershipDistributionPoint[] = [ + { label: "Basic", value: membershipCounts.basic, color: "#6b7280" }, + { label: "Premium", value: membershipCounts.premium, color: "#3b82f6" }, + { label: "VIP", value: membershipCounts.vip, color: "#f59e0b" }, + ]; + + const revenue: RevenuePoint[] = []; + + for (let i = months - 1; i >= 0; i--) { + const date = new Date(now.getFullYear(), now.getMonth() - i, 1); + const monthLabel = date.toLocaleDateString("en-US", { month: "short" }); + const nextMonthDate = new Date( + now.getFullYear(), + now.getMonth() - i + 1, + 1, + ); + + const monthlyRevenue = payments + .filter((p: any) => { + const paidAt = p.paid_at ? new Date(p.paid_at) : null; + if (!paidAt) return false; + return paidAt >= date && paidAt < nextMonthDate; + }) + .reduce((sum: number, p: any) => sum + (p.amount || 0), 0); + + revenue.push({ + label: monthLabel, + value: monthlyRevenue, + color: "#10b981", + }); + } + + const analyticsData: AnalyticsData = { + userGrowth, + membershipDistribution, + revenue, + }; + + return successResponse({ analytics: analyticsData }); + } catch (error) { + console.error("Analytics error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 }, + ); + } +} diff --git a/apps/admin/src/app/api/admin/stats/route.ts b/apps/admin/src/app/api/admin/stats/route.ts index 4c13fca..bd96d1d 100644 --- a/apps/admin/src/app/api/admin/stats/route.ts +++ b/apps/admin/src/app/api/admin/stats/route.ts @@ -2,6 +2,7 @@ import { auth } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import { getDatabase } from "@/lib/database"; import { ensureUserSynced } from "@/lib/sync-user"; +import { successResponse } from "@/lib/api/responses"; export async function GET(req: Request) { try { @@ -51,7 +52,7 @@ export async function GET(req: Request) { revenueGrowth: 0, }; - return NextResponse.json(stats); + return successResponse({ stats }); } catch (error) { console.error("Dashboard stats error:", error); return new NextResponse("Internal Server Error", { status: 500 }); diff --git a/apps/admin/src/app/layout.tsx b/apps/admin/src/app/layout.tsx index 2ae524c..b45416c 100644 --- a/apps/admin/src/app/layout.tsx +++ b/apps/admin/src/app/layout.tsx @@ -10,6 +10,7 @@ import { } from "@clerk/nextjs"; import { Sidebar } from "@/components/ui/Sidebar"; import { Toaster } from "sonner"; +import { QueryProvider } from "@/components/providers/QueryProvider"; const inter = Inter({ subsets: ["latin"] }); @@ -25,15 +26,17 @@ export default function RootLayout({ }) { return ( - - -
- -
{children}
-
- - - + + + +
+ +
{children}
+
+ + + +
); } diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx index eae9251..e2fdaad 100644 --- a/apps/admin/src/app/page.tsx +++ b/apps/admin/src/app/page.tsx @@ -1,99 +1,88 @@ "use client"; -import { useEffect, useState } from "react"; import { Users, CreditCard, CalendarCheck, TrendingUp } from "lucide-react"; import { StatsCard } from "@/components/ui/StatsCard"; +import { StatsCardSkeleton } from "@/components/ui/skeleton"; import { UserManagement } from "@/components/users/UserManagement"; import { AnalyticsDashboard } from "@/components/analytics/AnalyticsDashboard"; -import axios from "axios"; - -interface DashboardStats { - totalUsers: number; - activeClients: number; - totalRevenue: number; - revenueGrowth: number; -} +import { useDashboardStats } from "@/hooks/use-api"; export default function Home() { - const [stats, setStats] = useState({ - totalUsers: 0, - activeClients: 0, - totalRevenue: 0, - revenueGrowth: 0, - }); - const [loading, setLoading] = useState(true); - - useEffect(() => { - const fetchStats = async () => { - try { - const response = await axios.get("/api/admin/stats"); - setStats(response.data); - } catch (error) { - console.error("Failed to fetch dashboard stats:", error); - } finally { - setLoading(false); - } - }; - - fetchStats(); - }, []); + const { data: stats, isLoading } = useDashboardStats(); const formatCurrency = (value: number) => { return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", - }).format(value); + }).format(value || 0); }; return (

Dashboard

-

Welcome back, here's what's happening today.

+

+ Welcome back, here's what's happening today. +

- - - 0 ? "+" : ""}${stats.revenueGrowth}%`} - trend={stats.revenueGrowth >= 0 ? "up" : "down"} - icon={CreditCard} - color="purple" - /> - + {isLoading ? ( + <> + + + + + + ) : ( + <> + + + 0 ? "+" : ""}${stats?.revenueGrowth ?? 0}%`} + trend={(stats?.revenueGrowth ?? 0) >= 0 ? "up" : "down"} + icon={CreditCard} + color="purple" + /> + + + )}
-

Recent Activity

+

+ Recent Activity +

-

Quick Analytics

+

+ Quick Analytics +

diff --git a/apps/admin/src/app/recommendations/page.tsx b/apps/admin/src/app/recommendations/page.tsx index 371b131..6a7df97 100644 --- a/apps/admin/src/app/recommendations/page.tsx +++ b/apps/admin/src/app/recommendations/page.tsx @@ -1,86 +1,40 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useUser } from "@clerk/nextjs"; import log from "@/lib/logger"; import { toast } from "@/lib/toast"; - -interface User { - id: string; - firstName: string; - lastName: string; - email: string; -} - -interface Recommendation { - id: string; - userId: string; - content: string; - recommendationText: string; - activityPlan: string; - dietPlan: string; - status: string; - createdAt: Date; -} +import { + useUsers, + useRecommendations, + useGenerateRecommendations, + useApproveRecommendation, + useUpdateRecommendation, + type Recommendation, +} from "@/hooks/use-api"; export default function RecommendationsPage() { const { user } = useUser(); - const [users, setUsers] = useState([]); - const [pendingRecommendations, setPendingRecommendations] = useState< - Recommendation[] - >([]); - const [loading, setLoading] = useState(true); - const [generating, setGenerating] = useState(null); const [useExternalModel, setUseExternalModel] = useState(false); - useEffect(() => { - fetchData(); - }, []); + const { data: users = [], isLoading: usersLoading } = useUsers(); + const { data: allRecommendations = [], isLoading: recsLoading } = + useRecommendations(); + const pendingRecommendations = allRecommendations.filter( + (r) => r.status === "pending", + ); - const fetchData = async () => { - try { - // Fetch users - API returns { success: true, data: { users: [...] }, meta: {...} } - const usersRes = await fetch("/api/users"); - const usersResult = await usersRes.json(); - const usersArray = usersResult.data?.users || usersResult.users || []; - setUsers(usersArray); - - // Fetch pending recommendations - API returns { success: true, data: { recommendations: [...] }, meta: {...} } - const recsRes = await fetch("/api/recommendations"); - const recsResult = await recsRes.json(); - const allRecs = - recsResult.data?.recommendations || recsResult.recommendations || []; - setPendingRecommendations( - allRecs.filter((r: Recommendation) => r.status === "pending"), - ); - } catch (error) { - log.error("Failed to fetch data", error); - } finally { - setLoading(false); - } - }; + const generateRec = useGenerateRecommendations(); + const approveRec = useApproveRecommendation(); + const updateRec = useUpdateRecommendation(); const handleGenerate = async (userId: string) => { - setGenerating(userId); try { - const res = await fetch("/api/recommendations/generate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ userId, useExternalModel }), - }); - - if (!res.ok) { - const error = await res.json(); - toast.error(`Error: ${error.error}`); - } else { - toast.success("Recommendation generated successfully!"); - fetchData(); // Refresh data - } + await generateRec.mutateAsync(userId); + toast.success("Recommendation generated successfully!"); } catch (error) { log.error("Failed to generate recommendation", error); toast.error("Failed to generate recommendation."); - } finally { - setGenerating(null); } }; @@ -89,25 +43,11 @@ export default function RecommendationsPage() { status: "approved" | "rejected", ) => { try { - const res = await fetch("/api/recommendations/approve", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - recommendationId, - status, - approvedBy: user?.id || "admin", - }), + await approveRec.mutateAsync({ + recommendationId, + approved: status === "approved", }); - - if (!res.ok) { - const errorData = await res.json(); - toast.error( - `Failed to update status: ${errorData.error || "Unknown error"}`, - ); - } else { - toast.success("Recommendation status updated"); - fetchData(); // Refresh data - } + toast.success("Recommendation status updated"); } catch (error) { log.error("Failed to approve recommendation", error); toast.error("Error processing request"); @@ -124,38 +64,24 @@ export default function RecommendationsPage() { newActivityPlan === null || newDietPlan === null ) { - // User cancelled one of the prompts return; } try { - const res = await fetch("/api/recommendations", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - id: rec.id, - content: newContent, - activityPlan: newActivityPlan, - dietPlan: newDietPlan, - }), + await updateRec.mutateAsync({ + id: rec.id, + content: newContent, + activityPlan: newActivityPlan, + dietPlan: newDietPlan, }); - - if (!res.ok) { - const errorData = await res.json(); - toast.error( - `Failed to update recommendation: ${errorData.error || "Unknown error"}`, - ); - } else { - toast.success("Recommendation updated successfully!"); - fetchData(); // Refresh data - } + toast.success("Recommendation updated successfully!"); } catch (error) { log.error("Failed to update recommendation", error); toast.error("Failed to update recommendation."); } }; - if (loading) { + if (usersLoading || recsLoading) { return (
Loading...
@@ -215,10 +141,10 @@ export default function RecommendationsPage() {
))} diff --git a/apps/admin/src/components/analytics/AnalyticsDashboard.tsx b/apps/admin/src/components/analytics/AnalyticsDashboard.tsx index ba99415..3b3ec94 100644 --- a/apps/admin/src/components/analytics/AnalyticsDashboard.tsx +++ b/apps/admin/src/components/analytics/AnalyticsDashboard.tsx @@ -1,75 +1,34 @@ -'use client' +"use client"; -import { useState, useEffect } from 'react' -import { UserGrowthChart } from '@/components/charts/UserGrowthChart' -import { MembershipDistributionChart } from '@/components/charts/MembershipDistributionChart' -import { RevenueChart } from '@/components/charts/RevenueChart' -import { Card, CardHeader, CardContent } from '@/components/ui/card' - -interface ChartData { - label: string - value: number - color?: string -} +import { UserGrowthChart } from "@/components/charts/UserGrowthChart"; +import { MembershipDistributionChart } from "@/components/charts/MembershipDistributionChart"; +import { RevenueChart } from "@/components/charts/RevenueChart"; +import { Card, CardHeader, CardContent } from "@/components/ui/card"; +import { useAnalytics } from "@/hooks/use-api"; export function AnalyticsDashboard() { - const [userGrowthData, setUserGrowthData] = useState([]) - const [membershipData, setMembershipData] = useState([]) - const [revenueData, setRevenueData] = useState([]) - const [loading, setLoading] = useState(true) + const { data: analytics, isLoading } = useAnalytics(6); - useEffect(() => { - fetchAnalyticsData() - }, []) + const userGrowthData = analytics?.userGrowth ?? []; + const membershipData = analytics?.membershipDistribution ?? []; + const revenueData = analytics?.revenue ?? []; - const fetchAnalyticsData = async () => { - setLoading(true) - try { - // Mock data for demonstration - replace with real API calls - const mockUserGrowth = [ - { label: 'Jan', value: 45 }, - { label: 'Feb', value: 52 }, - { label: 'Mar', value: 61 }, - { label: 'Apr', value: 58 }, - { label: 'May', value: 67 }, - { label: 'Jun', value: 74 }, - ] + const totalUsers = + userGrowthData.length > 0 + ? userGrowthData[userGrowthData.length - 1].value + : 0; + const totalRevenue = revenueData.reduce((sum, item) => sum + item.value, 0); + const activeMembers = membershipData.reduce( + (sum, item) => sum + item.value, + 0, + ); - const mockMembershipData = [ - { label: 'Basic', value: 45, color: '#6b7280' }, - { label: 'Premium', value: 28, color: '#3b82f6' }, - { label: 'VIP', value: 12, color: '#f59e0b' }, - ] - - const mockRevenueData = [ - { label: 'Jan', value: 12500, color: '#10b981' }, - { label: 'Feb', value: 14200, color: '#10b981' }, - { label: 'Mar', value: 16800, color: '#10b981' }, - { label: 'Apr', value: 15900, color: '#10b981' }, - { label: 'May', value: 18200, color: '#10b981' }, - { label: 'Jun', value: 19400, color: '#10b981' }, - ] - - setUserGrowthData(mockUserGrowth) - setMembershipData(mockMembershipData) - setRevenueData(mockRevenueData) - } catch (error) { - console.error('Failed to fetch analytics data:', error) - } finally { - setLoading(false) - } - } - - const totalUsers = userGrowthData.length > 0 ? userGrowthData[userGrowthData.length - 1].value : 0 - const totalRevenue = revenueData.reduce((sum, item) => sum + item.value, 0) - const activeMembers = membershipData.reduce((sum, item) => sum + item.value, 0) - - if (loading) { + if (isLoading) { return (
Loading analytics...
- ) + ); } return ( @@ -81,7 +40,9 @@ export function AnalyticsDashboard() {
-
{totalUsers}
+
+ {totalUsers} +
Total Users
@@ -90,7 +51,9 @@ export function AnalyticsDashboard() {
-
${totalRevenue.toLocaleString()}
+
+ ${totalRevenue.toLocaleString()} +
Total Revenue
@@ -99,7 +62,9 @@ export function AnalyticsDashboard() {
-
{activeMembers}
+
+ {activeMembers} +
Active Members
@@ -133,14 +98,14 @@ export function AnalyticsDashboard() { ({ + data={revenueData.map((item) => ({ category: item.label, value: item.value, - color: item.color + color: item.color, }))} />
- ) + ); } diff --git a/apps/admin/src/components/providers/QueryProvider.tsx b/apps/admin/src/components/providers/QueryProvider.tsx new file mode 100644 index 0000000..db31138 --- /dev/null +++ b/apps/admin/src/components/providers/QueryProvider.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +interface QueryProviderProps { + children: ReactNode; +} + +export function QueryProvider({ children }: QueryProviderProps) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + }, + }, + }), + ); + + return ( + {children} + ); +} diff --git a/apps/admin/src/components/ui/Sidebar.tsx b/apps/admin/src/components/ui/Sidebar.tsx index 2c21046..fc5bedb 100644 --- a/apps/admin/src/components/ui/Sidebar.tsx +++ b/apps/admin/src/components/ui/Sidebar.tsx @@ -13,40 +13,18 @@ import { Brain, } from "lucide-react"; import { UserButton, useUser } from "@clerk/nextjs"; - -interface Recommendation { - id: string; - status: string; -} +import { usePendingRecommendationsCount } from "@/hooks/use-api"; export function Sidebar() { const pathname = usePathname(); const { user } = useUser(); - const [pendingCount, setPendingCount] = useState(0); + const { data: pendingCount = 0 } = usePendingRecommendationsCount(); const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); - useEffect(() => { - const fetchPending = async () => { - try { - const res = await fetch("/api/recommendations"); - if (res.ok) { - const data = await res.json(); - const pending = (data.recommendations || []).filter( - (r: Recommendation) => r.status === "pending", - ); - setPendingCount(pending.length); - } - } catch (e) { - console.error("Failed to fetch pending recommendations", e); - } - }; - fetchPending(); - }, []); - const menuItems = [ { icon: LayoutDashboard, label: "Dashboard", href: "/" }, { icon: Users, label: "Users", href: "/users" }, diff --git a/apps/admin/src/components/ui/skeleton.tsx b/apps/admin/src/components/ui/skeleton.tsx new file mode 100644 index 0000000..98e1da4 --- /dev/null +++ b/apps/admin/src/components/ui/skeleton.tsx @@ -0,0 +1,57 @@ +import { cn } from "@/lib/utils"; + +interface SkeletonProps { + className?: string; +} + +export function Skeleton({ className }: SkeletonProps) { + return ( +
+ ); +} + +export function StatsCardSkeleton() { + return ( +
+
+ +
+ + +
+
+
+ ); +} + +export function TableSkeleton({ rows = 5 }: { rows?: number }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} + +export function CardSkeleton() { + return ( +
+ +
+ + + +
+
+ ); +} diff --git a/apps/admin/src/components/users/UserGrid.tsx b/apps/admin/src/components/users/UserGrid.tsx index 2f577dd..adfe37b 100644 --- a/apps/admin/src/components/users/UserGrid.tsx +++ b/apps/admin/src/components/users/UserGrid.tsx @@ -9,9 +9,10 @@ import { formatDate } from "@/lib/utils"; ModuleRegistry.registerModules([AllCommunityModule]); -function getTimeAgo(date: Date): string { +function getTimeAgo(date: Date | string): string { const now = new Date(); - const diffMs = now.getTime() - date.getTime(); + const d = typeof date === "string" ? new Date(date) : date; + const diffMs = now.getTime() - d.getTime(); const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); const diffDays = Math.floor(diffHours / 24); @@ -22,7 +23,7 @@ function getTimeAgo(date: Date): string { return `${diffDays}d ago`; } -interface User { +export interface User { id: string; email: string; firstName: string; @@ -31,15 +32,18 @@ interface User { phone?: string; gymId?: string; gymName?: string | null; - createdAt: Date; + createdAt?: string | Date; isCheckedIn?: boolean; - checkInTime?: Date; + checkInTime?: string | Date; + lastCheckInTime?: string | Date; + checkInsThisWeek?: number; + checkInsThisMonth?: number; client?: { id: string; membershipType: string; membershipStatus: string; - joinDate: Date; - lastVisit?: Date; + joinDate: string | Date; + lastVisit?: string | Date; }; } diff --git a/apps/admin/src/components/users/UserManagement.tsx b/apps/admin/src/components/users/UserManagement.tsx index cf849c0..24a1fa4 100644 --- a/apps/admin/src/components/users/UserManagement.tsx +++ b/apps/admin/src/components/users/UserManagement.tsx @@ -1,43 +1,23 @@ "use client"; -import { useState, useEffect } from "react"; -import { UserGrid } from "@/components/users/UserGrid"; -// import { Button } from "@/components/ui/button"; +import { useState } from "react"; +import { UserGrid, type User } from "@/components/users/UserGrid"; import { Card, CardHeader, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { useUser } from "@clerk/nextjs"; -import { getGymIdFromUser } from "@/lib/error-helpers"; import log from "@/lib/logger"; import { toast } from "@/lib/toast"; import { CreateUserModal } from "./CreateUserModal"; - -interface User { - id: string; - email: string; - firstName: string; - lastName: string; - role: string; - phone?: string; - gymId?: string; - createdAt: Date; - isCheckedIn?: boolean; - checkInTime?: Date; - lastCheckInTime?: Date; - checkInsThisWeek?: number; - checkInsThisMonth?: number; - client?: { - id: string; - membershipType: string; - membershipStatus: string; - joinDate: Date; - lastVisit?: Date; - }; -} +import { + useUsers, + useGyms, + useUpdateUser, + useDeleteUser, + useSendInvitation, +} from "@/hooks/use-api"; export function UserManagement() { const { user } = useUser(); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); const [filter, setFilter] = useState("all"); const [selectedUser, setSelectedUser] = useState(null); const [isEditing, setIsEditing] = useState(false); @@ -52,68 +32,17 @@ export function UserManagement() { gymId: string; } | null>(null); - // Active gyms for dropdown - const [gyms, setGyms] = useState>([]); - - // Load gyms when modal opens or refreshes - useEffect(() => { - if (isEditing) { - (async () => { - try { - const res = await fetch("/api/gyms"); - const data = await res.json(); - if (Array.isArray(data)) { - // map down to id and name to avoid extra payload use here - setGyms(data.map((g: any) => ({ id: g.id, name: g.name }))); - } else { - setGyms([]); - } - } catch { - setGyms([]); - } - })(); - } - }, [isEditing]); - - useEffect(() => { - fetchUsers(); - }, [filter]); - - const fetchUsers = async () => { - setLoading(true); - try { - const ts = Date.now(); - const url = - filter === "all" - ? `/api/users?ts=${ts}` - : `/api/users?role=${filter}&ts=${ts}`; - - log.debug("Fetching users", { url }); - const response = await fetch(url, { cache: "no-store" }); - log.debug("Users fetch response", { - ok: response.ok, - status: response.status, - }); - const responseData = await response.json(); - const data = responseData.data || responseData; // Handle both old and new API response formats - log.debug("Received users data", { - count: Array.isArray(data.users) ? data.users.length : 0, - sample: - data.users && data.users[0] - ? { - id: data.users[0].id, - gymId: data.users[0].gymId, - role: data.users[0].role, - } - : null, - }); - setUsers(data.users || []); - } catch (error) { - log.error("Failed to fetch users", error); - } finally { - setLoading(false); - } - }; + const { + data: users = [], + isLoading, + refetch, + } = useUsers({ + role: filter !== "all" ? filter : undefined, + }); + const { data: gyms = [] } = useGyms(); + const updateUser = useUpdateUser(); + const deleteUser = useDeleteUser(); + const sendInvitation = useSendInvitation(); const handleUserSelect = (user: User | null) => { setSelectedUser(user); @@ -144,17 +73,12 @@ export function UserManagement() { return; try { - const response = await fetch("/api/users", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids: users.map((u) => u.id) }), - }); - if (response.ok) { - fetchUsers(); - toast.success("Users deleted successfully"); - } else { - toast.error("Error deleting users"); - } + const deletePromises = users.map((u) => + fetch(`/api/users?id=${u.id}`, { method: "DELETE" }), + ); + await Promise.all(deletePromises); + refetch(); + toast.success("Users deleted successfully"); } catch (error) { log.error("Failed to delete users", error); } @@ -196,7 +120,7 @@ export function UserManagement() { }; const handleRefresh = () => { - fetchUsers(); + refetch(); }; const handleSaveEdit = async () => { @@ -204,7 +128,6 @@ export function UserManagement() { try { if (selectedUser) { - // Update existing user const payload = { id: selectedUser.id, email: editForm.email, @@ -214,80 +137,21 @@ export function UserManagement() { phone: editForm.phone, gymId: editForm.gymId === "" ? null : editForm.gymId, }; - log.debug("Updating user", payload); - const response = await fetch("/api/users", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - }); - log.debug("User update response", { - ok: response.ok, - status: response.status, - }); - if (response.ok) { - // Optimistically update local state so grid reflects changes immediately - setUsers((prev) => - prev.map((u) => - u.id === selectedUser.id - ? { - ...u, - email: editForm.email, - firstName: editForm.firstName, - lastName: editForm.lastName, - role: editForm.role, - phone: editForm.phone || undefined, - gymId: editForm.gymId === "" ? undefined : editForm.gymId, - } - : u, - ), - ); - setSelectedUser((prev) => - prev - ? { - ...prev, - email: editForm.email, - firstName: editForm.firstName, - lastName: editForm.lastName, - role: editForm.role, - phone: editForm.phone || undefined, - gymId: editForm.gymId === "" ? undefined : editForm.gymId, - } - : prev, - ); - setIsEditing(false); - setEditForm(null); - // Still re-fetch from server to ensure consistency - log.debug("Re-fetching users after successful edit"); - fetchUsers(); - toast.success("User updated successfully"); - } else { - const errText = await response.text().catch(() => ""); - log.error("User update failed", new Error(errText), { - status: response.status, - }); - toast.error("Error updating user"); - } - } else { - // Create (Invite) new user - const response = await fetch("/api/invitations", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - inviteeEmail: editForm.email, - roleAssigned: editForm.role, - gymId: editForm.gymId || undefined, - }), - }); - if (response.ok) { - setIsEditing(false); - setEditForm(null); - fetchUsers(); - toast.success("Invitation sent successfully!"); - } else { - const errorData = await response.json(); - toast.error(`Error sending invitation: ${errorData.error}`); - } + await updateUser.mutateAsync(payload); + setIsEditing(false); + setEditForm(null); + refetch(); + toast.success("User updated successfully"); + } else { + await sendInvitation.mutateAsync({ + email: editForm.email, + role: editForm.role, + }); + setIsEditing(false); + setEditForm(null); + refetch(); + toast.success("Invitation sent successfully!"); } } catch (error) { console.error(error); @@ -299,17 +163,11 @@ export function UserManagement() { if (!selectedUser) return; try { - const response = await fetch(`/api/users?id=${selectedUser.id}`, { - method: "DELETE", - }); - if (response.ok) { - setIsDeleting(false); - setSelectedUser(null); - fetchUsers(); - toast.success("User deleted successfully"); - } else { - toast.error("Error deleting user"); - } + await deleteUser.mutateAsync(selectedUser.id); + setIsDeleting(false); + setSelectedUser(null); + refetch(); + toast.success("User deleted successfully"); } catch (error) { log.error("Failed to delete user", error); } @@ -397,7 +255,7 @@ export function UserManagement() { onEditUser={handleEditUser} onDeleteUser={handleDeleteUser} onBulkDelete={handleBulkDelete} - loading={loading} + loading={isLoading} /> @@ -591,7 +449,9 @@ export function UserManagement() {

Joined:{" "} - {new Date(selectedUser.createdAt).toLocaleDateString()} + {selectedUser.createdAt + ? new Date(selectedUser.createdAt).toLocaleDateString() + : "N/A"}

@@ -653,7 +513,7 @@ export function UserManagement() { fetchUsers()} + onSuccess={() => refetch()} /> ); diff --git a/apps/admin/src/hooks/use-api.ts b/apps/admin/src/hooks/use-api.ts new file mode 100644 index 0000000..d9150d8 --- /dev/null +++ b/apps/admin/src/hooks/use-api.ts @@ -0,0 +1,380 @@ +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; + +export interface DashboardStats { + totalUsers: number; + activeClients: number; + totalRevenue: number; + revenueGrowth: number; +} + +export interface User { + id: string; + email: string; + firstName: string; + lastName: string; + role: string; + phone?: string; + gymId?: string; + gymName?: string | null; + createdAt?: string; + isCheckedIn?: boolean; + checkInTime?: string; + lastCheckInTime?: string; + checkInsThisWeek?: number; + checkInsThisMonth?: number; + client?: { + id: string; + membershipType: string; + membershipStatus: string; + joinDate: string; + lastVisit?: string; + }; +} + +export interface Recommendation { + id: string; + userId: string; + type: string; + title: string; + description: string; + content?: string; + recommendationText?: string; + activityPlan?: string; + dietPlan?: string; + status: "pending" | "approved" | "rejected"; + createdAt: string; + user?: User; +} + +export interface Gym { + id: string; + name: string; + address?: string; +} + +export interface AttendanceRecord { + id: string; + userId: string; + checkIn: string; + checkOut?: string; + date: string; +} + +async function fetchApi(url: string, options?: RequestInit): Promise { + const response = await fetch(url, { + ...options, + headers: { + "Content-Type": "application/json", + ...options?.headers, + }, + }); + + if (!response.ok) { + const error = await response + .json() + .catch(() => ({ error: "Request failed" })); + throw new Error(error.error || `HTTP ${response.status}`); + } + + return response.json(); +} + +export function useDashboardStats(gymId?: string) { + return useQuery({ + queryKey: ["dashboard-stats", gymId], + queryFn: () => { + const url = gymId + ? `/api/admin/stats?gymId=${gymId}` + : "/api/admin/stats"; + return fetchApi<{ data: { stats: DashboardStats } }>(url).then( + (res) => res.data?.stats, + ); + }, + }); +} + +export function useUsers(filters?: { + role?: string; + gymId?: string; + search?: string; +}) { + const params = new URLSearchParams(); + if (filters?.role) params.set("role", filters.role); + if (filters?.gymId) params.set("gymId", filters.gymId); + if (filters?.search) params.set("search", filters.search); + + const queryString = params.toString(); + const url = queryString ? `/api/users?${queryString}` : "/api/users"; + + return useQuery({ + queryKey: ["users", filters], + queryFn: () => + fetchApi<{ data: { users: User[] } }>(url).then( + (res) => res.data?.users ?? [], + ), + }); +} + +export function useUser(userId: string) { + return useQuery({ + queryKey: ["user", userId], + queryFn: () => + fetchApi<{ data: { user: User } }>(`/api/users?id=${userId}`).then( + (res) => res.data?.user, + ), + enabled: !!userId, + }); +} + +export function useRecommendations(filters?: { + userId?: string; + status?: string; +}) { + const params = new URLSearchParams(); + if (filters?.userId) params.set("userId", filters.userId); + if (filters?.status) params.set("status", filters.status); + + const queryString = params.toString(); + const url = queryString + ? `/api/recommendations?${queryString}` + : "/api/recommendations"; + + return useQuery({ + queryKey: ["recommendations", filters], + queryFn: () => + fetchApi<{ data: { recommendations: Recommendation[] } }>(url).then( + (res) => res.data?.recommendations ?? [], + ), + }); +} + +export function usePendingRecommendationsCount() { + return useQuery({ + queryKey: ["pending-recommendations-count"], + queryFn: () => + fetchApi<{ data: { recommendations: Recommendation[] } }>( + "/api/recommendations", + ).then( + (res) => + (res.data?.recommendations ?? []).filter( + (r) => r.status === "pending", + ).length, + ), + refetchInterval: 30000, + }); +} + +export function useGyms() { + return useQuery({ + queryKey: ["gyms"], + queryFn: () => + fetchApi<{ data: { gyms: Gym[] } }>("/api/gyms").then( + (res) => res.data?.gyms ?? [], + ), + }); +} + +export function useAttendance(filters?: { userId?: string; date?: string }) { + const params = new URLSearchParams(); + if (filters?.userId) params.set("userId", filters.userId); + if (filters?.date) params.set("date", filters.date); + + const queryString = params.toString(); + const url = queryString + ? `/api/admin/attendance?${queryString}` + : "/api/admin/attendance"; + + return useQuery({ + queryKey: ["attendance", filters], + queryFn: () => + fetchApi<{ data: { records: AttendanceRecord[] } }>(url).then( + (res) => res.data?.records ?? [], + ), + }); +} + +export function useCreateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: { + email: string; + firstName: string; + lastName: string; + role: string; + phone?: string; + gymId?: string; + }) => + fetchApi<{ data: { userId: string } }>("/api/users/create", { + method: "POST", + body: JSON.stringify(data), + }).then((res) => res.data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); + }, + }); +} + +export function useUpdateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: { id: string; [key: string]: unknown }) => + fetchApi<{ data: { success: boolean } }>(`/api/users?id=${data.id}`, { + method: "PUT", + body: JSON.stringify(data), + }), + onSuccess: (_, variables) => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["user", variables.id] }); + }, + }); +} + +export function useDeleteUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (userId: string) => + fetchApi<{ data: { success: boolean } }>(`/api/users?id=${userId}`, { + method: "DELETE", + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + queryClient.invalidateQueries({ queryKey: ["dashboard-stats"] }); + }, + }); +} + +export function useGenerateRecommendations() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (userId?: string) => + fetchApi<{ + data: { success: boolean; recommendations?: Recommendation[] }; + }>("/api/recommendations/generate", { + method: "POST", + body: userId ? JSON.stringify({ userId }) : undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["recommendations"] }); + queryClient.invalidateQueries({ + queryKey: ["pending-recommendations-count"], + }); + }, + }); +} + +export function useApproveRecommendation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: { recommendationId: string; approved: boolean }) => + fetchApi<{ data: { success: boolean } }>("/api/recommendations/approve", { + method: "POST", + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["recommendations"] }); + queryClient.invalidateQueries({ + queryKey: ["pending-recommendations-count"], + }); + }, + }); +} + +export function useUpdateRecommendation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: { + id: string; + content?: string; + activityPlan?: string; + dietPlan?: string; + }) => + fetchApi<{ data: { success: boolean } }>("/api/recommendations", { + method: "PUT", + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["recommendations"] }); + }, + }); +} + +export function useCheckIn() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (userId: string) => + fetchApi<{ data: { success: boolean; record: AttendanceRecord } }>( + "/api/attendance/check-in", + { + method: "POST", + body: JSON.stringify({ userId }), + }, + ), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["attendance"] }); + }, + }); +} + +export function useCheckOut() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (userId: string) => + fetchApi<{ data: { success: boolean } }>("/api/attendance/check-out", { + method: "POST", + body: JSON.stringify({ userId }), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["attendance"] }); + }, + }); +} + +export function useInvitations() { + return useQuery({ + queryKey: ["invitations"], + queryFn: () => + fetchApi<{ data: { invitations: unknown[] } }>("/api/invitations").then( + (res) => res.data?.invitations ?? [], + ), + }); +} + +export function useSendInvitation() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (data: { email: string; role: string }) => + fetchApi<{ data: { success: boolean } }>("/api/invitations", { + method: "POST", + body: JSON.stringify(data), + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["invitations"] }); + }, + }); +} + +export interface AnalyticsData { + userGrowth: { label: string; value: number }[]; + membershipDistribution: { label: string; value: number; color: string }[]; + revenue: { label: string; value: number; color: string }[]; +} + +export function useAnalytics(months: number = 6) { + return useQuery({ + queryKey: ["analytics", months], + queryFn: () => + fetchApi<{ data: { analytics: AnalyticsData } }>( + `/api/admin/analytics?months=${months}`, + ).then((res) => res.data?.analytics), + }); +}