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 (
- )
+ );
}
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),
+ });
+}