diff --git a/apps/admin/src/app/api/gyms/[id]/route.ts b/apps/admin/src/app/api/gyms/[id]/route.ts new file mode 100644 index 0000000..5897965 --- /dev/null +++ b/apps/admin/src/app/api/gyms/[id]/route.ts @@ -0,0 +1,89 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { eq, sql } from "@fitai/database"; +import { db, users as usersTable } from "@fitai/database"; +import { ensureUserSynced } from "@/lib/sync-user"; +import log from "@/lib/logger"; + +async function ensureGymsTable() { + await db.run(sql` + CREATE TABLE IF NOT EXISTS gyms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + location TEXT, + status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active', + admin_user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); +} + +// DELETE /api/gyms/[id] +// Delete a gym (soft delete - mark as inactive) +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: gymId } = await params; + const { userId } = await auth(); + + if (!userId) { + return new NextResponse("Unauthorized", { status: 401 }); + } + + // Ensure user is synced + const currentUser = await ensureUserSynced(userId, { + getUserById: async (id: string) => { + const row = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, id)) + .get(); + return row + ? { + id: row.id, + email: row.email, + firstName: row.firstName, + lastName: row.lastName, + password: row.password ?? "", + phone: row.phone ?? undefined, + role: row.role, + imageUrl: undefined, + createdAt: new Date(row.createdAt), + updatedAt: new Date(row.updatedAt), + } + : null; + }, + } as any); + + // Only superAdmin can delete gyms + if (!currentUser || currentUser.role !== "superAdmin") { + return new NextResponse("Forbidden - Only superAdmin can delete gyms", { + status: 403, + }); + } + + await ensureGymsTable(); + + // Check if gym exists + const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`); + if (gymRows.length === 0) { + return new NextResponse("Gym not found", { status: 404 }); + } + + // Soft delete - mark as inactive + await db.run( + sql`UPDATE gyms SET status = 'inactive', updated_at = ${Date.now()} WHERE id = ${gymId}`, + ); + + return NextResponse.json({ + success: true, + message: "Gym deleted successfully", + }); + } catch (error) { + log.error("Failed to delete gym", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/apps/admin/src/app/api/gyms/[id]/stats/route.ts b/apps/admin/src/app/api/gyms/[id]/stats/route.ts new file mode 100644 index 0000000..e4b0938 --- /dev/null +++ b/apps/admin/src/app/api/gyms/[id]/stats/route.ts @@ -0,0 +1,105 @@ +import { NextResponse } from "next/server"; +import { eq, sql } from "@fitai/database"; +import { db } from "@fitai/database"; +import log from "@/lib/logger"; + +async function ensureGymsTable() { + await db.run(sql` + CREATE TABLE IF NOT EXISTS gyms ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + location TEXT, + status TEXT NOT NULL CHECK (status IN ('active','inactive')) DEFAULT 'active', + admin_user_id TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `); +} + +// GET /api/gyms/[id]/stats +// Get stats for a specific gym +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id: gymId } = await params; + await ensureGymsTable(); + + // Get gym info + const gymRows = await db.all(sql`SELECT * FROM gyms WHERE id = ${gymId}`); + if (gymRows.length === 0) { + return new NextResponse("Gym not found", { status: 404 }); + } + const gym = gymRows[0]; + + // Get user counts + const usersResult = await db.all( + sql`SELECT role, COUNT(*) as count FROM users WHERE gym_id = ${gymId} GROUP BY role`, + ); + + const userCounts: Record = { + admin: 0, + trainer: 0, + client: 0, + }; + for (const row of usersResult as any[]) { + if (row.role in userCounts) { + userCounts[row.role] = row.count; + } + } + + // Get client stats + const clientsResult = (await db.all( + sql`SELECT + membership_type, + membership_status, + COUNT(*) as count + FROM clients + WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId}) + GROUP BY membership_type, membership_status`, + )) as any[]; + + const membershipStats: Record = { + basic: 0, + premium: 0, + vip: 0, + }; + const activeClients = clientsResult.filter( + (c: any) => c.membership_status === "active", + ); + for (const row of activeClients) { + if (row.membership_type in membershipStats) { + membershipStats[row.membership_type] = row.count; + } + } + + // Get recent activity (attendance in last 30 days) + const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + const attendanceResult = (await db.all( + sql`SELECT COUNT(*) as count FROM attendance + WHERE user_id IN (SELECT id FROM users WHERE gym_id = ${gymId}) + AND check_in_time > ${thirtyDaysAgo}`, + )) as any[]; + const attendanceCount = attendanceResult[0]?.count || 0; + + const stats = { + totalUsers: userCounts.admin + userCounts.trainer + userCounts.client, + admins: userCounts.admin, + trainers: userCounts.trainer, + clients: userCounts.client, + membershipStats, + activeClients: activeClients.reduce( + (sum: number, c: any) => sum + c.count, + 0, + ), + attendanceLast30Days: attendanceCount, + }; + + return NextResponse.json({ gym, stats }); + } catch (error) { + log.error("Failed to get gym stats", error); + return new NextResponse("Internal Server Error", { status: 500 }); + } +} diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx index e22ac65..c1d7317 100644 --- a/apps/admin/src/app/settings/page.tsx +++ b/apps/admin/src/app/settings/page.tsx @@ -9,6 +9,10 @@ import { AlertTriangle, Check, Loader2, + Trash2, + Users, + CalendarCheck, + TrendingUp, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useUser } from "@clerk/nextjs"; @@ -26,6 +30,21 @@ interface Gym { location?: string | null; status: "active" | "inactive"; adminUserId: string; + createdAt?: number; +} + +interface GymStats { + totalUsers: number; + admins: number; + trainers: number; + clients: number; + membershipStats: { + basic: number; + premium: number; + vip: number; + }; + activeClients: number; + attendanceLast30Days: number; } export default function SettingsPage() { @@ -46,6 +65,13 @@ export default function SettingsPage() { type: "success" | "error"; text: string; } | null>(null); + + // Selected gym for details + const [selectedGym, setSelectedGym] = useState(null); + const [gymStats, setGymStats] = useState(null); + const [statsLoading, setStatsLoading] = useState(false); + const [deletingGym, setDeletingGym] = useState(false); + // Create Gym modal state const [showCreateGym, setShowCreateGym] = useState(false); const [gymName, setGymName] = useState(""); @@ -77,11 +103,33 @@ export default function SettingsPage() { } }; + const fetchGymStats = async (gymId: string) => { + setStatsLoading(true); + try { + const res = await axios.get(`/api/gyms/${gymId}/stats`); + if (res.data?.stats) { + setGymStats(res.data.stats); + } + } catch (error) { + log.error("Failed to fetch gym stats", error); + } finally { + setStatsLoading(false); + } + }; + useEffect(() => { fetchBackups(); fetchGyms(); }, []); + useEffect(() => { + if (selectedGym) { + fetchGymStats(selectedGym.id); + } else { + setGymStats(null); + } + }, [selectedGym]); + const handleCreateBackup = async () => { setCreatingBackup(true); setMessage(null); @@ -111,7 +159,6 @@ export default function SettingsPage() { try { await axios.post("/api/admin/backups/restore", { filename }); setMessage({ type: "success", text: "Database restored successfully" }); - // Optional: Refresh page or force re-login if session is invalidated } catch (error) { log.error("Failed to restore backup", error); setMessage({ type: "error", text: "Failed to restore backup" }); @@ -135,21 +182,39 @@ export default function SettingsPage() { return new Date(dateString).toLocaleString(); }; - const handleSelectGym = async (gymId: string | null) => { + const handleSelectGym = async (gym: Gym | null) => { + setSelectedGym(gym); + setGymStats(null); + }; + + const handleDeleteGym = async (gymId: string) => { + if ( + !window.confirm( + "Are you sure you want to delete this gym? This action cannot be undone.", + ) + ) { + return; + } + + setDeletingGym(true); setGymMessage(null); try { - // Update current user's gym selection - await axios.patch("/api/users/gym", { gymId }); - setGymMessage({ - type: "success", - text: gymId ? "Gym selected successfully" : "Proceeding without gym", - }); + await axios.delete(`/api/gyms/${gymId}`); + setGymMessage({ type: "success", text: "Gym deleted successfully" }); + setSelectedGym(null); + setGymStats(null); + fetchGyms(); } catch (error) { - log.error("Failed to set gym", error); - setGymMessage({ type: "error", text: "Failed to set gym" }); + log.error("Failed to delete gym", error); + setGymMessage({ type: "error", text: "Failed to delete gym" }); + } finally { + setDeletingGym(false); } }; + const userRole = (user?.publicMetadata?.role as string) ?? "client"; + const isSuperAdmin = userRole === "superAdmin"; + return (
@@ -159,7 +224,7 @@ export default function SettingsPage() {

- {/* Gym Picker */} + {/* Gym Management */}
@@ -168,10 +233,12 @@ export default function SettingsPage() {

- Gym Selection + Gym Management

- Select your gym or proceed without a gym + {isSuperAdmin + ? "Select a gym to view details, create or delete gyms" + : "Select your gym or proceed without a gym"}

{user ? ( @@ -180,11 +247,6 @@ export default function SettingsPage() { {String(user.publicMetadata?.role ?? "unknown")} - {" • "} - Gym ID:{" "} - - {String(user.publicMetadata?.gymId ?? "none")} - ) : ( "Loading user metadata..." @@ -196,6 +258,8 @@ export default function SettingsPage() { - + {isSuperAdmin && ( + + )}

@@ -226,6 +293,7 @@ export default function SettingsPage() { {gymMessage.text}
)} + {showCreateGym && (
@@ -310,59 +378,191 @@ export default function SettingsPage() {
)} -
-
-
-

- Proceed without gym -

-

- You can select a gym later. -

-
- +
+ {/* Gym List */} +
+

Gyms

+ {gymsLoading ? ( +
+ + Loading... +
+ ) : gyms.length === 0 ? ( +
+ No active gyms found. +
+ ) : ( +
+ {gyms.map((gym) => ( +
handleSelectGym(gym)} + > +
+
+
+ {gym.name} +
+

+ {gym.location || "No location"} +

+
+ + {gym.status} + +
+
+ ))} +
+ )}
- {gymsLoading ? ( -
- - Loading gyms... -
- ) : gyms.length === 0 ? ( -
- No active gyms found. -
- ) : ( - gyms.map((gym) => ( -
-
-

{gym.name}

-

- {gym.location || "No location provided"} -

+ {/* Gym Details / Stats */} +
+ {selectedGym ? ( +
+
+

+ {selectedGym.name} - Details +

+ {isSuperAdmin && ( + + )}
- + + {/* Gym Info */} +
+
+

Location

+

+ {selectedGym.location || "Not specified"} +

+
+
+

Status

+

+ {selectedGym.status} +

+
+
+ + {/* Stats */} + {statsLoading ? ( +
+ +
+ ) : gymStats ? ( +
+
+
+ + + Total Users + +
+

+ {gymStats.totalUsers} +

+
+
+
+ + + Active Clients + +
+

+ {gymStats.activeClients} +

+
+
+
+ + + Check-ins (30d) + +
+

+ {gymStats.attendanceLast30Days} +

+
+
+
+ + + Trainers + +
+

+ {gymStats.trainers} +

+
+
+ ) : null} + + {/* Membership Distribution */} + {gymStats && ( +
+
+ Membership Distribution +
+
+
+

+ {gymStats.membershipStats.basic} +

+

Basic

+
+
+

+ {gymStats.membershipStats.premium} +

+

Premium

+
+
+

+ {gymStats.membershipStats.vip} +

+

VIP

+
+
+
+ )}
- )) - )} + ) : ( +
+

Select a gym to view details

+
+ )} +
+ {/* Database Management */}