setings page enhanced

gym managment added
This commit is contained in:
echo 2026-03-18 06:23:40 +01:00
parent 624cdfc45c
commit bb9c675421
3 changed files with 465 additions and 71 deletions

View File

@ -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 });
}
}

View File

@ -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<string, number> = {
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<string, number> = {
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 });
}
}

View File

@ -9,6 +9,10 @@ import {
AlertTriangle, AlertTriangle,
Check, Check,
Loader2, Loader2,
Trash2,
Users,
CalendarCheck,
TrendingUp,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs"; import { useUser } from "@clerk/nextjs";
@ -26,6 +30,21 @@ interface Gym {
location?: string | null; location?: string | null;
status: "active" | "inactive"; status: "active" | "inactive";
adminUserId: string; 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() { export default function SettingsPage() {
@ -46,6 +65,13 @@ export default function SettingsPage() {
type: "success" | "error"; type: "success" | "error";
text: string; text: string;
} | null>(null); } | null>(null);
// Selected gym for details
const [selectedGym, setSelectedGym] = useState<Gym | null>(null);
const [gymStats, setGymStats] = useState<GymStats | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [deletingGym, setDeletingGym] = useState(false);
// Create Gym modal state // Create Gym modal state
const [showCreateGym, setShowCreateGym] = useState(false); const [showCreateGym, setShowCreateGym] = useState(false);
const [gymName, setGymName] = useState(""); 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(() => { useEffect(() => {
fetchBackups(); fetchBackups();
fetchGyms(); fetchGyms();
}, []); }, []);
useEffect(() => {
if (selectedGym) {
fetchGymStats(selectedGym.id);
} else {
setGymStats(null);
}
}, [selectedGym]);
const handleCreateBackup = async () => { const handleCreateBackup = async () => {
setCreatingBackup(true); setCreatingBackup(true);
setMessage(null); setMessage(null);
@ -111,7 +159,6 @@ export default function SettingsPage() {
try { try {
await axios.post("/api/admin/backups/restore", { filename }); await axios.post("/api/admin/backups/restore", { filename });
setMessage({ type: "success", text: "Database restored successfully" }); setMessage({ type: "success", text: "Database restored successfully" });
// Optional: Refresh page or force re-login if session is invalidated
} catch (error) { } catch (error) {
log.error("Failed to restore backup", error); log.error("Failed to restore backup", error);
setMessage({ type: "error", text: "Failed to restore backup" }); setMessage({ type: "error", text: "Failed to restore backup" });
@ -135,21 +182,39 @@ export default function SettingsPage() {
return new Date(dateString).toLocaleString(); 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); setGymMessage(null);
try { try {
// Update current user's gym selection await axios.delete(`/api/gyms/${gymId}`);
await axios.patch("/api/users/gym", { gymId }); setGymMessage({ type: "success", text: "Gym deleted successfully" });
setGymMessage({ setSelectedGym(null);
type: "success", setGymStats(null);
text: gymId ? "Gym selected successfully" : "Proceeding without gym", fetchGyms();
});
} catch (error) { } catch (error) {
log.error("Failed to set gym", error); log.error("Failed to delete gym", error);
setGymMessage({ type: "error", text: "Failed to set gym" }); setGymMessage({ type: "error", text: "Failed to delete gym" });
} finally {
setDeletingGym(false);
} }
}; };
const userRole = (user?.publicMetadata?.role as string) ?? "client";
const isSuperAdmin = userRole === "superAdmin";
return ( return (
<div className="space-y-8 p-8"> <div className="space-y-8 p-8">
<div> <div>
@ -159,7 +224,7 @@ export default function SettingsPage() {
</p> </p>
</div> </div>
{/* Gym Picker */} {/* Gym Management */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> <div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@ -168,10 +233,12 @@ export default function SettingsPage() {
</div> </div>
<div> <div>
<h3 className="text-xl font-bold text-slate-900"> <h3 className="text-xl font-bold text-slate-900">
Gym Selection Gym Management
</h3> </h3>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
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"}
</p> </p>
<p className="text-xs text-blue-600 mt-1"> <p className="text-xs text-blue-600 mt-1">
{user ? ( {user ? (
@ -180,11 +247,6 @@ export default function SettingsPage() {
<span className="font-medium"> <span className="font-medium">
{String(user.publicMetadata?.role ?? "unknown")} {String(user.publicMetadata?.role ?? "unknown")}
</span> </span>
{" • "}
Gym ID:{" "}
<span className="font-medium">
{String(user.publicMetadata?.gymId ?? "none")}
</span>
</> </>
) : ( ) : (
"Loading user metadata..." "Loading user metadata..."
@ -196,6 +258,8 @@ export default function SettingsPage() {
<Button <Button
onClick={fetchGyms} onClick={fetchGyms}
disabled={gymsLoading} disabled={gymsLoading}
variant="outline"
size="sm"
className="flex items-center gap-2" className="flex items-center gap-2"
> >
{gymsLoading ? ( {gymsLoading ? (
@ -203,14 +267,17 @@ export default function SettingsPage() {
) : ( ) : (
<RefreshCw className="w-4 h-4" /> <RefreshCw className="w-4 h-4" />
)} )}
Refresh Gyms Refresh
</Button>
<Button
onClick={() => setShowCreateGym(true)}
className="flex items-center gap-2"
>
Create Gym
</Button> </Button>
{isSuperAdmin && (
<Button
onClick={() => setShowCreateGym(true)}
size="sm"
className="flex items-center gap-2"
>
Create Gym
</Button>
)}
</div> </div>
</div> </div>
@ -226,6 +293,7 @@ export default function SettingsPage() {
{gymMessage.text} {gymMessage.text}
</div> </div>
)} )}
{showCreateGym && ( {showCreateGym && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full"> <div className="bg-white p-6 rounded-lg shadow-lg max-w-md w-full">
@ -310,59 +378,191 @@ export default function SettingsPage() {
</div> </div>
)} )}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="border rounded-lg p-4 flex flex-col justify-between"> {/* Gym List */}
<div> <div className="lg:col-span-1 space-y-4">
<h4 className="font-semibold text-slate-900"> <h4 className="font-semibold text-slate-900">Gyms</h4>
Proceed without gym {gymsLoading ? (
</h4> <div className="flex items-center justify-center p-8 text-slate-500">
<p className="text-sm text-slate-600 mt-1"> <Loader2 className="w-5 h-5 animate-spin mr-2" />
You can select a gym later. Loading...
</p> </div>
</div> ) : gyms.length === 0 ? (
<Button <div className="p-4 text-center text-slate-500">
variant="outline" No active gyms found.
className="mt-4" </div>
onClick={() => handleSelectGym(null)} ) : (
> <div className="space-y-2 max-h-96 overflow-y-auto">
Proceed without gym {gyms.map((gym) => (
</Button> <div
key={gym.id}
className={`border rounded-lg p-3 cursor-pointer transition-colors ${
selectedGym?.id === gym.id
? "border-blue-500 bg-blue-50"
: "hover:bg-slate-50"
}`}
onClick={() => handleSelectGym(gym)}
>
<div className="flex items-center justify-between">
<div>
<h5 className="font-medium text-slate-900">
{gym.name}
</h5>
<p className="text-xs text-slate-500">
{gym.location || "No location"}
</p>
</div>
<span
className={`text-xs px-2 py-1 rounded ${
gym.status === "active"
? "bg-green-100 text-green-700"
: "bg-red-100 text-red-700"
}`}
>
{gym.status}
</span>
</div>
</div>
))}
</div>
)}
</div> </div>
{gymsLoading ? ( {/* Gym Details / Stats */}
<div className="col-span-full flex items-center justify-center p-8 text-slate-500"> <div className="lg:col-span-2">
<Loader2 className="w-5 h-5 animate-spin mr-2" /> {selectedGym ? (
Loading gyms... <div className="space-y-4">
</div> <div className="flex items-center justify-between">
) : gyms.length === 0 ? ( <h4 className="font-semibold text-slate-900">
<div className="col-span-full p-8 text-center text-slate-500"> {selectedGym.name} - Details
No active gyms found. </h4>
</div> {isSuperAdmin && (
) : ( <Button
gyms.map((gym) => ( variant="ghost"
<div size="sm"
key={gym.id} className="text-red-600 hover:text-red-700 hover:bg-red-50"
className="border rounded-lg p-4 flex flex-col justify-between" onClick={() => handleDeleteGym(selectedGym.id)}
> disabled={deletingGym}
<div> >
<h4 className="font-semibold text-slate-900">{gym.name}</h4> {deletingGym ? (
<p className="text-sm text-slate-600 mt-1"> <Loader2 className="w-4 h-4 animate-spin" />
{gym.location || "No location provided"} ) : (
</p> <Trash2 className="w-4 h-4 mr-1" />
)}
Delete Gym
</Button>
)}
</div> </div>
<Button
variant="default" {/* Gym Info */}
className="mt-4" <div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg">
onClick={() => handleSelectGym(gym.id)} <div>
> <p className="text-xs text-slate-500">Location</p>
Select this gym <p className="font-medium">
</Button> {selectedGym.location || "Not specified"}
</p>
</div>
<div>
<p className="text-xs text-slate-500">Status</p>
<p className="font-medium capitalize">
{selectedGym.status}
</p>
</div>
</div>
{/* Stats */}
{statsLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="w-6 h-6 animate-spin text-slate-400" />
</div>
) : gymStats ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="p-4 bg-blue-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-blue-600" />
<span className="text-xs text-blue-600">
Total Users
</span>
</div>
<p className="text-2xl font-bold text-blue-700">
{gymStats.totalUsers}
</p>
</div>
<div className="p-4 bg-green-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<CalendarCheck className="w-4 h-4 text-green-600" />
<span className="text-xs text-green-600">
Active Clients
</span>
</div>
<p className="text-2xl font-bold text-green-700">
{gymStats.activeClients}
</p>
</div>
<div className="p-4 bg-purple-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<TrendingUp className="w-4 h-4 text-purple-600" />
<span className="text-xs text-purple-600">
Check-ins (30d)
</span>
</div>
<p className="text-2xl font-bold text-purple-700">
{gymStats.attendanceLast30Days}
</p>
</div>
<div className="p-4 bg-orange-50 rounded-lg">
<div className="flex items-center gap-2 mb-1">
<Users className="w-4 h-4 text-orange-600" />
<span className="text-xs text-orange-600">
Trainers
</span>
</div>
<p className="text-2xl font-bold text-orange-700">
{gymStats.trainers}
</p>
</div>
</div>
) : null}
{/* Membership Distribution */}
{gymStats && (
<div className="mt-4">
<h5 className="text-sm font-medium text-slate-700 mb-2">
Membership Distribution
</h5>
<div className="flex gap-4">
<div className="flex-1 p-3 bg-slate-100 rounded-lg text-center">
<p className="text-2xl font-bold text-slate-700">
{gymStats.membershipStats.basic}
</p>
<p className="text-xs text-slate-500">Basic</p>
</div>
<div className="flex-1 p-3 bg-blue-50 rounded-lg text-center">
<p className="text-2xl font-bold text-blue-700">
{gymStats.membershipStats.premium}
</p>
<p className="text-xs text-blue-500">Premium</p>
</div>
<div className="flex-1 p-3 bg-yellow-50 rounded-lg text-center">
<p className="text-2xl font-bold text-yellow-700">
{gymStats.membershipStats.vip}
</p>
<p className="text-xs text-yellow-600">VIP</p>
</div>
</div>
</div>
)}
</div> </div>
)) ) : (
)} <div className="flex items-center justify-center h-64 bg-slate-50 rounded-lg">
<p className="text-slate-500">Select a gym to view details</p>
</div>
)}
</div>
</div> </div>
</div> </div>
{/* Database Management */}
<div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6"> <div className="bg-white rounded-xl shadow-sm border border-slate-100 p-6">
<div className="flex items-center justify-between mb-6"> <div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">