invitation flow basis

This commit is contained in:
dimitar committed 2026-03-18 23:08:55 +01:00
1 parent b1f01208fa
commit 0817e8e72b
8 files changed
+507 -26

No files matched your search

@@ -0,0 +1,140 @@
"use client";
import { useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { toast } from "@/lib/toast";
import {
useRevokeInvitation,
useResendInvitation,
type Invitation,
} from "@/hooks/use-api";
import { Copy, RefreshCw, Trash2 } from "lucide-react";
interface InvitationsGridProps {
invitations: Invitation[];
onRefetch: () => void;
}
export function InvitationsGrid({
invitations,
onRefetch,
}: InvitationsGridProps) {
const revokeInvitation = useRevokeInvitation();
const resendInvitation = useResendInvitation();
const [actioningId, setActioningId] = useState<string | null>(null);
const handleCopyUrl = async (url: string) => {
try {
await navigator.clipboard.writeText(url);
toast.success("Invitation link copied to clipboard");
} catch (error) {
toast.error("Failed to copy link");
}
};
const handleRevoke = async (invitation: Invitation) => {
if (!confirm(`Cancel invitation for ${invitation.emailAddress}?`)) {
return;
}
setActioningId(invitation.id);
try {
await revokeInvitation.mutateAsync(invitation.id);
toast.success("Invitation cancelled");
onRefetch();
} catch (error) {
toast.error("Failed to cancel invitation");
} finally {
setActioningId(null);
}
};
const handleResend = async (invitation: Invitation) => {
setActioningId(invitation.id);
try {
await resendInvitation.mutateAsync(invitation.id);
toast.success("Invitation resent");
onRefetch();
} catch (error) {
toast.error("Failed to resend invitation");
} finally {
setActioningId(null);
}
};
if (invitations.length === 0) {
return (
<Card className="p-8 text-center text-muted-foreground">
No pending invitations
</Card>
);
}
return (
<div className="space-y-4">
{invitations.map((invitation) => {
const role = invitation.publicMetadata?.role || "unknown";
const createdDate = new Date(invitation.createdAt);
const isActioning = actioningId === invitation.id;
return (
<Card key={invitation.id} className="p-4">
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<h3 className="font-medium">{invitation.emailAddress}</h3>
<span className="text-xs px-2 py-1 rounded bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
<span className="text-xs px-2 py-1 rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{role}
</span>
</div>
<p className="text-sm text-muted-foreground mt-1">
Sent {formatDistanceToNow(createdDate, { addSuffix: true })}
</p>
</div>
<div className="flex items-center gap-2">
{invitation.url && (
<Button
variant="outline"
size="sm"
onClick={() => handleCopyUrl(invitation.url!)}
>
<Copy className="h-4 w-4 mr-1" />
Copy Link
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => handleResend(invitation)}
disabled={isActioning}
>
<RefreshCw
className={`h-4 w-4 mr-1 ${isActioning ? "animate-spin" : ""}`}
/>
Resend
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => handleRevoke(invitation)}
disabled={isActioning}
>
<Trash2 className="h-4 w-4 mr-1" />
Cancel
</Button>
</div>
</div>
</Card>
);
})}
</div>
);
}
@@ -2,6 +2,7 @@
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";
@@ -14,6 +15,7 @@ import {
useUpdateUser,
useDeleteUser,
useSendInvitation,
useInvitations,
} from "@/hooks/use-api";
interface UserManagementProps {
@@ -23,6 +25,9 @@ interface UserManagementProps {
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);
@@ -40,17 +45,28 @@ export function UserManagement({ gymId }: UserManagementProps) {
const {
data: users = [],
isLoading,
refetch,
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);
};
@@ -193,6 +209,19 @@ export function UserManagement({ gymId }: UserManagementProps) {
<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")}
@@ -220,7 +249,7 @@ export function UserManagement({ gymId }: UserManagementProps) {
variant={filter === "client" ? "default" : "outline"}
onClick={() => setFilter("client")}
>
Clientsa
Clients
</Button>
<Button
variant={filter === "trainer" ? "default" : "outline"}
@@ -245,33 +274,48 @@ export function UserManagement({ gymId }: UserManagementProps) {
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600">
Showing {users.length} users
{selectedUser && (
<span className="ml-4 text-blue-600">
Selected: {selectedUser.firstName} {selectedUser.lastName}
</span>
{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>
<Button variant="default" onClick={handleExport}>
Export CSV
</Button>
{viewFilter !== "pending" && (
<Button variant="default" onClick={handleExport}>
Export CSV
</Button>
)}
</div>
</div>
<Card>
<CardContent className="p-0">
<UserGrid
users={users}
onUserSelect={(user) => handleUserSelect(user)}
onEditUser={handleEditUser}
onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete}
loading={isLoading}
/>
{viewFilter === "pending" ? (
<InvitationsGrid
invitations={invitations}
onRefetch={refetchInvitations}
/>
) : (
<UserGrid
users={users}
onUserSelect={(user) => handleUserSelect(user)}
onEditUser={handleEditUser}
onDeleteUser={handleDeleteUser}
onBulkDelete={handleBulkDelete}
loading={isLoading}
/>
)}
</CardContent>
</Card>
@@ -580,7 +624,10 @@ export function UserManagement({ gymId }: UserManagementProps) {
<CreateUserModal
open={createModalOpen}
onOpenChange={setCreateModalOpen}
onSuccess={() => refetch()}
onSuccess={() => {
refetchUsers();
refetchInvitations();
}}
/>
</div>
);