invitation flow basis
This commit is contained in:
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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user