365 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|