working on onboarding flow
This commit is contained in:
1 parent
868eaa5e3d
commit
d3a36b6103
19 files changed
+2062
-393
No files matched your search
@@ -16,13 +16,12 @@ function getTimeAgo(date: Date): string {
|
||||
const diffHours = Math.floor(diffMins / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffMins < 1) return 'just now';
|
||||
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;
|
||||
@@ -30,6 +29,7 @@ interface User {
|
||||
lastName: string;
|
||||
role: string;
|
||||
phone?: string;
|
||||
gymId?: string;
|
||||
createdAt: Date;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date;
|
||||
@@ -61,6 +61,31 @@ export function UserGrid({
|
||||
}: 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(
|
||||
() => [
|
||||
@@ -95,16 +120,34 @@ export function UserGrid({
|
||||
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);
|
||||
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}`}>
|
||||
<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",
|
||||
@@ -119,7 +162,8 @@ export function UserGrid({
|
||||
filter: "agTextColumnFilter",
|
||||
sortable: true,
|
||||
cellRenderer: (params: any) => {
|
||||
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||
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",
|
||||
@@ -130,10 +174,13 @@ export function UserGrid({
|
||||
membershipColors[params.value as keyof typeof membershipColors] ||
|
||||
"bg-gray-100 text-gray-800";
|
||||
|
||||
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
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}`}>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium border ${colorClass}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -146,7 +193,8 @@ export function UserGrid({
|
||||
filter: "agTextColumnFilter",
|
||||
sortable: true,
|
||||
cellRenderer: (params: any) => {
|
||||
if (!params.value || params.value === "N/A") return <span className="text-gray-400">N/A</span>;
|
||||
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",
|
||||
@@ -158,10 +206,13 @@ export function UserGrid({
|
||||
statusColors[params.value as keyof typeof statusColors] ||
|
||||
"bg-gray-100 text-gray-800";
|
||||
|
||||
const label = params.value.charAt(0).toUpperCase() + params.value.slice(1);
|
||||
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}`}>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${colorClass}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
@@ -178,8 +229,10 @@ export function UserGrid({
|
||||
return <span className="text-gray-400">—</span>;
|
||||
}
|
||||
|
||||
const checkInTime = params.data.checkInTime ? new Date(params.data.checkInTime) : null;
|
||||
const timeAgo = checkInTime ? getTimeAgo(checkInTime) : '';
|
||||
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">
|
||||
@@ -231,7 +284,9 @@ export function UserGrid({
|
||||
rowSelection: "multiple" as const,
|
||||
onSelectionChanged: () => {
|
||||
const selectedNodes = gridRef.current?.api.getSelectedNodes();
|
||||
const selectedData = selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) || [];
|
||||
const selectedData =
|
||||
selectedNodes?.map((node) => node.data).filter((u): u is User => !!u) ||
|
||||
[];
|
||||
setSelectedUsers(selectedData);
|
||||
if (selectedData.length === 1 && onUserSelect) {
|
||||
onUserSelect(selectedData[0]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { UserGrid } from "@/components/users/UserGrid";
|
||||
// import { Button } from "@/components/ui/button";
|
||||
import { Card, CardHeader, CardContent } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useUser } from "@clerk/nextjs";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -13,6 +14,7 @@ interface User {
|
||||
lastName: string;
|
||||
role: string;
|
||||
phone?: string;
|
||||
gymId?: string;
|
||||
createdAt: Date;
|
||||
isCheckedIn?: boolean;
|
||||
checkInTime?: Date;
|
||||
@@ -29,6 +31,7 @@ interface User {
|
||||
}
|
||||
|
||||
export function UserManagement() {
|
||||
const { user } = useUser();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<string>("all");
|
||||
@@ -41,8 +44,32 @@ export function UserManagement() {
|
||||
email: string;
|
||||
role: string;
|
||||
phone: string;
|
||||
gymId: string;
|
||||
} | null>(null);
|
||||
|
||||
// Active gyms for dropdown
|
||||
const [gyms, setGyms] = useState<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
// Load gyms when modal opens or refreshes
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/gyms");
|
||||
const data = await res.json();
|
||||
if (Array.isArray(data)) {
|
||||
// map down to id and name to avoid extra payload use here
|
||||
setGyms(data.map((g: any) => ({ id: g.id, name: g.name })));
|
||||
} else {
|
||||
setGyms([]);
|
||||
}
|
||||
} catch {
|
||||
setGyms([]);
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [isEditing]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [filter]);
|
||||
@@ -74,6 +101,7 @@ export function UserManagement() {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
phone: user.phone || "",
|
||||
gymId: user.gymId || "",
|
||||
});
|
||||
setIsEditing(true);
|
||||
};
|
||||
@@ -150,10 +178,14 @@ export function UserManagement() {
|
||||
try {
|
||||
if (selectedUser) {
|
||||
// Update existing user
|
||||
const response = await fetch("/api/users", {
|
||||
method: "PUT",
|
||||
const response = await fetch("/api/admin/set-user-metadata", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: selectedUser.id, ...editForm }),
|
||||
body: JSON.stringify({
|
||||
targetUserId: selectedUser.id,
|
||||
role: editForm.role,
|
||||
gymId: editForm.gymId === "" ? null : editForm.gymId,
|
||||
}),
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsEditing(false);
|
||||
@@ -164,10 +196,14 @@ export function UserManagement() {
|
||||
}
|
||||
} else {
|
||||
// Create (Invite) new user
|
||||
const response = await fetch("/api/users", {
|
||||
const response = await fetch("/api/invitations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(editForm),
|
||||
body: JSON.stringify({
|
||||
inviteeEmail: editForm.email,
|
||||
roleAssigned: editForm.role,
|
||||
gymId: editForm.gymId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -232,6 +268,7 @@ export function UserManagement() {
|
||||
email: "",
|
||||
role: "client",
|
||||
phone: "",
|
||||
gymId: String((user?.publicMetadata as any)?.gymId ?? ""),
|
||||
});
|
||||
setSelectedUser(null);
|
||||
setIsEditing(true);
|
||||
@@ -308,7 +345,9 @@ export function UserManagement() {
|
||||
{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>
|
||||
<h3 className="text-lg font-semibold mb-4">
|
||||
{selectedUser ? "Edit User" : "Invite New User"}
|
||||
</h3>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
@@ -366,8 +405,8 @@ export function UserManagement() {
|
||||
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.
|
||||
{/* 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>
|
||||
@@ -389,6 +428,26 @@ export function UserManagement() {
|
||||
className="w-full border border-gray-300 rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<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>
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -404,7 +463,7 @@ export function UserManagement() {
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{selectedUser ? 'Save Changes' : 'Send Invitation'}
|
||||
{selectedUser ? "Save Changes" : "Send Invitation"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -499,8 +558,8 @@ export function UserManagement() {
|
||||
<span className="font-medium">Last Visit:</span>{" "}
|
||||
{selectedUser.client.lastVisit
|
||||
? new Date(
|
||||
selectedUser.client.lastVisit,
|
||||
).toLocaleDateString()
|
||||
selectedUser.client.lastVisit,
|
||||
).toLocaleDateString()
|
||||
: "Never"}
|
||||
</p>
|
||||
</div>
|
||||
@@ -513,9 +572,7 @@ export function UserManagement() {
|
||||
<p>
|
||||
<span className="font-medium">Last Check-In:</span>{" "}
|
||||
{selectedUser.lastCheckInTime
|
||||
? new Date(
|
||||
selectedUser.lastCheckInTime,
|
||||
).toLocaleString()
|
||||
? new Date(selectedUser.lastCheckInTime).toLocaleString()
|
||||
: "Never"}
|
||||
</p>
|
||||
<p>
|
||||
|
||||
Reference in new issue
Block a user