973 lines
35 KiB
TypeScript
973 lines
35 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import axios from "axios";
|
|
import {
|
|
Database,
|
|
Download,
|
|
RefreshCw,
|
|
AlertTriangle,
|
|
Check,
|
|
Loader2,
|
|
Trash2,
|
|
Users,
|
|
CalendarCheck,
|
|
TrendingUp,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useUser } from "@clerk/nextjs";
|
|
import log from "@/lib/logger";
|
|
import { MEMBERSHIP_FEATURES } from "@/lib/membership/features";
|
|
|
|
interface Backup {
|
|
name: string;
|
|
size: number;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface Gym {
|
|
id: string;
|
|
name: string;
|
|
location?: string | null;
|
|
latitude?: number | null;
|
|
longitude?: number | null;
|
|
geofenceRadiusMeters?: number | null;
|
|
geofenceEnabled?: boolean;
|
|
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() {
|
|
const { user } = useUser();
|
|
const [backups, setBackups] = useState<Backup[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [creatingBackup, setCreatingBackup] = useState(false);
|
|
const [restoring, setRestoring] = useState<string | null>(null);
|
|
const [message, setMessage] = useState<{
|
|
type: "success" | "error";
|
|
text: string;
|
|
} | null>(null);
|
|
|
|
// Gym picker state
|
|
const [gyms, setGyms] = useState<Gym[]>([]);
|
|
const [gymsLoading, setGymsLoading] = useState<boolean>(true);
|
|
const [gymMessage, setGymMessage] = useState<{
|
|
type: "success" | "error";
|
|
text: string;
|
|
} | 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);
|
|
const [savingGeofence, setSavingGeofence] = useState(false);
|
|
const [geofenceLatitude, setGeofenceLatitude] = useState("");
|
|
const [geofenceLongitude, setGeofenceLongitude] = useState("");
|
|
const [geofenceRadiusMeters, setGeofenceRadiusMeters] = useState("30");
|
|
const [geofenceEnabled, setGeofenceEnabled] = useState(true);
|
|
|
|
// Create Gym modal state
|
|
const [showCreateGym, setShowCreateGym] = useState(false);
|
|
const [gymName, setGymName] = useState("");
|
|
const [gymLocation, setGymLocation] = useState("");
|
|
const [creatingGym, setCreatingGym] = useState(false);
|
|
|
|
const fetchBackups = async () => {
|
|
try {
|
|
const response = await axios.get("/api/admin/backups");
|
|
setBackups(response.data);
|
|
} catch (error) {
|
|
log.error("Failed to fetch backups", error);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const fetchGyms = async () => {
|
|
setGymsLoading(true);
|
|
setGymMessage(null);
|
|
try {
|
|
const res = await axios.get("/api/gyms");
|
|
setGyms(Array.isArray(res.data) ? res.data : []);
|
|
} catch (error) {
|
|
log.error("Failed to fetch gyms", error);
|
|
setGymMessage({ type: "error", text: "Failed to load gyms" });
|
|
} finally {
|
|
setGymsLoading(false);
|
|
}
|
|
};
|
|
|
|
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);
|
|
try {
|
|
await axios.post("/api/admin/backups");
|
|
await fetchBackups();
|
|
setMessage({ type: "success", text: "Backup created successfully" });
|
|
} catch (error) {
|
|
log.error("Failed to create backup", error);
|
|
setMessage({ type: "error", text: "Failed to create backup" });
|
|
} finally {
|
|
setCreatingBackup(false);
|
|
}
|
|
};
|
|
|
|
const handleRestore = async (filename: string) => {
|
|
if (
|
|
!window.confirm(
|
|
`Are you sure you want to restore from ${filename}? This will overwrite the current database.`,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
setRestoring(filename);
|
|
setMessage(null);
|
|
try {
|
|
await axios.post("/api/admin/backups/restore", { filename });
|
|
setMessage({ type: "success", text: "Database restored successfully" });
|
|
} catch (error) {
|
|
log.error("Failed to restore backup", error);
|
|
setMessage({ type: "error", text: "Failed to restore backup" });
|
|
} finally {
|
|
setRestoring(null);
|
|
}
|
|
};
|
|
|
|
const formatSize = (bytes: number) => {
|
|
const units = ["B", "KB", "MB", "GB"];
|
|
let size = bytes;
|
|
let unitIndex = 0;
|
|
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
size /= 1024;
|
|
unitIndex++;
|
|
}
|
|
return `${size.toFixed(2)} ${units[unitIndex]}`;
|
|
};
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Date(dateString).toLocaleString();
|
|
};
|
|
|
|
const handleSelectGym = async (gym: Gym | null) => {
|
|
setSelectedGym(gym);
|
|
setGymStats(null);
|
|
|
|
if (gym) {
|
|
setGeofenceLatitude(
|
|
gym.latitude !== null && gym.latitude !== undefined
|
|
? String(gym.latitude)
|
|
: "",
|
|
);
|
|
setGeofenceLongitude(
|
|
gym.longitude !== null && gym.longitude !== undefined
|
|
? String(gym.longitude)
|
|
: "",
|
|
);
|
|
setGeofenceRadiusMeters(String(gym.geofenceRadiusMeters ?? 30));
|
|
setGeofenceEnabled(gym.geofenceEnabled ?? true);
|
|
}
|
|
};
|
|
|
|
const handleSaveGeofence = async () => {
|
|
if (!selectedGym) return;
|
|
|
|
const latitude =
|
|
geofenceLatitude.trim() === "" ? null : Number(geofenceLatitude);
|
|
const longitude =
|
|
geofenceLongitude.trim() === "" ? null : Number(geofenceLongitude);
|
|
const radius = Number(geofenceRadiusMeters);
|
|
|
|
if (
|
|
latitude !== null &&
|
|
(!Number.isFinite(latitude) || latitude < -90 || latitude > 90)
|
|
) {
|
|
setGymMessage({
|
|
type: "error",
|
|
text: "Latitude must be between -90 and 90",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (
|
|
longitude !== null &&
|
|
(!Number.isFinite(longitude) || longitude < -180 || longitude > 180)
|
|
) {
|
|
setGymMessage({
|
|
type: "error",
|
|
text: "Longitude must be between -180 and 180",
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (!Number.isFinite(radius) || radius <= 0) {
|
|
setGymMessage({
|
|
type: "error",
|
|
text: "Radius must be a positive number",
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSavingGeofence(true);
|
|
setGymMessage(null);
|
|
|
|
try {
|
|
const response = await axios.patch(`/api/gyms/${selectedGym.id}`, {
|
|
latitude,
|
|
longitude,
|
|
geofenceRadiusMeters: radius,
|
|
geofenceEnabled,
|
|
});
|
|
setGymMessage({ type: "success", text: "Geofence settings updated" });
|
|
const updatedGym = response.data as Gym;
|
|
setSelectedGym(updatedGym);
|
|
setGyms((prev) =>
|
|
prev.map((gym) => (gym.id === updatedGym.id ? updatedGym : gym)),
|
|
);
|
|
} catch (error) {
|
|
log.error("Failed to update geofence settings", error);
|
|
setGymMessage({
|
|
type: "error",
|
|
text: "Failed to update geofence settings",
|
|
});
|
|
} finally {
|
|
setSavingGeofence(false);
|
|
}
|
|
};
|
|
|
|
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 {
|
|
const response = await axios.delete(`/api/gyms/${gymId}`);
|
|
log.info("Delete gym response:", response.data);
|
|
setGymMessage({ type: "success", text: "Gym deleted successfully" });
|
|
setSelectedGym(null);
|
|
setGymStats(null);
|
|
await fetchGyms();
|
|
} catch (error: any) {
|
|
log.error("Failed to delete gym", error);
|
|
const errorMessage =
|
|
error.response?.data?.error ||
|
|
error.response?.data ||
|
|
error.message ||
|
|
"Failed to delete gym";
|
|
setGymMessage({ type: "error", text: errorMessage });
|
|
} finally {
|
|
setDeletingGym(false);
|
|
}
|
|
};
|
|
|
|
const userRole = (user?.publicMetadata?.role as string) ?? "client";
|
|
const isSuperAdmin = userRole === "superAdmin";
|
|
|
|
return (
|
|
<div className="space-y-8 p-8">
|
|
<div>
|
|
<h2 className="text-3xl font-bold text-slate-900">Settings</h2>
|
|
<p className="text-slate-500 mt-2">
|
|
Manage your application settings and database.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Gym Management */}
|
|
<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 gap-3">
|
|
<div className="p-2 bg-blue-50 rounded-lg">
|
|
<Database className="w-6 h-6 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-xl font-bold text-slate-900">
|
|
Gym Management
|
|
</h3>
|
|
<p className="text-sm text-slate-500">
|
|
{isSuperAdmin
|
|
? "Select a gym to view details, create or delete gyms"
|
|
: "Select your gym or proceed without a gym"}
|
|
</p>
|
|
<p className="text-xs text-blue-600 mt-1">
|
|
{user ? (
|
|
<>
|
|
Current role:{" "}
|
|
<span className="font-medium">
|
|
{String(user.publicMetadata?.role ?? "unknown")}
|
|
</span>
|
|
</>
|
|
) : (
|
|
"Loading user metadata..."
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
onClick={fetchGyms}
|
|
disabled={gymsLoading}
|
|
variant="outline"
|
|
size="sm"
|
|
className="flex items-center gap-2"
|
|
>
|
|
{gymsLoading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4" />
|
|
)}
|
|
Refresh
|
|
</Button>
|
|
{isSuperAdmin && (
|
|
<Button
|
|
onClick={() => setShowCreateGym(true)}
|
|
size="sm"
|
|
className="flex items-center gap-2"
|
|
>
|
|
Create Gym
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{gymMessage && (
|
|
<div
|
|
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${gymMessage.type === "success" ? "bg-green-50 text-green-700" : "bg-red-50 text-red-700"}`}
|
|
>
|
|
{gymMessage.type === "success" ? (
|
|
<Check className="w-5 h-5" />
|
|
) : (
|
|
<AlertTriangle className="w-5 h-5" />
|
|
)}
|
|
{gymMessage.text}
|
|
</div>
|
|
)}
|
|
|
|
{showCreateGym && (
|
|
<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">
|
|
<h3 className="text-lg font-semibold mb-4">Create Gym</h3>
|
|
<form
|
|
onSubmit={async (e) => {
|
|
e.preventDefault();
|
|
try {
|
|
setCreatingGym(true);
|
|
await axios.post("/api/gyms", {
|
|
name: gymName.trim(),
|
|
location: gymLocation.trim() || undefined,
|
|
});
|
|
setGymMessage({
|
|
type: "success",
|
|
text: "Gym created successfully",
|
|
});
|
|
setShowCreateGym(false);
|
|
setGymName("");
|
|
setGymLocation("");
|
|
fetchGyms();
|
|
} catch (error) {
|
|
log.error("Failed to create gym", error);
|
|
setGymMessage({
|
|
type: "error",
|
|
text: "Failed to create gym",
|
|
});
|
|
} finally {
|
|
setCreatingGym(false);
|
|
}
|
|
}}
|
|
>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium mb-1">
|
|
Gym Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={gymName}
|
|
onChange={(e) => setGymName(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="mb-4">
|
|
<label className="block text-sm font-medium mb-1">
|
|
Location (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={gymLocation}
|
|
onChange={(e) => setGymLocation(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
placeholder="Enter location"
|
|
/>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setShowCreateGym(false);
|
|
setGymName("");
|
|
setGymLocation("");
|
|
}}
|
|
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
|
|
>
|
|
Cancel
|
|
</button>
|
|
<button
|
|
type="submit"
|
|
disabled={creatingGym}
|
|
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 flex items-center gap-2"
|
|
>
|
|
{creatingGym ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : null}
|
|
Create
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Gym List */}
|
|
<div className="lg:col-span-1 space-y-4">
|
|
<h4 className="font-semibold text-slate-900">Gyms</h4>
|
|
{gymsLoading ? (
|
|
<div className="flex items-center justify-center p-8 text-slate-500">
|
|
<Loader2 className="w-5 h-5 animate-spin mr-2" />
|
|
Loading...
|
|
</div>
|
|
) : gyms.length === 0 ? (
|
|
<div className="p-4 text-center text-slate-500">
|
|
No active gyms found.
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2 max-h-96 overflow-y-auto">
|
|
{gyms.map((gym) => (
|
|
<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>
|
|
|
|
{/* Gym Details / Stats */}
|
|
<div className="lg:col-span-2">
|
|
{selectedGym ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="font-semibold text-slate-900">
|
|
{selectedGym.name} - Details
|
|
</h4>
|
|
{isSuperAdmin && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
onClick={() => handleDeleteGym(selectedGym.id)}
|
|
disabled={deletingGym}
|
|
>
|
|
{deletingGym ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Trash2 className="w-4 h-4 mr-1" />
|
|
)}
|
|
Delete Gym
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Gym Info */}
|
|
<div className="grid grid-cols-2 gap-4 p-4 bg-slate-50 rounded-lg">
|
|
<div>
|
|
<p className="text-xs text-slate-500">Location</p>
|
|
<p className="font-medium">
|
|
{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>
|
|
<p className="text-xs text-slate-500">Geofence</p>
|
|
<p className="font-medium">
|
|
{selectedGym.geofenceEnabled === false
|
|
? "Disabled"
|
|
: `${selectedGym.geofenceRadiusMeters ?? 30}m`}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Geofence Settings */}
|
|
<div className="p-4 border rounded-lg space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h5 className="text-sm font-medium text-slate-700">
|
|
Attendance Geofence
|
|
</h5>
|
|
<label className="inline-flex items-center gap-2 text-sm text-slate-700">
|
|
<input
|
|
type="checkbox"
|
|
checked={geofenceEnabled}
|
|
onChange={(e) => setGeofenceEnabled(e.target.checked)}
|
|
/>
|
|
Enabled
|
|
</label>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">
|
|
Latitude
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={geofenceLatitude}
|
|
onChange={(e) => setGeofenceLatitude(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
placeholder="e.g. 37.7749"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">
|
|
Longitude
|
|
</label>
|
|
<input
|
|
type="number"
|
|
step="any"
|
|
value={geofenceLongitude}
|
|
onChange={(e) => setGeofenceLongitude(e.target.value)}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
placeholder="e.g. -122.4194"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-slate-500 mb-1">
|
|
Radius (meters)
|
|
</label>
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
value={geofenceRadiusMeters}
|
|
onChange={(e) =>
|
|
setGeofenceRadiusMeters(e.target.value)
|
|
}
|
|
className="w-full border border-gray-300 rounded px-3 py-2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-slate-500">
|
|
Default radius is 30m and geofence is enabled by default.
|
|
</p>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveGeofence}
|
|
disabled={savingGeofence}
|
|
>
|
|
{savingGeofence ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
"Save Geofence"
|
|
)}
|
|
</Button>
|
|
</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>
|
|
)}
|
|
|
|
{/* Membership Feature Access */}
|
|
<div className="mt-6">
|
|
<h5 className="text-sm font-medium text-slate-700 mb-2">
|
|
Membership Feature Access
|
|
</h5>
|
|
<div className="overflow-x-auto border rounded-lg">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="bg-slate-50 border-b">
|
|
<tr>
|
|
<th className="px-4 py-3 font-semibold text-slate-900">
|
|
Feature
|
|
</th>
|
|
<th className="px-4 py-3 font-semibold text-slate-900">
|
|
Basic
|
|
</th>
|
|
<th className="px-4 py-3 font-semibold text-slate-900">
|
|
Premium
|
|
</th>
|
|
<th className="px-4 py-3 font-semibold text-slate-900">
|
|
VIP
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
<tr>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
Recommendations per month
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.basic.recommendationsPerMonth}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
Unlimited
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
Unlimited
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
Nutrition tracking
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.basic.nutritionTracking
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.premium.nutritionTracking
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.vip.nutritionTracking
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
Hydration tracking
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.basic.hydrationTracking
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.premium.hydrationTracking
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.vip.hydrationTracking
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
Advanced statistics
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.basic.advancedStatistics
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.premium.advancedStatistics
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
<td className="px-4 py-3 text-slate-700">
|
|
{MEMBERSHIP_FEATURES.vip.advancedStatistics
|
|
? "Yes"
|
|
: "No"}
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</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>
|
|
|
|
{/* Database Management */}
|
|
<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 gap-3">
|
|
<div className="p-2 bg-blue-50 rounded-lg">
|
|
<Database className="w-6 h-6 text-blue-600" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-xl font-bold text-slate-900">
|
|
Database Management
|
|
</h3>
|
|
<p className="text-sm text-slate-500">
|
|
Create backups and restore your database
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={handleCreateBackup}
|
|
disabled={creatingBackup}
|
|
className="flex items-center gap-2"
|
|
>
|
|
{creatingBackup ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Download className="w-4 h-4" />
|
|
)}
|
|
Create Backup
|
|
</Button>
|
|
</div>
|
|
|
|
{message && (
|
|
<div
|
|
className={`p-4 rounded-lg mb-6 flex items-center gap-2 ${
|
|
message.type === "success"
|
|
? "bg-green-50 text-green-700"
|
|
: "bg-red-50 text-red-700"
|
|
}`}
|
|
>
|
|
{message.type === "success" ? (
|
|
<Check className="w-5 h-5" />
|
|
) : (
|
|
<AlertTriangle className="w-5 h-5" />
|
|
)}
|
|
{message.text}
|
|
</div>
|
|
)}
|
|
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="bg-slate-50 border-b">
|
|
<tr>
|
|
<th className="px-6 py-4 font-semibold text-slate-900">
|
|
Filename
|
|
</th>
|
|
<th className="px-6 py-4 font-semibold text-slate-900">Size</th>
|
|
<th className="px-6 py-4 font-semibold text-slate-900">
|
|
Created At
|
|
</th>
|
|
<th className="px-6 py-4 font-semibold text-slate-900 text-right">
|
|
Actions
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y">
|
|
{loading ? (
|
|
<tr>
|
|
<td
|
|
colSpan={4}
|
|
className="px-6 py-8 text-center text-slate-500"
|
|
>
|
|
Loading backups...
|
|
</td>
|
|
</tr>
|
|
) : backups.length === 0 ? (
|
|
<tr>
|
|
<td
|
|
colSpan={4}
|
|
className="px-6 py-8 text-center text-slate-500"
|
|
>
|
|
No backups found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
backups.map((backup) => (
|
|
<tr
|
|
key={backup.name}
|
|
className="hover:bg-slate-50 transition-colors"
|
|
>
|
|
<td className="px-6 py-4 font-medium text-slate-900">
|
|
{backup.name}
|
|
</td>
|
|
<td className="px-6 py-4 text-slate-600">
|
|
{formatSize(backup.size)}
|
|
</td>
|
|
<td className="px-6 py-4 text-slate-600">
|
|
{formatDate(backup.createdAt)}
|
|
</td>
|
|
<td className="px-6 py-4 text-right">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleRestore(backup.name)}
|
|
disabled={!!restoring}
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
>
|
|
{restoring === backup.name ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<RefreshCw className="w-4 h-4 mr-2" />
|
|
)}
|
|
Restore
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|