634 lines
20 KiB
TypeScript
634 lines
20 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useMemo, useEffect } from "react";
|
|
import {
|
|
PlusCircle,
|
|
Search,
|
|
Filter,
|
|
MoreHorizontal,
|
|
X,
|
|
ChevronDown,
|
|
UserPlus,
|
|
Mail,
|
|
SortAsc,
|
|
SortDesc,
|
|
Mail as MailIcon,
|
|
Phone,
|
|
Clock,
|
|
Calendar,
|
|
ShieldAlert,
|
|
ListFilter,
|
|
} from "lucide-react";
|
|
import { Button } from "@/app/_components/ui/button";
|
|
import { Input } from "@/app/_components/ui/input";
|
|
import { Badge } from "@/app/_components/ui/badge";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuCheckboxItem,
|
|
} from "@/app/_components/ui/dropdown-menu";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchUsers } from "@/app/(protected)/(admin)/dashboard/user-management/action";
|
|
import { User } from "@/src/models/users/users.model";
|
|
import { toast } from "sonner";
|
|
import { DataTable } from "./data-table";
|
|
import { InviteUserDialog } from "./invite-user";
|
|
import { AddUserDialog } from "./add-user-dialog";
|
|
import { UserDetailsSheet } from "./sheet";
|
|
import { Avatar } from "@radix-ui/react-avatar";
|
|
import Image from "next/image";
|
|
import { useNavigations } from "@/app/_hooks/use-navigations";
|
|
|
|
export default function UserManagement() {
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
|
const [isAddUserOpen, setIsAddUserOpen] = useState(false);
|
|
const [isInviteUserOpen, setIsInviteUserOpen] = useState(false);
|
|
|
|
// Filter states
|
|
const [filters, setFilters] = useState<{
|
|
email: string;
|
|
phone: string;
|
|
lastSignIn: string;
|
|
createdAt: string;
|
|
status: string[];
|
|
}>({
|
|
email: "",
|
|
phone: "",
|
|
lastSignIn: "",
|
|
createdAt: "",
|
|
status: [],
|
|
});
|
|
|
|
// Use React Query to fetch users
|
|
const [users, setUsers] = useState<User[]>([]);
|
|
const { isLoading, setIsLoading } = useNavigations();
|
|
|
|
useEffect(() => {
|
|
const fetchData = async () => {
|
|
try {
|
|
setIsLoading(true);
|
|
const fetchedUsers = await fetchUsers();
|
|
setUsers(fetchedUsers);
|
|
} catch (error) {
|
|
toast.error("Failed to fetch users");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchData();
|
|
}, []);
|
|
|
|
const refetch = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const fetchedUsers = await fetchUsers();
|
|
setUsers(fetchedUsers);
|
|
} catch (error) {
|
|
toast.error("Failed to fetch users");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleUserClick = (user: User) => {
|
|
setSelectedUser(user);
|
|
setIsSheetOpen(true);
|
|
};
|
|
|
|
const handleUserUpdate = () => {
|
|
refetch();
|
|
setIsSheetOpen(false);
|
|
};
|
|
|
|
const filteredUsers = useMemo(() => {
|
|
return users.filter((user) => {
|
|
// Global search
|
|
if (searchQuery) {
|
|
const query = searchQuery.toLowerCase();
|
|
const matchesSearch =
|
|
user.email?.toLowerCase().includes(query) ||
|
|
user.phone?.toLowerCase().includes(query) ||
|
|
user.id.toLowerCase().includes(query);
|
|
|
|
if (!matchesSearch) return false;
|
|
}
|
|
|
|
// Email filter
|
|
if (
|
|
filters.email &&
|
|
!user.email?.toLowerCase().includes(filters.email.toLowerCase())
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Phone filter
|
|
if (
|
|
filters.phone &&
|
|
!user.phone?.toLowerCase().includes(filters.phone.toLowerCase())
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
// Last sign in filter
|
|
if (filters.lastSignIn) {
|
|
if (filters.lastSignIn === "never" && user.last_sign_in_at) {
|
|
return false;
|
|
} else if (filters.lastSignIn === "today") {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const signInDate = user.last_sign_in_at
|
|
? new Date(user.last_sign_in_at)
|
|
: null;
|
|
if (!signInDate || signInDate < today) return false;
|
|
} else if (filters.lastSignIn === "week") {
|
|
const weekAgo = new Date();
|
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
const signInDate = user.last_sign_in_at
|
|
? new Date(user.last_sign_in_at)
|
|
: null;
|
|
if (!signInDate || signInDate < weekAgo) return false;
|
|
} else if (filters.lastSignIn === "month") {
|
|
const monthAgo = new Date();
|
|
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
|
const signInDate = user.last_sign_in_at
|
|
? new Date(user.last_sign_in_at)
|
|
: null;
|
|
if (!signInDate || signInDate < monthAgo) return false;
|
|
}
|
|
}
|
|
|
|
// Created at filter
|
|
if (filters.createdAt) {
|
|
if (filters.createdAt === "today") {
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const createdAt = user.created_at
|
|
? user.created_at
|
|
? new Date(user.created_at)
|
|
: new Date()
|
|
: new Date();
|
|
if (createdAt < today) return false;
|
|
} else if (filters.createdAt === "week") {
|
|
const weekAgo = new Date();
|
|
weekAgo.setDate(weekAgo.getDate() - 7);
|
|
const createdAt = user.created_at
|
|
? new Date(user.created_at)
|
|
: new Date();
|
|
if (createdAt < weekAgo) return false;
|
|
} else if (filters.createdAt === "month") {
|
|
const monthAgo = new Date();
|
|
monthAgo.setMonth(monthAgo.getMonth() - 1);
|
|
const createdAt = user.created_at
|
|
? new Date(user.created_at)
|
|
: new Date();
|
|
if (createdAt < monthAgo) return false;
|
|
}
|
|
}
|
|
|
|
// Status filter
|
|
if (filters.status.length > 0) {
|
|
const userStatus = user.banned_until
|
|
? "banned"
|
|
: !user.email_confirmed_at
|
|
? "unconfirmed"
|
|
: "active";
|
|
|
|
if (!filters.status.includes(userStatus)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}, [users, searchQuery, filters]);
|
|
|
|
const clearFilters = () => {
|
|
setFilters({
|
|
email: "",
|
|
phone: "",
|
|
lastSignIn: "",
|
|
createdAt: "",
|
|
status: [],
|
|
});
|
|
};
|
|
|
|
const activeFilterCount = Object.values(filters).filter(
|
|
(value) =>
|
|
(typeof value === "string" && value !== "") ||
|
|
(Array.isArray(value) && value.length > 0)
|
|
).length;
|
|
|
|
const columns = [
|
|
{
|
|
id: "email",
|
|
header: ({ column }: any) => (
|
|
<div className="flex items-center gap-1">
|
|
<span>Email</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 p-0 ml-1">
|
|
<ListFilter className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<div className="p-2">
|
|
<Input
|
|
placeholder="Filter by email..."
|
|
value={filters.email}
|
|
onChange={(e) =>
|
|
setFilters({ ...filters, email: e.target.value })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setFilters({ ...filters, email: "" })}
|
|
>
|
|
Clear filter
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
),
|
|
cell: ({ row }: { row: { original: User } }) => (
|
|
<div className="flex items-center gap-2">
|
|
<Avatar className="w-8 h-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-medium">
|
|
{row.original.profile?.avatar ? (
|
|
<Image
|
|
src={row.original.profile.avatar}
|
|
alt="Avatar"
|
|
className="w-full h-full rounded-full"
|
|
width={32}
|
|
height={32}
|
|
/>
|
|
) : (
|
|
row.original.email?.[0]?.toUpperCase() || "?"
|
|
)}
|
|
</Avatar>
|
|
<div>
|
|
<div className="font-medium">
|
|
{row.original.email || "No email"}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{row.original.id}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
id: "phone",
|
|
header: ({ column }: any) => (
|
|
<div className="flex items-center gap-1">
|
|
<span>Phone</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 p-0 ml-1">
|
|
<ListFilter className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<div className="p-2">
|
|
<Input
|
|
placeholder="Filter by phone..."
|
|
value={filters.phone}
|
|
onChange={(e) =>
|
|
setFilters({ ...filters, phone: e.target.value })
|
|
}
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setFilters({ ...filters, phone: "" })}
|
|
>
|
|
Clear filter
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
),
|
|
cell: ({ row }: { row: { original: User } }) => row.original.phone || "-",
|
|
},
|
|
{
|
|
id: "lastSignIn",
|
|
header: ({ column }: any) => (
|
|
<div className="flex items-center gap-1">
|
|
<span>Last Sign In</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 p-0 ml-1">
|
|
<ListFilter className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.lastSignIn === "today"}
|
|
onCheckedChange={() =>
|
|
setFilters({
|
|
...filters,
|
|
lastSignIn: filters.lastSignIn === "today" ? "" : "today",
|
|
})
|
|
}
|
|
>
|
|
Today
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.lastSignIn === "week"}
|
|
onCheckedChange={() =>
|
|
setFilters({
|
|
...filters,
|
|
lastSignIn: filters.lastSignIn === "week" ? "" : "week",
|
|
})
|
|
}
|
|
>
|
|
Last 7 days
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.lastSignIn === "month"}
|
|
onCheckedChange={() =>
|
|
setFilters({
|
|
...filters,
|
|
lastSignIn: filters.lastSignIn === "month" ? "" : "month",
|
|
})
|
|
}
|
|
>
|
|
Last 30 days
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.lastSignIn === "never"}
|
|
onCheckedChange={() =>
|
|
setFilters({
|
|
...filters,
|
|
lastSignIn: filters.lastSignIn === "never" ? "" : "never",
|
|
})
|
|
}
|
|
>
|
|
Never
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setFilters({ ...filters, lastSignIn: "" })}
|
|
>
|
|
Clear filter
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
),
|
|
cell: ({ row }: { row: { original: User } }) => {
|
|
return row.original.last_sign_in_at
|
|
? new Date(row.original.last_sign_in_at).toLocaleString()
|
|
: "Never";
|
|
},
|
|
},
|
|
{
|
|
id: "createdAt",
|
|
header: ({ column }: any) => (
|
|
<div className="flex items-center gap-1">
|
|
<span>Created At</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 p-0 ml-1">
|
|
<ListFilter className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.createdAt === "today"}
|
|
onCheckedChange={() =>
|
|
setFilters({
|
|
...filters,
|
|
createdAt: filters.createdAt === "today" ? "" : "today",
|
|
})
|
|
}
|
|
>
|
|
Today
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.createdAt === "week"}
|
|
onCheckedChange={() =>
|
|
setFilters({
|
|
...filters,
|
|
createdAt: filters.createdAt === "week" ? "" : "week",
|
|
})
|
|
}
|
|
>
|
|
Last 7 days
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.createdAt === "month"}
|
|
onCheckedChange={() =>
|
|
setFilters({
|
|
...filters,
|
|
createdAt: filters.createdAt === "month" ? "" : "month",
|
|
})
|
|
}
|
|
>
|
|
Last 30 days
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setFilters({ ...filters, createdAt: "" })}
|
|
>
|
|
Clear filter
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
),
|
|
cell: ({ row }: { row: { original: User } }) => {
|
|
return row.original.created_at
|
|
? new Date(row.original.created_at).toLocaleString()
|
|
: "N/A";
|
|
},
|
|
},
|
|
{
|
|
id: "status",
|
|
header: ({ column }: any) => (
|
|
<div className="flex items-center gap-1">
|
|
<span>Status</span>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="ghost" size="icon" className="h-6 w-6 p-0 ml-1">
|
|
<ListFilter className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="start">
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.status.includes("active")}
|
|
onCheckedChange={() => {
|
|
const newStatus = [...filters.status];
|
|
if (newStatus.includes("active")) {
|
|
newStatus.splice(newStatus.indexOf("active"), 1);
|
|
} else {
|
|
newStatus.push("active");
|
|
}
|
|
setFilters({ ...filters, status: newStatus });
|
|
}}
|
|
>
|
|
Active
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.status.includes("unconfirmed")}
|
|
onCheckedChange={() => {
|
|
const newStatus = [...filters.status];
|
|
if (newStatus.includes("unconfirmed")) {
|
|
newStatus.splice(newStatus.indexOf("unconfirmed"), 1);
|
|
} else {
|
|
newStatus.push("unconfirmed");
|
|
}
|
|
setFilters({ ...filters, status: newStatus });
|
|
}}
|
|
>
|
|
Unconfirmed
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuCheckboxItem
|
|
checked={filters.status.includes("banned")}
|
|
onCheckedChange={() => {
|
|
const newStatus = [...filters.status];
|
|
if (newStatus.includes("banned")) {
|
|
newStatus.splice(newStatus.indexOf("banned"), 1);
|
|
} else {
|
|
newStatus.push("banned");
|
|
}
|
|
setFilters({ ...filters, status: newStatus });
|
|
}}
|
|
>
|
|
Banned
|
|
</DropdownMenuCheckboxItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={() => setFilters({ ...filters, status: [] })}
|
|
>
|
|
Clear filter
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
),
|
|
cell: ({ row }: { row: { original: User } }) => {
|
|
if (row.original.banned_until) {
|
|
return <Badge variant="destructive">Banned</Badge>;
|
|
}
|
|
if (!row.original.email_confirmed_at) {
|
|
return <Badge variant="outline">Unconfirmed</Badge>;
|
|
}
|
|
return <Badge variant="default">Active</Badge>;
|
|
},
|
|
},
|
|
{
|
|
id: "actions",
|
|
header: "",
|
|
cell: ({ row }: { row: { original: User } }) => (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleUserClick(row.original);
|
|
}}
|
|
>
|
|
<MoreHorizontal className="h-4 w-4" />
|
|
</Button>
|
|
),
|
|
},
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
|
<div className="relative w-full sm:w-72">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder="Search users..."
|
|
className="pl-8"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
/>
|
|
{searchQuery && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-0 top-0 h-9 w-9"
|
|
onClick={() => setSearchQuery("")}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
<div className="flex gap-2 w-full sm:w-auto">
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button variant="outline" className="gap-1">
|
|
<PlusCircle className="h-4 w-4" />
|
|
Add User
|
|
<ChevronDown className="h-4 w-4 ml-1" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem onClick={() => setIsAddUserOpen(true)}>
|
|
<UserPlus className="h-4 w-4 mr-2" />
|
|
Create new user
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={() => setIsInviteUserOpen(true)}>
|
|
<Mail className="h-4 w-4 mr-2" />
|
|
Send invitation
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
className={activeFilterCount > 0 ? "relative" : ""}
|
|
onClick={clearFilters}
|
|
>
|
|
<ListFilter className="h-4 w-4" />
|
|
{activeFilterCount > 0 && (
|
|
<span className="absolute -top-1 -right-1 bg-primary text-primary-foreground rounded-full w-4 h-4 text-xs flex items-center justify-center">
|
|
{activeFilterCount}
|
|
</span>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
<DataTable
|
|
columns={columns}
|
|
data={filteredUsers}
|
|
loading={isLoading}
|
|
onRowClick={(user) => handleUserClick(user)}
|
|
/>
|
|
|
|
{selectedUser && (
|
|
<UserDetailsSheet
|
|
user={selectedUser}
|
|
open={isSheetOpen}
|
|
onOpenChange={setIsSheetOpen}
|
|
onUserUpdate={handleUserUpdate}
|
|
/>
|
|
)}
|
|
|
|
<AddUserDialog
|
|
open={isAddUserOpen}
|
|
onOpenChange={setIsAddUserOpen}
|
|
onUserAdded={() => refetch()}
|
|
/>
|
|
|
|
<InviteUserDialog
|
|
open={isInviteUserOpen}
|
|
onOpenChange={setIsInviteUserOpen}
|
|
onUserInvited={() => refetch()}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|