fitaiProto/apps/admin/src/components/users/UserGrid.tsx
2025-12-13 06:26:23 +01:00

365 lines
10 KiB
TypeScript

"use client";
import React, { useState, useMemo } from "react";
import { AgGridReact } from "ag-grid-react";
import { ColDef, ModuleRegistry, AllCommunityModule } from "ag-grid-community";
import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-alpine.css";
import { formatDate } from "@/lib/utils";
ModuleRegistry.registerModules([AllCommunityModule]);
function getTimeAgo(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
interface User {
id: string;
email: string;
firstName: string;
lastName: string;
role: string;
phone?: string;
gymId?: string;
createdAt: Date;
isCheckedIn?: boolean;
checkInTime?: Date;
client?: {
id: string;
membershipType: string;
membershipStatus: string;
joinDate: Date;
lastVisit?: Date;
};
}
interface UserGridProps {
users: User[];
onUserSelect?: (user: User) => void;
onEditUser?: (user: User) => void;
onDeleteUser?: (user: User) => void;
onBulkDelete?: (users: User[]) => void;
loading?: boolean;
}
export function UserGrid({
users,
onUserSelect,
onEditUser,
onDeleteUser,
onBulkDelete,
loading = false,
}: UserGridProps) {
const [selectedUsers, setSelectedUsers] = useState<User[]>([]);
const [searchQuery, setSearchQuery] = useState<string>("");
const [gymNames, setGymNames] = useState<Record<string, string>>({});
React.useEffect(() => {
let isMounted = true;
(async () => {
try {
const res = await fetch("/api/gyms");
const data = await res.json();
if (isMounted && Array.isArray(data)) {
const map: Record<string, string> = {};
for (const g of data) {
if (g && g.id) {
map[g.id] = g.name || g.id;
}
}
setGymNames(map);
}
} catch (e) {
// silently fail; we'll show gymId if name not available
}
})();
return () => {
isMounted = false;
};
}, []);
const columnDefs: ColDef<User>[] = useMemo(
() => [
{
headerName: "Name",
valueGetter: (params) =>
`${params.data?.firstName} ${params.data?.lastName}`,
filter: "agTextColumnFilter",
sortable: true,
minWidth: 150,
},
{
headerName: "Email",
field: "email",
filter: "agTextColumnFilter",
sortable: true,
minWidth: 200,
},
{
headerName: "Role",
field: "role",
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
const roleColors = {
superAdmin: "bg-red-100 text-red-800",
admin: "bg-purple-100 text-purple-800",
trainer: "bg-blue-100 text-blue-800",
client: "bg-green-100 text-green-800",
};
const colorClass =
roleColors[params.value as keyof typeof roleColors] ||
"bg-gray-100 text-gray-800";
const label =
params.value === "superAdmin"
? "Super Admin"
: params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
>
{label}
</span>
);
},
minWidth: 120,
},
{
headerName: "Gym",
field: "gymId",
filter: "agTextColumnFilter",
sortable: true,
minWidth: 160,
valueFormatter: (params: any) => {
const gymId = params.value;
if (!gymId) return "None";
return gymNames[gymId] || gymId;
},
},
{
headerName: "Phone",
field: "phone",
filter: "agTextColumnFilter",
sortable: true,
minWidth: 130,
valueFormatter: (params: any) => params.value || "N/A",
},
{
headerName: "Membership",
valueGetter: (params) => params.data?.client?.membershipType || "N/A",
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A")
return <span className="text-gray-400">N/A</span>;
const membershipColors = {
vip: "bg-yellow-100 text-yellow-800 border-yellow-200",
premium: "bg-blue-100 text-blue-800 border-blue-200",
basic: "bg-slate-100 text-slate-800 border-slate-200",
};
const colorClass =
membershipColors[params.value as keyof typeof membershipColors] ||
"bg-gray-100 text-gray-800";
const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}
>
{label}
</span>
);
},
minWidth: 120,
},
{
headerName: "Status",
valueGetter: (params) => params.data?.client?.membershipStatus || "N/A",
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.value || params.value === "N/A")
return <span className="text-gray-400">N/A</span>;
const statusColors = {
active: "bg-green-100 text-green-800",
inactive: "bg-red-100 text-red-800",
suspended: "bg-orange-100 text-orange-800",
expired: "bg-gray-100 text-gray-800",
};
const colorClass =
statusColors[params.value as keyof typeof statusColors] ||
"bg-gray-100 text-gray-800";
const label =
params.value.charAt(0).toUpperCase() + params.value.slice(1);
return (
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
>
{label}
</span>
);
},
minWidth: 120,
},
{
headerName: "Currently Checked In",
valueGetter: (params) => params.data?.isCheckedIn,
filter: "agTextColumnFilter",
sortable: true,
cellRenderer: (params: any) => {
if (!params.data?.isCheckedIn) {
return <span className="text-gray-400"></span>;
}
const checkInTime = params.data.checkInTime
? new Date(params.data.checkInTime)
: null;
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : "";
return (
<span className="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 border border-green-200">
Checked In {timeAgo && `(${timeAgo})`}
</span>
);
},
minWidth: 180,
},
{
headerName: "Join Date",
valueGetter: (params) =>
params.data?.client?.joinDate || params.data?.createdAt,
filter: "agDateColumnFilter",
sortable: true,
valueFormatter: (params: any) => formatDate(new Date(params.value)),
minWidth: 120,
},
{
headerName: "Last Visit",
valueGetter: (params) => params.data?.client?.lastVisit,
filter: "agDateColumnFilter",
sortable: true,
valueFormatter: (params: any) =>
params.value ? formatDate(new Date(params.value)) : "Never",
minWidth: 120,
},
],
[],
);
const defaultColDef: ColDef = useMemo(
() => ({
flex: 1,
resizable: true,
floatingFilter: true,
suppressMenu: true,
}),
[],
);
const gridRef = React.useRef<AgGridReact<User>>(null);
const gridOptions = {
theme: "legacy" as const,
columnDefs,
defaultColDef,
rowData: users,
rowSelection: "multiple" as const,
onSelectionChanged: () => {
const selectedNodes = gridRef.current?.api.getSelectedNodes();
const selectedData =
selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) ||
[];
setSelectedUsers(selectedData);
if (selectedData.length === 1 && onUserSelect) {
onUserSelect(selectedData[0]);
}
},
suppressRowClickSelection: false,
animateRows: true,
loading: loading,
pagination: true,
paginationPageSize: 20,
paginationPageSizeSelector: [10, 20, 50, 100],
quickFilterText: searchQuery,
};
const handleEdit = () => {
if (selectedUsers.length === 1 && onEditUser) {
onEditUser(selectedUsers[0]);
}
};
const handleDelete = () => {
if (selectedUsers.length === 1 && onDeleteUser) {
onDeleteUser(selectedUsers[0]);
}
};
const handleBulkDelete = () => {
if (selectedUsers.length > 0 && onBulkDelete) {
onBulkDelete(selectedUsers);
}
};
return (
<div>
<div className="flex justify-between items-center mb-4">
<input
type="text"
placeholder="Search users..."
className="border border-gray-300 rounded px-4 py-2"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="flex gap-2">
<button
className="bg-green-500 text-white px-4 py-2 rounded disabled:opacity-50"
onClick={handleEdit}
disabled={selectedUsers.length !== 1}
>
Edit User
</button>
<button
className="bg-red-500 text-white px-4 py-2 rounded disabled:opacity-50"
onClick={handleDelete}
disabled={selectedUsers.length !== 1}
>
Delete User
</button>
<button
className="bg-yellow-500 text-white px-4 py-2 rounded disabled:opacity-50"
onClick={handleBulkDelete}
disabled={selectedUsers.length === 0}
>
Bulk Delete
</button>
</div>
</div>
<div
className="ag-theme-alpine"
style={{ height: "600px", width: "100%" }}
>
<AgGridReact<User> {...gridOptions} ref={gridRef} />
</div>
</div>
);
}