Files
fitaiProto/apps/admin/src/components/users/UserManagement.tsx
T
2026-03-19 01:29:09 +01:00

635 lines
21 KiB
TypeScript

"use client";
import { useState } from "react";
import { UserGrid, type User } from "@/components/users/UserGrid";
import { InvitationsGrid } from "@/components/users/InvitationsGrid";
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { useUser } from "@clerk/nextjs";
import log from "@/lib/logger";
import { toast } from "@/lib/toast";
import { CreateUserModal } from "./CreateUserModal";
import {
useUsers,
useGyms,
useUpdateUser,
useDeleteUser,
useSendInvitation,
useInvitations,
} from "@/hooks/use-api";
interface UserManagementProps {
gymId?: string;
}
export function UserManagement({ gymId }: UserManagementProps) {
const { user } = useUser();
const [filter, setFilter] = useState<string>("all");
const [viewFilter, setViewFilter] = useState<"all" | "active" | "pending">(
"all",
);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [isEditing, setIsEditing] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editForm, setEditForm] = useState<{
firstName: string;
lastName: string;
email: string;
role: string;
phone: string;
gymId: string;
membershipType?: string;
membershipStatus?: string;
} | null>(null);
const {
data: users = [],
isLoading: usersLoading,
refetch: refetchUsers,
} = useUsers({
role: filter !== "all" ? filter : undefined,
gymId,
});
const {
data: invitations = [],
isLoading: invitationsLoading,
refetch: refetchInvitations,
} = useInvitations(gymId);
const { data: gyms = [] } = useGyms();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
const sendInvitation = useSendInvitation();
const isLoading =
viewFilter === "pending" ? invitationsLoading : usersLoading;
const refetch = viewFilter === "pending" ? refetchInvitations : refetchUsers;
const handleUserSelect = (user: User | null) => {
setSelectedUser(user);
};
const handleEditUser = (user: User) => {
setSelectedUser(user);
setEditForm({
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
role: user.role,
phone: user.phone || "",
gymId: user.gymId || "",
membershipType: user.client?.membershipType || "basic",
membershipStatus: user.client?.membershipStatus || "active",
});
setIsEditing(true);
};
const handleDeleteUser = (user: User) => {
setSelectedUser(user);
setIsDeleting(true);
};
const handleBulkDelete = async (users: User[]) => {
if (users.length === 0) return;
if (!confirm(`Are you sure you want to delete ${users.length} users?`))
return;
try {
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);
}
};
const handleExport = () => {
const csvContent = [
[
"Name",
"Email",
"Role",
"Phone",
"Membership",
"Status",
"Join Date",
"Last Visit",
],
...users.map((user) => [
`${user.firstName} ${user.lastName}`,
user.email,
user.role,
user.phone || "",
user.client?.membershipType || "",
user.client?.membershipStatus || "",
user.client?.joinDate || user.createdAt,
user.client?.lastVisit || "",
]),
]
.map((row) => row.join(","))
.join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `users_${new Date().toISOString().split("T")[0]}.csv`;
a.click();
window.URL.revokeObjectURL(url);
};
const handleRefresh = () => {
refetch();
};
const handleSaveEdit = async () => {
if (!editForm) return;
try {
if (selectedUser) {
const payload: any = {
id: selectedUser.id,
email: editForm.email,
firstName: editForm.firstName,
lastName: editForm.lastName,
role: editForm.role,
phone: editForm.phone,
gymId: editForm.gymId === "" ? null : editForm.gymId,
};
// Include membership fields if user is a client
if (editForm.role === "client") {
payload.membershipType = editForm.membershipType;
payload.membershipStatus = editForm.membershipStatus;
}
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);
toast.error("An unexpected error occurred");
}
};
const handleDeleteConfirm = async () => {
if (!selectedUser) return;
try {
await deleteUser.mutateAsync(selectedUser.id);
setIsDeleting(false);
setSelectedUser(null);
refetch();
toast.success("User deleted successfully");
} catch (error) {
log.error("Failed to delete user", error);
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-2xl font-bold">User Management</h2>
<div className="flex gap-2">
{/* View Filter: All/Active/Pending */}
<select
value={viewFilter}
onChange={(e) =>
setViewFilter(e.target.value as "all" | "active" | "pending")
}
className="px-3 py-2 border rounded-md bg-white dark:bg-gray-800"
>
<option value="all">All Users</option>
<option value="active">Active Users</option>
<option value="pending">Pending Invitations</option>
</select>
<Button
variant={filter === "all" ? "default" : "outline"}
onClick={() => setFilter("all")}
>
All Users
</Button>
<Button
variant={filter === "client" ? "default" : "outline"}
onClick={() => selectedUser && handleEditUser(selectedUser)}
disabled={!selectedUser}
>
Edit User
</Button>
<Button variant="default" onClick={() => setCreateModalOpen(true)}>
Create User
</Button>
<Button
variant={filter === "client" ? "default" : "outline"}
onClick={() => selectedUser && handleDeleteUser(selectedUser)}
disabled={!selectedUser}
>
Delete User
</Button>
<Button
variant={filter === "client" ? "default" : "outline"}
onClick={() => setFilter("client")}
>
Clients
</Button>
<Button
variant={filter === "trainer" ? "default" : "outline"}
onClick={() => setFilter("trainer")}
>
Trainers
</Button>
<Button
variant={filter === "admin" ? "default" : "outline"}
onClick={() => setFilter("admin")}
>
Admins
</Button>
<Button
variant={filter === "superAdmin" ? "default" : "outline"}
onClick={() => setFilter("superAdmin")}
>
Super Admins
</Button>
</div>
</div>
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
{viewFilter === "pending" ? (
<>Showing {invitations.length} pending invitations</>
) : (
<>
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
)}
</>
)}
</div>
<div className="flex gap-2">
<Button variant="default" onClick={handleRefresh}>
Refresh
</Button>
{viewFilter !== "pending" && (
<Button variant="default" onClick={handleExport}>
Export CSV
</Button>
)}
</div>
</div>
<Card>
<CardContent className="p-0">
{viewFilter === "pending" ? (
<InvitationsGrid
invitations={invitations}
onRefetch={refetchInvitations}
/>
) : (
<UserGrid
users={users}
onUserSelect={(user) => handleUserSelect(user)}
onEditUser={handleEditUser}
onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete}
loading={isLoading}
/>
)}
</CardContent>
</Card>
{isEditing && editForm && (
<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">
{selectedUser ? "Edit User" : "Invite New User"}
</h3>
<form
onSubmit={(e) => {
e.preventDefault();
handleSaveEdit();
}}
>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
First Name
</label>
<input
type="text"
value={editForm.firstName}
onChange={(e) =>
setEditForm({ ...editForm, firstName: 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">
Last Name
</label>
<input
type="text"
value={editForm.lastName}
onChange={(e) =>
setEditForm({ ...editForm, lastName: 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">Email</label>
<input
type="email"
value={editForm.email}
onChange={(e) =>
setEditForm({ ...editForm, email: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
required
disabled={!!selectedUser} // Disable email edit for existing users if desired, or keep enabled
/>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Role</label>
<select
value={editForm.role}
onChange={(e) =>
setEditForm({ ...editForm, role: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
required
>
{/* Ideally we fetch current user role to filter these.
For now, we show all but the API will enforce it.
We can add a visual indicator or fetch "me" to filter. */}
<option value="client">Client</option>
<option value="trainer">Trainer</option>
<option value="admin">Admin</option>
<option value="superAdmin">Super Admin</option>
</select>
<p className="text-xs text-gray-500 mt-1">
Note: You can only assign roles lower than your own.
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Phone</label>
<input
type="tel"
value={editForm.phone}
onChange={(e) =>
setEditForm({ ...editForm, phone: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
/>
</div>
{/* Only superAdmins can reassign gyms */}
{(() => {
const currentRole = user?.publicMetadata?.role;
console.log("Current user role for gym selector:", currentRole);
return currentRole === "superAdmin";
})() && (
<div className="mb-4">
<label className="block text-sm font-medium mb-1">Gym</label>
<select
value={editForm.gymId}
onChange={(e) =>
setEditForm({ ...editForm, gymId: e.target.value })
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="">Proceed without gym</option>
{gyms.map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">
Select an active gym or proceed without a gym.
</p>
</div>
)}
{/* Client-specific fields - only show when role is client */}
{editForm.role === "client" && (
<>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Membership Type
</label>
<select
value={editForm.membershipType || "basic"}
onChange={(e) =>
setEditForm({
...editForm,
membershipType: e.target.value,
})
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="basic">Basic</option>
<option value="premium">Premium</option>
<option value="vip">VIP</option>
</select>
</div>
<div className="mb-4">
<label className="block text-sm font-medium mb-1">
Membership Status
</label>
<select
value={editForm.membershipStatus || "active"}
onChange={(e) =>
setEditForm({
...editForm,
membershipStatus: e.target.value,
})
}
className="w-full border border-gray-300 rounded px-3 py-2"
>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
<option value="suspended">Suspended</option>
</select>
</div>
</>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => {
setIsEditing(false);
setEditForm(null);
}}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
{selectedUser ? "Save Changes" : "Send Invitation"}
</button>
</div>
</form>
</div>
</div>
)}
{isDeleting && selectedUser && (
<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">Delete User</h3>
<p className="mb-4">
Are you sure you want to delete {selectedUser.firstName}{" "}
{selectedUser.lastName}? This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => setIsDeleting(false)}
className="px-4 py-2 bg-gray-300 text-gray-700 rounded hover:bg-gray-400"
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
</div>
</div>
</div>
)}
{selectedUser && (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<h3 className="text-lg font-semibold">User Details</h3>
<a
href={`/users/${selectedUser.id}`}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm font-medium"
>
View Full Profile & Recommendations
</a>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div>
<h4 className="font-medium mb-2">Basic Information</h4>
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">Name:</span>{" "}
{selectedUser.firstName} {selectedUser.lastName}
</p>
<p>
<span className="font-medium">Email:</span>{" "}
{selectedUser.email}
</p>
<p>
<span className="font-medium">Phone:</span>{" "}
{selectedUser.phone || "N/A"}
</p>
<p>
<span className="font-medium">Role:</span>{" "}
{selectedUser.role}
</p>
<p>
<span className="font-medium">Joined:</span>{" "}
{selectedUser.createdAt
? new Date(selectedUser.createdAt).toLocaleDateString()
: "N/A"}
</p>
</div>
</div>
{selectedUser.client && (
<div>
<h4 className="font-medium mb-2">Client Information</h4>
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">Membership:</span>{" "}
{selectedUser.client.membershipType}
</p>
<p>
<span className="font-medium">Status:</span>{" "}
{selectedUser.client.membershipStatus}
</p>
<p>
<span className="font-medium">Member Since:</span>{" "}
{new Date(
selectedUser.client.joinDate,
).toLocaleDateString()}
</p>
<p>
<span className="font-medium">Last Visit:</span>{" "}
{selectedUser.lastCheckInTime
? new Date(
selectedUser.lastCheckInTime,
).toLocaleDateString()
: "Never"}
</p>
</div>
</div>
)}
<div>
<h4 className="font-medium mb-2">Check-In Statistics</h4>
<div className="space-y-1 text-sm">
<p>
<span className="font-medium">Last Check-In:</span>{" "}
{selectedUser.lastCheckInTime
? new Date(selectedUser.lastCheckInTime).toLocaleString()
: "Never"}
</p>
<p>
<span className="font-medium">This Week:</span>{" "}
{selectedUser.checkInsThisWeek || 0} check-ins
</p>
<p>
<span className="font-medium">This Month:</span>{" "}
{selectedUser.checkInsThisMonth || 0} check-ins
</p>
</div>
</div>
</div>
</CardContent>
</Card>
)}
<CreateUserModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSuccess={() => {
refetchUsers();
refetchInvitations();
}}
/>
</div>
);
}