Refactor lagi anjir
This commit is contained in:
parent
99692c37da
commit
741c44ebe5
|
@ -29,7 +29,7 @@ import { Label } from "@/app/_components/ui/label";
|
|||
import { ImageIcon, Loader2 } from "lucide-react";
|
||||
import { createClient } from "@/app/_utils/supabase/client";
|
||||
import { getFullName, getInitials } from "@/app/_utils/common";
|
||||
import { useProfileFormHandlers } from "../_handlers/use-profile-form";
|
||||
import { useProfileFormHandlers } from "../dashboard/user-management/_handlers/use-profile-form";
|
||||
import { CTexts } from "@/app/_lib/const/string";
|
||||
|
||||
// Profile update form schema
|
||||
|
@ -48,105 +48,6 @@ interface ProfileFormProps {
|
|||
}
|
||||
|
||||
export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
||||
// const [isLoading, setIsLoading] = useState(false);
|
||||
// const [avatarPreview, setAvatarPreview] = useState<string | null>(
|
||||
// user?.profile?.avatar || null
|
||||
// );
|
||||
|
||||
// const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// const supabase = createClient();
|
||||
|
||||
// // Use profile data with fallbacks
|
||||
// const firstName = user?.profile?.first_name || "";
|
||||
// const lastName = user?.profile?.last_name || "";
|
||||
// const email = user?.email || "";
|
||||
// const userBio = user?.profile?.bio || "";
|
||||
// const username = user?.profile?.username || "";
|
||||
|
||||
// // Setup form with react-hook-form and zod validation
|
||||
// const form = useForm<ProfileFormValues>({
|
||||
// resolver: zodResolver(profileFormSchema),
|
||||
// defaultValues: {
|
||||
// first_name: firstName || "",
|
||||
// last_name: lastName || "",
|
||||
// bio: userBio || "",
|
||||
// avatar: user?.profile?.avatar || "",
|
||||
// },
|
||||
// });
|
||||
|
||||
// // Handle avatar file upload
|
||||
// const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
// const file = e.target.files?.[0];
|
||||
// if (!file || !user?.id) return;
|
||||
|
||||
// try {
|
||||
// setIsLoading(true);
|
||||
|
||||
// // Create a preview of the selected image
|
||||
// const objectUrl = URL.createObjectURL(file);
|
||||
// setAvatarPreview(objectUrl);
|
||||
|
||||
// // Upload to Supabase Storage
|
||||
// const fileExt = file.name.split(".").pop();
|
||||
// const fileName = `${user.id}-${Date.now()}.${fileExt}`;
|
||||
// const filePath = `avatars/${fileName}`;
|
||||
|
||||
// const { error: uploadError, data } = await supabase.storage
|
||||
// .from("profiles")
|
||||
// .upload(filePath, file, {
|
||||
// upsert: true,
|
||||
// contentType: file.type,
|
||||
// });
|
||||
|
||||
// if (uploadError) {
|
||||
// throw uploadError;
|
||||
// }
|
||||
|
||||
// // Get the public URL
|
||||
// const {
|
||||
// data: { publicUrl },
|
||||
// } = supabase.storage.from("profiles").getPublicUrl(filePath);
|
||||
|
||||
// // Update the form value
|
||||
// form.setValue("avatar", publicUrl);
|
||||
// } catch (error) {
|
||||
// console.error("Error uploading avatar:", error);
|
||||
// // Revert to previous avatar if upload fails
|
||||
// setAvatarPreview(user?.profile?.avatar || null);
|
||||
// } finally {
|
||||
// setIsLoading(false);
|
||||
// }
|
||||
// };
|
||||
|
||||
// // Trigger file input click
|
||||
// const handleAvatarClick = () => {
|
||||
// fileInputRef.current?.click();
|
||||
// };
|
||||
|
||||
// // Handle form submission
|
||||
// async function onSubmit(data: ProfileFormValues) {
|
||||
// try {
|
||||
// if (!user?.id) return;
|
||||
|
||||
// // Update profile in database
|
||||
// const { error } = await supabase
|
||||
// .from("profiles")
|
||||
// .update({
|
||||
// first_name: data.first_name,
|
||||
// last_name: data.last_name,
|
||||
// bio: data.bio,
|
||||
// avatar: data.avatar,
|
||||
// })
|
||||
// .eq("user_id", user.id);
|
||||
|
||||
// if (error) throw error;
|
||||
|
||||
// // Call success callback
|
||||
// onSuccess?.();
|
||||
// } catch (error) {
|
||||
// console.error("Error updating profile:", error);
|
||||
// }
|
||||
// }
|
||||
|
||||
const firstName = user?.profile?.first_name || "";
|
||||
const lastName = user?.profile?.last_name || "";
|
||||
|
@ -177,7 +78,7 @@ export function ProfileForm({ user, onSuccess }: ProfileFormProps) {
|
|||
<AvatarImage src={avatarPreview} alt={username} />
|
||||
) : (
|
||||
<AvatarFallback className="text-2xl">
|
||||
{getInitials(firstName, lastName, email)}
|
||||
{getInitials(firstName, lastName, email)}
|
||||
</AvatarFallback>
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50 rounded-full opacity-0 group-hover:opacity-100 transition-opacity">
|
|
@ -1,128 +0,0 @@
|
|||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Checkbox } from "@/app/_components/ui/checkbox";
|
||||
import { MoreHorizontal } from "lucide-react";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/app/_components/ui/dropdown-menu";
|
||||
import { formatDate } from "date-fns";
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string;
|
||||
first_name: string | null;
|
||||
last_name: string | null;
|
||||
role: string;
|
||||
created_at: string;
|
||||
last_sign_in_at: string | null;
|
||||
email_confirmed_at: string | null;
|
||||
is_anonymous: boolean;
|
||||
banned_until: string | null;
|
||||
};
|
||||
|
||||
export const columns: ColumnDef<User>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: "Email",
|
||||
cell: ({ row }) => (
|
||||
<div className="font-medium">{row.getValue("email")}</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "first_name",
|
||||
header: "First Name",
|
||||
cell: ({ row }) => <div>{row.getValue("first_name") || "-"}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "last_name",
|
||||
header: "Last Name",
|
||||
cell: ({ row }) => <div>{row.getValue("last_name") || "-"}</div>,
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: "Role",
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{row.getValue("role")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "created_at",
|
||||
header: "Created At",
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
{formatDate(new Date(row.getValue("created_at")), "yyyy-MM-dd")}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "email_confirmed_at",
|
||||
header: "Email Verified",
|
||||
cell: ({ row }) => {
|
||||
const verified = row.getValue("email_confirmed_at") !== null;
|
||||
return (
|
||||
<Badge variant={verified ? "default" : "destructive"}>
|
||||
{verified ? "Verified" : "Unverified"}
|
||||
</Badge>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">Open menu</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
<DropdownMenuItem>Edit user</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>Reset password</DropdownMenuItem>
|
||||
<DropdownMenuItem>Send magic link</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive">
|
||||
Delete user
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
|
@ -2,21 +2,21 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/app/_compone
|
|||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Mail, Lock, Loader2 } from "lucide-react"
|
||||
import { ReactHookFormField } from "@/app/_components/react-hook-form-field"
|
||||
import { useAddUserDialogHandler } from "../_handlers/use-add-user-dialog"
|
||||
import { useAddUserDialogHandler } from "../../_handlers/use-add-user-dialog"
|
||||
|
||||
interface AddUserDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUserAdded: () => void
|
||||
}
|
||||
export function AddUserDialog({ open, onOpenChange, onUserAdded }: AddUserDialogProps) {
|
||||
|
||||
export function AddUserDialog({ open, onOpenChange }: AddUserDialogProps) {
|
||||
const {
|
||||
register,
|
||||
errors,
|
||||
isPending,
|
||||
handleSubmit,
|
||||
handleOpenChange,
|
||||
} = useAddUserDialogHandler({ onUserAdded, onOpenChange });
|
||||
} = useAddUserDialogHandler({ onOpenChange });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
@ -19,19 +19,18 @@ import { toast } from "sonner";
|
|||
import { ReactHookFormField } from "@/app/_components/react-hook-form-field";
|
||||
import { Loader2, MailIcon } from "lucide-react";
|
||||
import { Separator } from "@/app/_components/ui/separator";
|
||||
import { useInviteUserHandler } from "../_handlers/use-invite-user";
|
||||
import { useInviteUserHandler } from "../../_handlers/use-invite-user";
|
||||
|
||||
|
||||
|
||||
interface InviteUserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUserInvited: () => void;
|
||||
}
|
||||
|
||||
export function InviteUserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onUserInvited,
|
||||
}: InviteUserDialogProps) {
|
||||
|
||||
const {
|
||||
|
@ -41,7 +40,7 @@ export function InviteUserDialog({
|
|||
errors,
|
||||
isPending,
|
||||
handleOpenChange
|
||||
} = useInviteUserHandler({ onUserInvited, onOpenChange });
|
||||
} = useInviteUserHandler({ onOpenChange });
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
@ -0,0 +1,100 @@
|
|||
import { ConfirmDialog } from "@/app/_components/confirm-dialog"
|
||||
import { AddUserDialog } from "./add-user-dialog"
|
||||
import { InviteUserDialog } from "./invite-user-dialog"
|
||||
import { BanUserDialog } from "./ban-user-dialog"
|
||||
import { ShieldCheck } from "lucide-react"
|
||||
import { useCreateUserColumn } from "../../_handlers/use-create-user-column"
|
||||
import { useUserManagementHandlers } from "../../_handlers/use-user-management"
|
||||
import { useAddUserDialogHandler } from "../../_handlers/use-add-user-dialog"
|
||||
import { useInviteUserHandler } from "../../_handlers/use-invite-user"
|
||||
|
||||
|
||||
export const UserDialogs = () => {
|
||||
|
||||
// User management handler
|
||||
const {
|
||||
isAddUserDialogOpen,
|
||||
setIsAddUserDialogOpen,
|
||||
} = useAddUserDialogHandler({
|
||||
onOpenChange: (open) => setIsAddUserDialogOpen(open),
|
||||
})
|
||||
|
||||
const {
|
||||
isInviteUserDialogOpen,
|
||||
setIsInviteUserDialogOpen,
|
||||
} = useInviteUserHandler({
|
||||
onOpenChange: (open) => setIsInviteUserDialogOpen(open),
|
||||
})
|
||||
|
||||
const {
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
|
||||
handleDeleteConfirm,
|
||||
isDeletePending,
|
||||
banDialogOpen,
|
||||
setBanDialogOpen,
|
||||
|
||||
handleBanConfirm,
|
||||
unbanDialogOpen,
|
||||
setUnbanDialogOpen,
|
||||
|
||||
isBanPending,
|
||||
isUnbanPending,
|
||||
handleUnbanConfirm,
|
||||
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
} = useCreateUserColumn()
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddUserDialog
|
||||
open={isAddUserDialogOpen}
|
||||
onOpenChange={setIsAddUserDialogOpen}
|
||||
/>
|
||||
|
||||
<InviteUserDialog
|
||||
open={isInviteUserDialogOpen}
|
||||
onOpenChange={setIsInviteUserDialogOpen}
|
||||
/>
|
||||
|
||||
{/* Alert Dialog for Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete the user account and remove their data from our servers."
|
||||
confirmText="Delete"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isPending={isDeletePending}
|
||||
pendingText="Deleting..."
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Alert Dialog for Ban Confirmation */}
|
||||
<BanUserDialog
|
||||
open={banDialogOpen}
|
||||
onOpenChange={setBanDialogOpen}
|
||||
onConfirm={handleBanConfirm}
|
||||
isPending={isBanPending}
|
||||
/>
|
||||
|
||||
{/* Alert Dialog for Unban Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={unbanDialogOpen}
|
||||
onOpenChange={setUnbanDialogOpen}
|
||||
title="Unban User"
|
||||
description="This will restore the user's access to the system. Are you sure you want to unban this user?"
|
||||
confirmText="Unban"
|
||||
onConfirm={handleUnbanConfirm}
|
||||
isPending={isUnbanPending}
|
||||
pendingText="Unbanning..."
|
||||
variant="default"
|
||||
size="sm"
|
||||
confirmIcon={<ShieldCheck className="h-4 w-4" />}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -1,301 +0,0 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/app/_components/ui/sheet";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Separator } from "@/app/_components/ui/separator";
|
||||
import {
|
||||
Mail,
|
||||
Trash2,
|
||||
Ban,
|
||||
SendHorizonal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Copy,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
import { formatDate } from "@/app/_utils/common";
|
||||
import { useUserDetailSheetHandlers } from "../_handlers/use-detail-sheet";
|
||||
import { CAlertDialog } from "@/app/_components/alert-dialog";
|
||||
|
||||
interface UserDetailSheetProps {
|
||||
open: boolean;
|
||||
user: IUserSchema;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUserUpdated: () => void;
|
||||
}
|
||||
|
||||
export function UserDetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
onUserUpdated,
|
||||
}: UserDetailSheetProps) {
|
||||
|
||||
const {
|
||||
handleDeleteUser,
|
||||
handleSendPasswordRecovery,
|
||||
handleSendMagicLink,
|
||||
handleCopyItem,
|
||||
handleToggleBan,
|
||||
isBanPending,
|
||||
isUnbanPending,
|
||||
isDeletePending,
|
||||
isSendPasswordRecoveryPending,
|
||||
isSendMagicLinkPending,
|
||||
} = useUserDetailSheetHandlers({ open, user, onUserUpdated, onOpenChange });
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-md md:max-w-xl overflow-y-auto">
|
||||
<SheetHeader className="space-y-1">
|
||||
<SheetTitle className="text-xl flex items-center gap-2">
|
||||
{user.email}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4"
|
||||
onClick={() => handleCopyItem(user.email ?? "", "Email")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{user.banned_until && <Badge variant="destructive">Banned</Badge>}
|
||||
{!user.email_confirmed_at && (
|
||||
<Badge variant="outline">Unconfirmed</Badge>
|
||||
)}
|
||||
{!user.banned_until && user.email_confirmed_at && (
|
||||
<Badge variant="default">Active</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="mt-6 space-y-8">
|
||||
{/* User Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Information</h3>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">User UID</span>
|
||||
<div className="flex items-center">
|
||||
<span className="font-mono">{user.id}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 ml-2"
|
||||
onClick={() => handleCopyItem(user.id, "UID")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">Created at</span>
|
||||
<span>{formatDate(user.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">Updated at</span>
|
||||
<span>
|
||||
{formatDate(user.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">Invited at</span>
|
||||
<span>{formatDate(user.invited_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">
|
||||
Confirmation sent at
|
||||
</span>
|
||||
<span>{formatDate(user.email_confirmed_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">Confirmed at</span>
|
||||
<span>{formatDate(user.email_confirmed_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">Last signed in</span>
|
||||
<span>{formatDate(user.last_sign_in_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">SSO</span>
|
||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Provider Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Provider Information</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The user has the following providers
|
||||
</p>
|
||||
|
||||
<div className="border rounded-md p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
<div>
|
||||
<div className="font-medium">Email</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Signed in with a email account
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" /> Enabled
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Reset password</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a password recovery email to the user
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSendPasswordRecovery}
|
||||
disabled={isSendPasswordRecoveryPending}
|
||||
>
|
||||
{isSendPasswordRecoveryPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send password recovery
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Send magic link</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Passwordless login via email for the user
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSendMagicLink}
|
||||
disabled={isSendMagicLinkPending}
|
||||
>
|
||||
{isSendMagicLinkPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendHorizonal className="h-4 w-4 mr-2" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-destructive">
|
||||
Danger zone
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Be wary of the following features as they cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border border-destructive/20 rounded-md p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Ban user</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Revoke access to the project for a set duration
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={user.banned_until ? "outline" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleToggleBan()}
|
||||
disabled={isBanPending || isUnbanPending}
|
||||
>
|
||||
{isBanPending || isUnbanPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{user.banned_until ? "Unbanning..." : "Banning..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
{user.banned_until ? "Unban user" : "Ban user"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-destructive/20 rounded-md p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Delete user</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
User will no longer have access to the project
|
||||
</p>
|
||||
</div>
|
||||
<CAlertDialog
|
||||
triggerText="Delete user"
|
||||
triggerIcon={<Trash2 className="h-4 w-4" />}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete the user account and remove their data from our servers."
|
||||
confirmText="Delete"
|
||||
onConfirm={handleDeleteUser}
|
||||
isPending={isDeletePending}
|
||||
pendingText="Deleting..."
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <SheetFooter className="mt-6">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</SheetFooter> */}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
|
@ -15,27 +15,26 @@ import { Button } from "@/app/_components/ui/button"
|
|||
import { FormSection } from "@/app/_components/form-section"
|
||||
import { FormFieldWrapper } from "@/app/_components/form-wrapper"
|
||||
import { useMutation } from "@tanstack/react-query"
|
||||
import { updateUser } from "../action"
|
||||
import { updateUser } from "../../action"
|
||||
import { toast } from "sonner"
|
||||
import { UpdateUserSchema } from "@/src/entities/models/users/update-user.model"
|
||||
import { useUserProfileSheetHandlers } from "../_handlers/use-profile-sheet"
|
||||
import { useUpdateUserSheetHandlers } from "../../_handlers/use-profile-sheet"
|
||||
|
||||
type UserProfileFormValues = z.infer<typeof UpdateUserSchema>
|
||||
|
||||
interface UserProfileSheetProps {
|
||||
interface UpdateUserSheetProps {
|
||||
open: boolean
|
||||
userData: IUserSchema
|
||||
onOpenChange: (open: boolean) => void
|
||||
onUserUpdated: () => void
|
||||
}
|
||||
|
||||
export function UserProfileSheet({ open, onOpenChange, userData, onUserUpdated }: UserProfileSheetProps) {
|
||||
export function UpdateUserSheet({ open, onOpenChange, userData }: UpdateUserSheetProps) {
|
||||
|
||||
const {
|
||||
form,
|
||||
handleUpdateUser,
|
||||
isPending,
|
||||
} = useUserProfileSheetHandlers({ open, userData, onOpenChange, onUserUpdated })
|
||||
} = useUpdateUserSheetHandlers({ open, userData, onOpenChange })
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
|
@ -0,0 +1,95 @@
|
|||
// components/user-management/sheet/user-information-sheet.tsx
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/app/_components/ui/sheet";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Copy } from "lucide-react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs";
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
import { useUserDetailSheetHandlers } from "../../_handlers/use-detail-sheet";
|
||||
import { UserDetailsTab } from "../tabs/user-detail-tab";
|
||||
import { UserLogsTab } from "../tabs/user-log-tab";
|
||||
import { UserOverviewTab } from "../tabs/user-overview-tab";
|
||||
|
||||
|
||||
interface UserInformationSheetProps {
|
||||
open: boolean;
|
||||
user: IUserSchema;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function UserInformationSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
user,
|
||||
}: UserInformationSheetProps) {
|
||||
const [activeTab, setActiveTab] = useState("overview");
|
||||
|
||||
const {
|
||||
handleCopyItem,
|
||||
} = useUserDetailSheetHandlers({ open, user, onOpenChange });
|
||||
|
||||
const getUserStatusBadge = () => {
|
||||
if (user.banned_until) {
|
||||
return <Badge variant="destructive">Banned</Badge>;
|
||||
}
|
||||
if (!user.email_confirmed_at) {
|
||||
return <Badge variant="outline">Unconfirmed</Badge>;
|
||||
}
|
||||
return <Badge variant="default">Active</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="sm:max-w-md md:max-w-xl overflow-y-auto">
|
||||
<SheetHeader className="space-y-1">
|
||||
<SheetTitle className="text-xl flex items-center gap-2">
|
||||
{user.email}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4"
|
||||
onClick={() => handleCopyItem(user.email ?? "", "Email")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
{getUserStatusBadge()}
|
||||
</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<Tabs
|
||||
defaultValue="overview"
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="mt-6"
|
||||
>
|
||||
<TabsList className="grid grid-cols-3 mb-4">
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs</TabsTrigger>
|
||||
<TabsTrigger value="details">Details</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<UserOverviewTab
|
||||
user={user}
|
||||
handleCopyItem={handleCopyItem}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs">
|
||||
<UserLogsTab user={user} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="details">
|
||||
<UserDetailsTab user={user} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
// "use client"
|
||||
|
||||
// import React from "react"
|
||||
// import { Badge } from "@/app/_components/ui/badge"
|
||||
// import { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
|
||||
// interface AccessCellProps {
|
||||
// user: IUserSchema
|
||||
// }
|
||||
|
||||
// const ACCESS = [
|
||||
// "Admin",
|
||||
// "Super Admin",
|
||||
// "Data Export",
|
||||
// "Data Import",
|
||||
// "Insert",
|
||||
// "Update",
|
||||
// "Delete",
|
||||
// ]
|
||||
|
||||
// export const AccessCell: React.FC<AccessCellProps> = ({ user }) => {
|
||||
// const userAccess = ACCESS.filter(access => user.access?.includes(access))
|
||||
|
||||
// return (
|
||||
// <div className="flex flex-wrap gap-2">
|
||||
// {userAccess.map(access => (
|
||||
// <Badge key={access} variant="default">
|
||||
// {access}
|
||||
// </Badge>
|
||||
// ))}
|
||||
// {user.banned_until && <Badge variant="destructive">Banned</Badge>}
|
||||
// {!user.email_confirmed_at && <Badge variant="outline">Unconfirmed</Badge>}
|
||||
// </div>
|
||||
// )
|
||||
// }
|
|
@ -0,0 +1,121 @@
|
|||
// cells/actions-cell.tsx
|
||||
|
||||
import React, { useState } from "react"
|
||||
import { MoreHorizontal, PenIcon as UserPen, Trash2, ShieldAlert, ShieldCheck } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from "@/app/_components/ui/dropdown-menu"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { ConfirmDialog } from "@/app/_components/confirm-dialog"
|
||||
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
import { useUserActionsHandler } from "../../../_handlers/actions/use-user-actions"
|
||||
import { BanUserDialog } from "../../dialogs/ban-user-dialog"
|
||||
import { useCreateUserColumn } from "../../../_handlers/use-create-user-column"
|
||||
|
||||
|
||||
interface ActionsCellProps {
|
||||
user: IUserSchema
|
||||
onUpdate: (user: IUserSchema) => void
|
||||
}
|
||||
|
||||
export const ActionsCell: React.FC<ActionsCellProps> = ({ user, onUpdate }) => {
|
||||
|
||||
const {
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
handleDeleteConfirm,
|
||||
isDeletePending,
|
||||
banDialogOpen,
|
||||
setBanDialogOpen,
|
||||
handleBanConfirm,
|
||||
unbanDialogOpen,
|
||||
setUnbanDialogOpen,
|
||||
isBanPending,
|
||||
isUnbanPending,
|
||||
handleUnbanConfirm,
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
} = useCreateUserColumn()
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onUpdate(user)}>
|
||||
<UserPen className="h-4 w-4 mr-2 text-blue-500" />
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
setSelectedUser({ id: user.id, email: user.email! })
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2 text-destructive" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
if (user.banned_until != null) {
|
||||
setSelectedUser({ id: user.id, email: user.email! })
|
||||
setUnbanDialogOpen(true)
|
||||
} else {
|
||||
setSelectedUser({ id: user.id, email: user.email! })
|
||||
setBanDialogOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4 mr-2 text-yellow-500" />
|
||||
{user.banned_until != null ? "Unban" : "Ban"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Alert Dialog for Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete the user account and remove their data from our servers."
|
||||
confirmText="Delete"
|
||||
onConfirm={handleDeleteConfirm}
|
||||
isPending={isDeletePending}
|
||||
pendingText="Deleting..."
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Alert Dialog for Ban Confirmation */}
|
||||
<BanUserDialog
|
||||
open={banDialogOpen}
|
||||
onOpenChange={setBanDialogOpen}
|
||||
onConfirm={handleBanConfirm}
|
||||
isPending={isBanPending}
|
||||
/>
|
||||
|
||||
{/* Alert Dialog for Unban Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={unbanDialogOpen}
|
||||
onOpenChange={setUnbanDialogOpen}
|
||||
title="Unban User"
|
||||
description="This will restore the user's access to the system. Are you sure you want to unban this user?"
|
||||
confirmText="Unban"
|
||||
onConfirm={handleUnbanConfirm}
|
||||
isPending={isUnbanPending}
|
||||
pendingText="Unbanning..."
|
||||
variant="default"
|
||||
size="sm"
|
||||
confirmIcon={<ShieldCheck className="h-4 w-4" />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
// cells/created-at-cell.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
interface CreatedAtCellProps {
|
||||
createdAt: string | null | undefined
|
||||
}
|
||||
|
||||
export const CreatedAtCell: React.FC<CreatedAtCellProps> = ({ createdAt }) => {
|
||||
return <>{createdAt ? new Date(createdAt).toLocaleString() : "N/A"}</>
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
// cells/email-cell.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import Image from "next/image"
|
||||
import { Avatar } from "@/app/_components/ui/avatar"
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
|
||||
interface EmailCellProps {
|
||||
user: IUserSchema
|
||||
}
|
||||
|
||||
export const EmailCell: React.FC<EmailCellProps> = ({ user }) => {
|
||||
return (
|
||||
<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">
|
||||
{user.profile?.avatar ? (
|
||||
<Image
|
||||
src={user.profile.avatar || "/placeholder.svg"}
|
||||
alt="Avatar"
|
||||
className="w-full h-full rounded-full"
|
||||
width={32}
|
||||
height={32}
|
||||
/>
|
||||
) : (
|
||||
user.email?.[0]?.toUpperCase() || "?"
|
||||
)}
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="font-medium">{user.email || "No email"}</div>
|
||||
<div className="text-xs text-muted-foreground">{user.profile?.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
// cells/last-sign-in-cell.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
interface LastSignInCellProps {
|
||||
lastSignInAt: string | null | undefined
|
||||
}
|
||||
|
||||
export const LastSignInCell: React.FC<LastSignInCellProps> = ({ lastSignInAt }) => {
|
||||
return <>{lastSignInAt ? new Date(lastSignInAt).toLocaleString() : "Never"}</>
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
// cells/phone-cell.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
interface PhoneCellProps {
|
||||
phone: string | null | undefined
|
||||
}
|
||||
|
||||
export const PhoneCell: React.FC<PhoneCellProps> = ({ phone }) => {
|
||||
return <>{phone || "-"}</>
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
// cells/status-cell.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { Badge } from "@/app/_components/ui/badge"
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
|
||||
interface StatusCellProps {
|
||||
user: IUserSchema
|
||||
}
|
||||
|
||||
export const StatusCell: React.FC<StatusCellProps> = ({ user }) => {
|
||||
if (user.banned_until) {
|
||||
return <Badge variant="destructive">Banned</Badge>
|
||||
}
|
||||
if (!user.email_confirmed_at) {
|
||||
return <Badge variant="outline">Unconfirmed</Badge>
|
||||
}
|
||||
return <Badge variant="default">Active</Badge>
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
// import type { ColumnDef } from "@tanstack/react-table";
|
||||
// import { Badge } from "@/app/_components/ui/badge";
|
||||
// import { Checkbox } from "@/app/_components/ui/checkbox";
|
||||
// import { MoreHorizontal } from "lucide-react";
|
||||
// import { Button } from "@/app/_components/ui/button";
|
||||
// import {
|
||||
// DropdownMenu,
|
||||
// DropdownMenuContent,
|
||||
// DropdownMenuItem,
|
||||
// DropdownMenuLabel,
|
||||
// DropdownMenuSeparator,
|
||||
// DropdownMenuTrigger,
|
||||
// } from "@/app/_components/ui/dropdown-menu";
|
||||
// import { formatDate } from "date-fns";
|
||||
|
||||
// export type User = {
|
||||
// id: string;
|
||||
// email: string;
|
||||
// first_name: string | null;
|
||||
// last_name: string | null;
|
||||
// role: string;
|
||||
// created_at: string;
|
||||
// last_sign_in_at: string | null;
|
||||
// email_confirmed_at: string | null;
|
||||
// is_anonymous: boolean;
|
||||
// banned_until: string | null;
|
||||
// };
|
||||
|
||||
// export const columns: ColumnDef<User>[] = [
|
||||
// {
|
||||
// id: "select",
|
||||
// header: ({ table }) => (
|
||||
// <Checkbox
|
||||
// checked={
|
||||
// table.getIsAllPageRowsSelected() ||
|
||||
// (table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
// }
|
||||
// onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
// aria-label="Select all"
|
||||
// />
|
||||
// ),
|
||||
// cell: ({ row }) => (
|
||||
// <Checkbox
|
||||
// checked={row.getIsSelected()}
|
||||
// onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
// aria-label="Select row"
|
||||
// onClick={(e) => e.stopPropagation()}
|
||||
// />
|
||||
// ),
|
||||
// enableSorting: false,
|
||||
// enableHiding: false,
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "email",
|
||||
// header: "Email",
|
||||
// cell: ({ row }) => (
|
||||
// <div className="font-medium">{row.getValue("email")}</div>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "first_name",
|
||||
// header: "First Name",
|
||||
// cell: ({ row }) => <div>{row.getValue("first_name") || "-"}</div>,
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "last_name",
|
||||
// header: "Last Name",
|
||||
// cell: ({ row }) => <div>{row.getValue("last_name") || "-"}</div>,
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "role",
|
||||
// header: "Role",
|
||||
// cell: ({ row }) => (
|
||||
// <Badge variant="outline" className="capitalize">
|
||||
// {row.getValue("role")}
|
||||
// </Badge>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "created_at",
|
||||
// header: "Created At",
|
||||
// cell: ({ row }) => (
|
||||
// <div>
|
||||
// {formatDate(new Date(row.getValue("created_at")), "yyyy-MM-dd")}
|
||||
// </div>
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// accessorKey: "email_confirmed_at",
|
||||
// header: "Email Verified",
|
||||
// cell: ({ row }) => {
|
||||
// const verified = row.getValue("email_confirmed_at") !== null;
|
||||
// return (
|
||||
// <Badge variant={verified ? "default" : "destructive"}>
|
||||
// {verified ? "Verified" : "Unverified"}
|
||||
// </Badge>
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// id: "actions",
|
||||
// cell: ({ row }) => {
|
||||
// const user = row.original;
|
||||
|
||||
// return (
|
||||
// <DropdownMenu>
|
||||
// <DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
// <Button variant="ghost" className="h-8 w-8 p-0">
|
||||
// <span className="sr-only">Open menu</span>
|
||||
// <MoreHorizontal className="h-4 w-4" />
|
||||
// </Button>
|
||||
// </DropdownMenuTrigger>
|
||||
// <DropdownMenuContent align="end">
|
||||
// <DropdownMenuLabel>Actions</DropdownMenuLabel>
|
||||
// <DropdownMenuItem>Edit user</DropdownMenuItem>
|
||||
// <DropdownMenuSeparator />
|
||||
// <DropdownMenuItem>Reset password</DropdownMenuItem>
|
||||
// <DropdownMenuItem>Send magic link</DropdownMenuItem>
|
||||
// <DropdownMenuSeparator />
|
||||
// <DropdownMenuItem className="text-destructive">
|
||||
// Delete user
|
||||
// </DropdownMenuItem>
|
||||
// </DropdownMenuContent>
|
||||
// </DropdownMenu>
|
||||
// );
|
||||
// },
|
||||
// },
|
||||
// ];
|
|
@ -0,0 +1,32 @@
|
|||
// columns/status-column.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import type { HeaderContext } from "@tanstack/react-table"
|
||||
import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ColumnFilter } from "../filters/column-filter"
|
||||
import { StatusFilter } from "../filters/status-filter"
|
||||
import { StatusCell } from "../cells/status-cell"
|
||||
import { AccessFilter } from "../filters/access-filter"
|
||||
|
||||
export const createAccessColumn = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void
|
||||
) => {
|
||||
return {
|
||||
id: "status",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Access</span>
|
||||
<ColumnFilter>
|
||||
<AccessFilter
|
||||
statusValues={filters.status}
|
||||
onChange={(values) => setFilters({ ...filters, status: values })}
|
||||
onClear={() => setFilters({ ...filters, status: [] })}
|
||||
/>
|
||||
</ColumnFilter>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: { original: IUserSchema } }) => <StatusCell user={row.original} />
|
||||
}
|
||||
}
|
|
@ -0,0 +1,16 @@
|
|||
// columns/actions-column.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ActionsCell } from "../cells/actions-cell"
|
||||
|
||||
export const createActionsColumn = (
|
||||
handleUserUpdate: (user: IUserSchema) => void
|
||||
) => {
|
||||
return {
|
||||
id: "actions",
|
||||
header: "",
|
||||
cell: ({ row }: { row: { original: IUserSchema } }) => <ActionsCell user={row.original} onUpdate={handleUserUpdate} />
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
// columns/created-at-column.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import type { HeaderContext } from "@tanstack/react-table"
|
||||
import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ColumnFilter } from "../filters/column-filter"
|
||||
import { DateFilter } from "../filters/date-filter"
|
||||
import { CreatedAtCell } from "../cells/created-at-cell"
|
||||
|
||||
export const createCreatedAtColumn = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void
|
||||
) => {
|
||||
return {
|
||||
id: "createdAt",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Created At</span>
|
||||
<ColumnFilter>
|
||||
<DateFilter
|
||||
value={filters.createdAt}
|
||||
onChange={(value) => setFilters({ ...filters, createdAt: value })}
|
||||
onClear={() => setFilters({ ...filters, createdAt: "" })}
|
||||
/>
|
||||
</ColumnFilter>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: { original: IUserSchema } }) => (
|
||||
<CreatedAtCell createdAt={row.original.created_at instanceof Date ? row.original.created_at.toISOString() : row.original.created_at} />
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
// columns/email-column.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import type { HeaderContext } from "@tanstack/react-table"
|
||||
import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ColumnFilter } from "../filters/column-filter"
|
||||
import { TextFilter } from "../filters/text-filter"
|
||||
import { EmailCell } from "../cells/email-cell"
|
||||
|
||||
export const createEmailColumn = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void
|
||||
) => {
|
||||
return {
|
||||
id: "email",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Email</span>
|
||||
<ColumnFilter>
|
||||
<TextFilter
|
||||
placeholder="Filter by email..."
|
||||
value={filters.email}
|
||||
onChange={(value) => setFilters({ ...filters, email: value })}
|
||||
onClear={() => setFilters({ ...filters, email: "" })}
|
||||
/>
|
||||
</ColumnFilter>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: { original: IUserSchema } }) => <EmailCell user={row.original} />
|
||||
}
|
||||
}
|
|
@ -0,0 +1,28 @@
|
|||
// columns/index.ts
|
||||
"use client"
|
||||
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { createEmailColumn } from "./email-column"
|
||||
import { createPhoneColumn } from "./phone-column"
|
||||
import { createCreatedAtColumn } from "./created-at-column"
|
||||
import { createStatusColumn } from "./status-column"
|
||||
import { createActionsColumn } from "./actions-column"
|
||||
import { createLastSignInColumn } from "./last-sign-in-column"
|
||||
|
||||
export type UserTableColumn = ColumnDef<IUserSchema, IUserSchema>
|
||||
|
||||
export const createUserColumns = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void,
|
||||
handleUserUpdate: (user: IUserSchema) => void,
|
||||
): UserTableColumn[] => {
|
||||
return [
|
||||
createEmailColumn(filters, setFilters),
|
||||
createPhoneColumn(filters, setFilters),
|
||||
createLastSignInColumn(filters, setFilters),
|
||||
createCreatedAtColumn(filters, setFilters),
|
||||
createStatusColumn(filters, setFilters),
|
||||
createActionsColumn(handleUserUpdate),
|
||||
]
|
||||
}
|
|
@ -0,0 +1,40 @@
|
|||
// columns/last-sign-in-column.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import type { HeaderContext } from "@tanstack/react-table"
|
||||
import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ColumnFilter } from "../filters/column-filter"
|
||||
import { DateFilter } from "../filters/date-filter"
|
||||
import { LastSignInCell } from "../cells/last-sign-in-cell"
|
||||
|
||||
export const createLastSignInColumn = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void
|
||||
) => {
|
||||
return {
|
||||
id: "lastSignIn",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Last Sign In</span>
|
||||
<ColumnFilter>
|
||||
<DateFilter
|
||||
value={filters.lastSignIn}
|
||||
onChange={(value) => setFilters({ ...filters, lastSignIn: value })}
|
||||
onClear={() => setFilters({ ...filters, lastSignIn: "" })}
|
||||
includeNever={true}
|
||||
/>
|
||||
</ColumnFilter>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: { original: IUserSchema } }) => (
|
||||
<LastSignInCell
|
||||
lastSignInAt={
|
||||
row.original.last_sign_in_at instanceof Date
|
||||
? row.original.last_sign_in_at.toISOString()
|
||||
: row.original.last_sign_in_at
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
// columns/phone-column.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import type { HeaderContext } from "@tanstack/react-table"
|
||||
import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ColumnFilter } from "../filters/column-filter"
|
||||
import { TextFilter } from "../filters/text-filter"
|
||||
import { PhoneCell } from "../cells/phone-cell"
|
||||
|
||||
export const createPhoneColumn = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void
|
||||
) => {
|
||||
return {
|
||||
id: "phone",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Phone</span>
|
||||
<ColumnFilter>
|
||||
<TextFilter
|
||||
placeholder="Filter by phone..."
|
||||
value={filters.phone}
|
||||
onChange={(value) => setFilters({ ...filters, phone: value })}
|
||||
onClear={() => setFilters({ ...filters, phone: "" })}
|
||||
/>
|
||||
</ColumnFilter>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: { original: IUserSchema } }) => <PhoneCell phone={row.original.phone} />
|
||||
}
|
||||
}
|
|
@ -0,0 +1,31 @@
|
|||
// columns/status-column.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import type { HeaderContext } from "@tanstack/react-table"
|
||||
import { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ColumnFilter } from "../filters/column-filter"
|
||||
import { StatusFilter } from "../filters/status-filter"
|
||||
import { StatusCell } from "../cells/status-cell"
|
||||
|
||||
export const createStatusColumn = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void
|
||||
) => {
|
||||
return {
|
||||
id: "status",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>Status</span>
|
||||
<ColumnFilter>
|
||||
<StatusFilter
|
||||
statusValues={filters.status}
|
||||
onChange={(values) => setFilters({ ...filters, status: values })}
|
||||
onClear={() => setFilters({ ...filters, status: [] })}
|
||||
/>
|
||||
</ColumnFilter>
|
||||
</div>
|
||||
),
|
||||
cell: ({ row }: { row: { original: IUserSchema } }) => <StatusCell user={row.original} />
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
// filters/status-filter.tsx
|
||||
|
||||
|
||||
import React from "react"
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuSeparator, DropdownMenuItem } from "@/app/_components/ui/dropdown-menu"
|
||||
|
||||
interface AccessFilterProps {
|
||||
statusValues: string[]
|
||||
onChange: (values: string[]) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export const AccessFilter: React.FC<AccessFilterProps> = ({ statusValues, onChange, onClear }) => {
|
||||
const toggleStatus = (status: string) => {
|
||||
const newStatus = [...statusValues]
|
||||
if (newStatus.includes(status)) {
|
||||
newStatus.splice(newStatus.indexOf(status), 1)
|
||||
} else {
|
||||
newStatus.push(status)
|
||||
}
|
||||
onChange(newStatus)
|
||||
}
|
||||
|
||||
const ACCESS = [
|
||||
"Admin",
|
||||
"Super Admin",
|
||||
"Data Export",
|
||||
"Data Import",
|
||||
"Insert",
|
||||
"Update",
|
||||
"Delete",
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
{ACCESS.map((access) => (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={access}
|
||||
checked={statusValues.includes(access)}
|
||||
onCheckedChange={() => toggleStatus(access)}
|
||||
>
|
||||
{access}
|
||||
</DropdownMenuCheckboxItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onClear}>Clear filter</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
// filters/column-filter.tsx
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
import { ListFilter } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
} from "@/app/_components/ui/dropdown-menu"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
|
||||
interface ColumnFilterProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const ColumnFilter: React.FC<ColumnFilterProps> = ({ children }) => {
|
||||
return (
|
||||
<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">
|
||||
{children}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,46 @@
|
|||
// filters/date-filter.tsx
|
||||
|
||||
import React from "react"
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuSeparator, DropdownMenuItem } from "@/app/_components/ui/dropdown-menu"
|
||||
|
||||
interface DateFilterProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onClear: () => void
|
||||
includeNever?: boolean
|
||||
}
|
||||
|
||||
export const DateFilter: React.FC<DateFilterProps> = ({ value, onChange, onClear, includeNever = false }) => {
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value === "today"}
|
||||
onCheckedChange={() => onChange(value === "today" ? "" : "today")}
|
||||
>
|
||||
Today
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value === "week"}
|
||||
onCheckedChange={() => onChange(value === "week" ? "" : "week")}
|
||||
>
|
||||
Last 7 days
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value === "month"}
|
||||
onCheckedChange={() => onChange(value === "month" ? "" : "month")}
|
||||
>
|
||||
Last 30 days
|
||||
</DropdownMenuCheckboxItem>
|
||||
{includeNever && (
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={value === "never"}
|
||||
onCheckedChange={() => onChange(value === "never" ? "" : "never")}
|
||||
>
|
||||
Never
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onClear}>Clear filter</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,48 @@
|
|||
// filters/status-filter.tsx
|
||||
|
||||
|
||||
import React from "react"
|
||||
import { DropdownMenuCheckboxItem, DropdownMenuSeparator, DropdownMenuItem } from "@/app/_components/ui/dropdown-menu"
|
||||
|
||||
interface StatusFilterProps {
|
||||
statusValues: string[]
|
||||
onChange: (values: string[]) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export const StatusFilter: React.FC<StatusFilterProps> = ({ statusValues, onChange, onClear }) => {
|
||||
const toggleStatus = (status: string) => {
|
||||
const newStatus = [...statusValues]
|
||||
if (newStatus.includes(status)) {
|
||||
newStatus.splice(newStatus.indexOf(status), 1)
|
||||
} else {
|
||||
newStatus.push(status)
|
||||
}
|
||||
onChange(newStatus)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusValues.includes("active")}
|
||||
onCheckedChange={() => toggleStatus("active")}
|
||||
>
|
||||
Active
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusValues.includes("unconfirmed")}
|
||||
onCheckedChange={() => toggleStatus("unconfirmed")}
|
||||
>
|
||||
Unconfirmed
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={statusValues.includes("banned")}
|
||||
onCheckedChange={() => toggleStatus("banned")}
|
||||
>
|
||||
Banned
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onClear}>Clear filter</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
// filters/text-filter.tsx
|
||||
|
||||
import React from "react"
|
||||
import { Input } from "@/app/_components/ui/input"
|
||||
import { DropdownMenuItem, DropdownMenuSeparator } from "@/app/_components/ui/dropdown-menu"
|
||||
|
||||
interface TextFilterProps {
|
||||
placeholder: string
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onClear: () => void
|
||||
}
|
||||
|
||||
export const TextFilter: React.FC<TextFilterProps> = ({ placeholder, value, onChange, onClear }) => {
|
||||
return (
|
||||
<>
|
||||
<div className="p-2">
|
||||
<Input
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={onClear}>Clear filter</DropdownMenuItem>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,226 @@
|
|||
// components/user-management/sheet/tabs/user-details-tab.tsx
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
import { formatDate } from "@/app/_utils/common";
|
||||
import { Separator } from "@/app/_components/ui/separator";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Edit2, Eye, EyeOff } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
interface UserDetailsTabProps {
|
||||
user: IUserSchema;
|
||||
}
|
||||
|
||||
export function UserDetailsTab({ user }: UserDetailsTabProps) {
|
||||
const [showSensitiveInfo, setShowSensitiveInfo] = useState(false);
|
||||
|
||||
const toggleSensitiveInfo = () => {
|
||||
setShowSensitiveInfo(!showSensitiveInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">User Details</h3>
|
||||
<Button variant="outline" size="sm" onClick={() => { /* Implement edit functionality */ }}>
|
||||
<Edit2 className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Basic Information</h4>
|
||||
<div className="border rounded-md p-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Email</p>
|
||||
<p className="text-sm font-medium">{user.email || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Phone</p>
|
||||
<p className="text-sm font-medium">{user.phone || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Role</p>
|
||||
<p className="text-sm font-medium">{user.role || "—"}</p>
|
||||
</div>
|
||||
|
||||
{user.is_anonymous !== undefined && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Anonymous</p>
|
||||
<Badge variant={user.is_anonymous ? "default" : "outline"}>
|
||||
{user.is_anonymous ? "Yes" : "No"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">Profile Information</h4>
|
||||
<div className="border rounded-md p-4 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Username</p>
|
||||
<p className="text-sm font-medium">{user.profile?.username || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">First Name</p>
|
||||
<p className="text-sm font-medium">{user.profile?.first_name || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Last Name</p>
|
||||
<p className="text-sm font-medium">{user.profile?.last_name || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Birth Date</p>
|
||||
<p className="text-sm font-medium">
|
||||
{user.profile?.birth_date ? formatDate(user.profile.birth_date) : "—"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Address Information */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Address Information</h3>
|
||||
<div className="border rounded-md p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Street</p>
|
||||
<p className="text-sm font-medium">{user.profile?.address?.street || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">City</p>
|
||||
<p className="text-sm font-medium">{user.profile?.address?.city || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">State</p>
|
||||
<p className="text-sm font-medium">{user.profile?.address?.state || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Country</p>
|
||||
<p className="text-sm font-medium">{user.profile?.address?.country || "—"}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Postal Code</p>
|
||||
<p className="text-sm font-medium">{user.profile?.address?.postal_code || "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Advanced Information */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-lg font-semibold">System Information</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={toggleSensitiveInfo}
|
||||
>
|
||||
{showSensitiveInfo ? (
|
||||
<>
|
||||
<EyeOff className="h-4 w-4 mr-2" />
|
||||
Hide
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Show
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4 space-y-3">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">User ID</p>
|
||||
<p className="text-sm font-medium font-mono">{user.id}</p>
|
||||
</div>
|
||||
|
||||
{showSensitiveInfo && user.encrypted_password && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Password Status</p>
|
||||
<Badge variant="outline">
|
||||
{user.encrypted_password ? "Set" : "Not Set"}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4">
|
||||
<h4 className="text-sm font-medium mb-3">Timestamps</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Created At</p>
|
||||
<p className="text-sm">{formatDate(user.created_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Updated At</p>
|
||||
<p className="text-sm">{formatDate(user.updated_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Last Sign In</p>
|
||||
<p className="text-sm">{formatDate(user.last_sign_in_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Email Confirmed At</p>
|
||||
<p className="text-sm">{formatDate(user.email_confirmed_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Invited At</p>
|
||||
<p className="text-sm">{formatDate(user.invited_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Recovery Sent At</p>
|
||||
<p className="text-sm">{formatDate(user.recovery_sent_at)}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Confirmed At</p>
|
||||
<p className="text-sm">{formatDate(user.confirmed_at)}</p>
|
||||
</div>
|
||||
|
||||
{user.banned_until && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-muted-foreground">Banned Until</p>
|
||||
<p className="text-sm text-destructive">{formatDate(user.banned_until)}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{user.profile?.bio && (
|
||||
<div className="border rounded-md p-4">
|
||||
<h4 className="text-sm font-medium mb-2">Bio</h4>
|
||||
<p className="text-sm whitespace-pre-wrap">{user.profile.bio}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,387 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
import { formatDistance } from "date-fns";
|
||||
import { Loader2, RefreshCw, UserCheck, Mail, Lock, LogIn, LogOut, Plus, Edit, Trash, FileText } from "lucide-react";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { ScrollArea } from "@/app/_components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/app/_components/ui/tabs";
|
||||
import { Skeleton } from "@/app/_components/ui/skeleton";
|
||||
|
||||
// Extended interface for user logs with more types
|
||||
interface UserLog {
|
||||
id: string;
|
||||
type: 'login' | 'logout' | 'password_reset' | 'email_change' | 'profile_update' | 'account_creation' |
|
||||
'token_request' | 'insert' | 'update' | 'delete' | 'view';
|
||||
timestamp: Date;
|
||||
status_code?: string;
|
||||
ip_address?: string;
|
||||
user_agent?: string;
|
||||
details?: string;
|
||||
resource?: string; // For database actions: which resource was modified
|
||||
endpoint?: string; // For API requests: which endpoint was called
|
||||
}
|
||||
|
||||
interface UserLogsTabProps {
|
||||
user: IUserSchema;
|
||||
}
|
||||
|
||||
export function UserLogsTab({ user }: UserLogsTabProps) {
|
||||
const [logs, setLogs] = useState<UserLog[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<string>("all");
|
||||
const [showErrorOnly, setShowErrorOnly] = useState(false);
|
||||
|
||||
// Mock function to fetch user logs - replace with actual implementation
|
||||
const fetchUserLogs = async () => {
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
// Mock authentication logs data similar to the screenshot
|
||||
const mockAuthLogs: UserLog[] = [
|
||||
{
|
||||
id: '1',
|
||||
type: 'token_request',
|
||||
timestamp: new Date(2025, 3, 2, 13, 48, 51), // Apr 2, 2025, 13:48:51
|
||||
status_code: '200',
|
||||
details: 'request completed',
|
||||
endpoint: '/token'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'login',
|
||||
timestamp: new Date(2025, 3, 2, 13, 48, 51), // Apr 2, 2025, 13:48:51
|
||||
status_code: '-',
|
||||
details: 'Login successful',
|
||||
endpoint: undefined
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'logout',
|
||||
timestamp: new Date(2025, 3, 2, 13, 30, 0),
|
||||
status_code: '200',
|
||||
details: 'User logged out',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
type: 'login',
|
||||
timestamp: new Date(2025, 3, 2, 13, 25, 10),
|
||||
status_code: '401',
|
||||
details: 'Failed login attempt - incorrect password',
|
||||
ip_address: '192.168.1.5'
|
||||
}
|
||||
];
|
||||
|
||||
// Mock database action logs
|
||||
const mockActionLogs: UserLog[] = [
|
||||
{
|
||||
id: '5',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
type: 'insert',
|
||||
timestamp: new Date(2025, 3, 2, 14, 15, 23),
|
||||
status_code: '201',
|
||||
resource: 'products',
|
||||
details: 'Created new product "Smartphone X1"',
|
||||
ip_address: '192.168.1.5'
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
// Combine all logs and sort by timestamp (newest first)
|
||||
const allLogs = [...mockAuthLogs, ...mockActionLogs].sort((a, b) =>
|
||||
b.timestamp.getTime() - a.timestamp.getTime()
|
||||
);
|
||||
|
||||
setLogs(allLogs);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch user logs:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserLogs();
|
||||
}, [user.id]);
|
||||
|
||||
const getLogIcon = (type: UserLog['type']) => {
|
||||
switch (type) {
|
||||
case 'login':
|
||||
return <LogIn className="h-4 w-4 text-green-500" />;
|
||||
case 'logout':
|
||||
return <LogOut className="h-4 w-4 text-yellow-500" />;
|
||||
case 'password_reset':
|
||||
return <Lock className="h-4 w-4 text-blue-500" />;
|
||||
case 'email_change':
|
||||
return <Mail className="h-4 w-4 text-purple-500" />;
|
||||
case 'profile_update':
|
||||
return <UserCheck className="h-4 w-4 text-indigo-500" />;
|
||||
case 'account_creation':
|
||||
return <UserCheck className="h-4 w-4 text-green-500" />;
|
||||
case 'token_request':
|
||||
return <FileText className="h-4 w-4 text-blue-500" />;
|
||||
case 'insert':
|
||||
return <Plus className="h-4 w-4 text-green-500" />;
|
||||
case 'update':
|
||||
return <Edit className="h-4 w-4 text-amber-500" />;
|
||||
case 'delete':
|
||||
return <Trash className="h-4 w-4 text-red-500" />;
|
||||
case 'view':
|
||||
return <FileText className="h-4 w-4 text-gray-500" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getLogTitle = (type: UserLog['type']) => {
|
||||
switch (type) {
|
||||
case 'login':
|
||||
return 'Login';
|
||||
case 'logout':
|
||||
return 'Logout';
|
||||
case 'password_reset':
|
||||
return 'Password reset';
|
||||
case 'email_change':
|
||||
return 'Email changed';
|
||||
case 'profile_update':
|
||||
return 'Profile updated';
|
||||
case 'account_creation':
|
||||
return 'Account created';
|
||||
case 'token_request':
|
||||
return 'Token request';
|
||||
case 'insert':
|
||||
return 'Create record';
|
||||
case 'update':
|
||||
return 'Update record';
|
||||
case 'delete':
|
||||
return 'Delete record';
|
||||
case 'view':
|
||||
return 'View record';
|
||||
default:
|
||||
return 'Unknown action';
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusClass = (statusCode: string | undefined) => {
|
||||
if (!statusCode || statusCode === '-') return "bg-gray-100 text-gray-800";
|
||||
|
||||
const code = parseInt(statusCode);
|
||||
if (code >= 200 && code < 300) return "bg-green-100 text-green-800";
|
||||
if (code >= 400) return "bg-red-100 text-red-800";
|
||||
return "bg-yellow-100 text-yellow-800";
|
||||
};
|
||||
|
||||
const isAuthLog = (type: UserLog['type']) => {
|
||||
return ['login', 'logout', 'password_reset', 'account_creation', 'token_request'].includes(type);
|
||||
};
|
||||
|
||||
const isActionLog = (type: UserLog['type']) => {
|
||||
return ['insert', 'update', 'delete', 'view'].includes(type);
|
||||
};
|
||||
|
||||
const filteredLogs = logs.filter(log => {
|
||||
if (showErrorOnly) {
|
||||
const code = parseInt(log.status_code || '0');
|
||||
if (code < 400) return false;
|
||||
}
|
||||
|
||||
if (activeTab === 'all') return true;
|
||||
if (activeTab === 'auth') return isAuthLog(log.type);
|
||||
if (activeTab === 'action') return isActionLog(log.type);
|
||||
return true;
|
||||
});
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const day = date.getDate().toString().padStart(2, '0');
|
||||
const month = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'][date.getMonth()];
|
||||
const hours = date.getHours().toString().padStart(2, '0');
|
||||
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||
const seconds = date.getSeconds().toString().padStart(2, '0');
|
||||
|
||||
return `${day} ${month} ${hours}:${minutes}:${seconds}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex flex-col">
|
||||
<h3 className="text-lg font-semibold">User Activity Logs</h3>
|
||||
<p className="text-sm text-muted-foreground">Latest logs from activity for this user in the past hour</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="all" value={activeTab} onValueChange={setActiveTab}>
|
||||
<div className="flex justify-between items-center">
|
||||
<TabsList className="mb-2 space-x-1 bg-transparent m-0">
|
||||
<TabsTrigger value="all" className="text-xs data-[state=active]:bg-white data-[state=active]:text-background">Show all</TabsTrigger>
|
||||
<TabsTrigger value="auth" className="text-xs data-[state=active]:bg-white data-[state=active]:text-background">Authentication logs</TabsTrigger>
|
||||
<TabsTrigger value="action" className="text-xs data-[state=active]:bg-white data-[state=active]:text-background">Action logs</TabsTrigger>
|
||||
</TabsList>
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
className="px-2 py-1 text-xs"
|
||||
onClick={fetchUserLogs}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-4">
|
||||
{Array.from({ length: 1 }).map((_, index) => (
|
||||
<div key={index} className="flex flex-col space-y-4">
|
||||
<Skeleton className="h-6 w-full bg-muted" />
|
||||
<Skeleton className="h-6 w-4/6 bg-muted" />
|
||||
<Skeleton className="h-6 w-2/5 bg-muted" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-center p-8 text-muted-foreground">
|
||||
No logs found with the current filters.
|
||||
</div>
|
||||
) : (
|
||||
<div className=" border rounded-md">
|
||||
<table className="w-full">
|
||||
{/* <thead className="bg-muted/50 sticky top-0">
|
||||
<tr className="border-b">
|
||||
<th className="text-left p-2 text-xs font-medium text-muted-foreground">Timestamp</th>
|
||||
<th className="text-left p-2 text-xs font-medium text-muted-foreground">Status</th>
|
||||
<th className="text-left p-2 text-xs font-medium text-muted-foreground w-full">Details</th>
|
||||
</tr>
|
||||
</thead> */}
|
||||
<tbody>
|
||||
{filteredLogs.slice(0, 10).map((log) => (
|
||||
<tr key={log.id} className="border-b hover:bg-muted/20 text-muted-foreground">
|
||||
<td className="p-2 text-xs whitespace-nowrap">
|
||||
{formatDate(log.timestamp)}
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<Badge variant="outline" className={`bg-none border-none text-sm font-mono text-muted-foreground`}>
|
||||
{log.status_code || '-'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getLogIcon(log.type)}
|
||||
<span className="text-sm font-medium">
|
||||
{log.endpoint && (
|
||||
<span className="font-mono text-muted-foreground">{log.endpoint}</span>
|
||||
)}
|
||||
{log.endpoint && log.details && ' | '}
|
||||
{log.details}
|
||||
</span>
|
||||
</div>
|
||||
{/* {log.resource && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
Resource: {log.resource}
|
||||
</div>
|
||||
)} */}
|
||||
{/* {log.ip_address && (
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
IP: {log.ip_address}
|
||||
</div>
|
||||
)}
|
||||
{log.user_agent && (
|
||||
<div className="mt-1 text-xs text-muted-foreground truncate max-w-md">
|
||||
{log.user_agent}
|
||||
</div>
|
||||
)} */}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{/* {filteredLogs.length > 5 && ( */}
|
||||
<div className="text-center p-2 border-t">
|
||||
<Button variant="ghost" size="sm" className="flex w-full text-xs text-muted-foreground">
|
||||
See more logs
|
||||
</Button>
|
||||
</div>
|
||||
{/* )} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,228 @@
|
|||
// components/user-management/sheet/tabs/user-overview-tab.tsx
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Badge } from "@/app/_components/ui/badge";
|
||||
import { Separator } from "@/app/_components/ui/separator";
|
||||
import {
|
||||
Mail,
|
||||
Trash2,
|
||||
Ban,
|
||||
SendHorizonal,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Copy,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
import { formatDate } from "@/app/_utils/common";
|
||||
import { CAlertDialog } from "@/app/_components/alert-dialog";
|
||||
import { useUserDetailSheetHandlers } from "../../_handlers/use-detail-sheet";
|
||||
|
||||
interface UserOverviewTabProps {
|
||||
user: IUserSchema;
|
||||
handleCopyItem: (text: string, label: string) => void;
|
||||
}
|
||||
|
||||
export function UserOverviewTab({ user, handleCopyItem }: UserOverviewTabProps) {
|
||||
const {
|
||||
handleDeleteUser,
|
||||
handleSendPasswordRecovery,
|
||||
handleSendMagicLink,
|
||||
handleToggleBan,
|
||||
isBanPending,
|
||||
isUnbanPending,
|
||||
isDeletePending,
|
||||
isSendPasswordRecoveryPending,
|
||||
isSendMagicLinkPending,
|
||||
} = useUserDetailSheetHandlers({ open: true, user, onOpenChange: () => { } });
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* User Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">User Information</h3>
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">User UID</span>
|
||||
<div className="flex items-center">
|
||||
<span className="font-mono">{user.id}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-4 w-4 ml-2"
|
||||
onClick={() => handleCopyItem(user.id, "UID")}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">Created at</span>
|
||||
<span>{formatDate(user.created_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">Last signed in</span>
|
||||
<span>{formatDate(user.last_sign_in_at)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center py-1">
|
||||
<span className="text-muted-foreground">SSO</span>
|
||||
<XCircle className="h-4 w-4 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Provider Information Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">Provider Information</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The user has the following providers
|
||||
</p>
|
||||
|
||||
<div className="border rounded-md p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5" />
|
||||
<div>
|
||||
<div className="font-medium">Email</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Signed in with a email account
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="bg-green-500/10 text-green-500 border-green-500/20"
|
||||
>
|
||||
<CheckCircle className="h-3 w-3 mr-1" /> Enabled
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border rounded-md p-4 space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Reset password</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send a password recovery email to the user
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSendPasswordRecovery}
|
||||
disabled={isSendPasswordRecoveryPending}
|
||||
>
|
||||
{isSendPasswordRecoveryPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send password recovery
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Send magic link</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Passwordless login via email for the user
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSendMagicLink}
|
||||
disabled={isSendMagicLinkPending}
|
||||
>
|
||||
{isSendMagicLinkPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SendHorizonal className="h-4 w-4 mr-2" />
|
||||
Send magic link
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Danger Zone Section */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-destructive">
|
||||
Danger zone
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Be wary of the following features as they cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="border border-destructive/20 rounded-md p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Ban user</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Revoke access to the project for a set duration
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={user.banned_until ? "outline" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => handleToggleBan()}
|
||||
disabled={isBanPending || isUnbanPending}
|
||||
>
|
||||
{isBanPending || isUnbanPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
{user.banned_until ? "Unbanning..." : "Banning..."}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Ban className="h-4 w-4 mr-2" />
|
||||
{user.banned_until ? "Unban user" : "Ban user"}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border border-destructive/20 rounded-md p-4 flex justify-between items-center">
|
||||
<div>
|
||||
<h4 className="font-medium">Delete user</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
User will no longer have access to the project
|
||||
</p>
|
||||
</div>
|
||||
<CAlertDialog
|
||||
triggerText="Delete user"
|
||||
triggerIcon={<Trash2 className="h-4 w-4" />}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete the user account and remove their data from our servers."
|
||||
confirmText="Delete"
|
||||
onConfirm={handleDeleteUser}
|
||||
isPending={isDeletePending}
|
||||
pendingText="Deleting..."
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
// components/user-management/toolbar/user-actions-menu.tsx
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { PlusCircle, ChevronDown, UserPlus, Mail, Plus } from "lucide-react";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/app/_components/ui/dropdown-menu";
|
||||
|
||||
interface UserActionsMenuProps {
|
||||
setIsAddUserDialogOpen: (isOpen: boolean) => void;
|
||||
setIsInviteUserDialogOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export const UserActionsMenu: React.FC<UserActionsMenuProps> = ({
|
||||
setIsAddUserDialogOpen,
|
||||
setIsInviteUserDialogOpen,
|
||||
}) => {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" className="gap-1">
|
||||
<Plus className="h-4 w-4" />
|
||||
Add User
|
||||
<ChevronDown className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setIsAddUserDialogOpen(true)}>
|
||||
<UserPlus className="h-4 w-4 mr-2" />
|
||||
Create new user
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setIsInviteUserDialogOpen(true)}>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send invitation
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,33 @@
|
|||
// components/user-management/toolbar/filter-button.tsx
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { ListFilter } from "lucide-react";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
|
||||
interface FilterButtonProps {
|
||||
activeFilterCount: number;
|
||||
clearFilters: () => void;
|
||||
}
|
||||
|
||||
export const FilterButton: React.FC<FilterButtonProps> = ({
|
||||
activeFilterCount,
|
||||
clearFilters,
|
||||
}) => {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
className={activeFilterCount > 0 ? "relative" : ""}
|
||||
onClick={clearFilters}
|
||||
>
|
||||
<ListFilter className="h-4 w-4" />
|
||||
Filters
|
||||
{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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,39 @@
|
|||
// components/user-management/toolbar/search-input.tsx
|
||||
|
||||
|
||||
import React from "react";
|
||||
import { Search, X } from "lucide-react";
|
||||
import { Input } from "@/app/_components/ui/input";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
|
||||
interface SearchInputProps {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
export const SearchInput: React.FC<SearchInputProps> = ({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
}) => {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,52 @@
|
|||
// components/user-management/toolbar/user-management-toolbar.tsx
|
||||
|
||||
import React from "react";
|
||||
import { SearchInput } from "./search-input";
|
||||
import { UserActionsMenu } from "./action";
|
||||
import { FilterButton } from "./filter-button";
|
||||
import { calculateUserStats } from "@/app/_utils/common";
|
||||
import { useGetUsersQuery } from "../../_queries/queries";
|
||||
|
||||
|
||||
interface UserManagementToolbarProps {
|
||||
searchQuery: string;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setIsAddUserDialogOpen: (isOpen: boolean) => void;
|
||||
setIsInviteUserDialogOpen: (isOpen: boolean) => void;
|
||||
activeFilterCount: number;
|
||||
clearFilters: () => void;
|
||||
currentPageDataCount?: number;
|
||||
}
|
||||
|
||||
export const UserManagementToolbar: React.FC<UserManagementToolbarProps> = ({
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
setIsAddUserDialogOpen,
|
||||
setIsInviteUserDialogOpen,
|
||||
activeFilterCount,
|
||||
clearFilters,
|
||||
currentPageDataCount,
|
||||
}) => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Users in table <span className="text-muted-foreground">{currentPageDataCount}</span></h2>
|
||||
</div>
|
||||
<div className="flex gap-2 w-full sm:w-auto">
|
||||
<SearchInput
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
/>
|
||||
<FilterButton
|
||||
activeFilterCount={activeFilterCount}
|
||||
clearFilters={clearFilters}
|
||||
/>
|
||||
<UserActionsMenu
|
||||
setIsAddUserDialogOpen={setIsAddUserDialogOpen}
|
||||
setIsInviteUserDialogOpen={setIsInviteUserDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -13,6 +13,7 @@ import {
|
|||
ListFilter,
|
||||
Trash2,
|
||||
PenIcon as UserPen,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { Input } from "@/app/_components/ui/input";
|
||||
|
@ -23,14 +24,24 @@ import {
|
|||
DropdownMenuTrigger,
|
||||
} from "@/app/_components/ui/dropdown-menu";
|
||||
|
||||
import { DataTable } from "./data-table";
|
||||
import { InviteUserDialog } from "./invite-user";
|
||||
import { AddUserDialog } from "./add-user-dialog";
|
||||
import { UserDetailSheet } from "./sheet";
|
||||
import { UserProfileSheet } from "./update-user";
|
||||
import { createUserColumns } from "./users-table";
|
||||
import { DataTable } from "../../../../../_components/data-table";
|
||||
import { UserInformationSheet } from "./sheets/user-information-sheet";
|
||||
import { useGetUsersQuery } from "../_queries/queries";
|
||||
import { filterUsers, useUserManagementHandlers } from "../_handlers/use-user-management";
|
||||
import { UserDialogs } from "./dialogs/user-dialogs";
|
||||
import { useAddUserDialogHandler } from "../_handlers/use-add-user-dialog";
|
||||
import { useInviteUserHandler } from "../_handlers/use-invite-user";
|
||||
import { AddUserDialog } from "./dialogs/add-user-dialog";
|
||||
import { InviteUserDialog } from "./dialogs/invite-user-dialog";
|
||||
import { ConfirmDialog } from "@/app/_components/confirm-dialog";
|
||||
import { BanUserDialog } from "./dialogs/ban-user-dialog";
|
||||
import { useBanUserHandler } from "../_handlers/actions/use-ban-user";
|
||||
import { useDeleteUserHandler } from "../_handlers/actions/use-delete-user";
|
||||
import { useUnbanUserHandler } from "../_handlers/actions/use-unban-user";
|
||||
import { createUserColumns } from "./table/columns";
|
||||
import { UserManagementToolbar } from "./toolbars/user-management-toolbar";
|
||||
import { UpdateUserSheet } from "./sheets/update-user-sheet";
|
||||
|
||||
|
||||
export default function UserManagement() {
|
||||
|
||||
|
@ -45,16 +56,12 @@ export default function UserManagement() {
|
|||
const {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
detailUser,
|
||||
updateUser,
|
||||
isDetailUser,
|
||||
isUpdateUser,
|
||||
isSheetOpen,
|
||||
setIsSheetOpen,
|
||||
isUpdateOpen,
|
||||
setIsUpdateOpen,
|
||||
isAddUserOpen,
|
||||
setIsAddUserOpen,
|
||||
isInviteUserOpen,
|
||||
setIsInviteUserOpen,
|
||||
filters,
|
||||
setFilters,
|
||||
handleUserClick,
|
||||
|
@ -63,6 +70,21 @@ export default function UserManagement() {
|
|||
getActiveFilterCount,
|
||||
} = useUserManagementHandlers()
|
||||
|
||||
// User management handler
|
||||
const {
|
||||
isAddUserDialogOpen,
|
||||
setIsAddUserDialogOpen,
|
||||
} = useAddUserDialogHandler({
|
||||
onOpenChange: (open) => setIsAddUserDialogOpen(open),
|
||||
})
|
||||
|
||||
const {
|
||||
isInviteUserDialogOpen,
|
||||
setIsInviteUserDialogOpen,
|
||||
} = useInviteUserHandler({
|
||||
onOpenChange: (open) => setIsInviteUserDialogOpen(open),
|
||||
})
|
||||
|
||||
// Apply filters to users
|
||||
const filteredUsers = useMemo(() => {
|
||||
return filterUsers(users, searchQuery, filters)
|
||||
|
@ -72,98 +94,60 @@ export default function UserManagement() {
|
|||
const activeFilterCount = getActiveFilterCount()
|
||||
|
||||
// Create table columns
|
||||
const columns = createUserColumns(filters, setFilters, handleUserUpdate)
|
||||
const columns = createUserColumns(
|
||||
filters,
|
||||
setFilters,
|
||||
handleUserUpdate,
|
||||
)
|
||||
|
||||
// State untuk jumlah data di halaman saat ini
|
||||
const [currentPageDataCount, setCurrentPageDataCount] = useState(0);
|
||||
|
||||
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>
|
||||
<UserManagementToolbar
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
setIsAddUserDialogOpen={setIsAddUserDialogOpen}
|
||||
setIsInviteUserDialogOpen={setIsInviteUserDialogOpen}
|
||||
activeFilterCount={activeFilterCount}
|
||||
clearFilters={clearFilters}
|
||||
currentPageDataCount={currentPageDataCount}
|
||||
/>
|
||||
|
||||
<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={isPending}
|
||||
onRowClick={(user) => handleUserClick(user)}
|
||||
onCurrentPageDataCountChange={setCurrentPageDataCount}
|
||||
/>
|
||||
{detailUser && (
|
||||
<UserDetailSheet
|
||||
user={detailUser}
|
||||
|
||||
{isDetailUser && (
|
||||
<UserInformationSheet
|
||||
user={isDetailUser}
|
||||
open={isSheetOpen}
|
||||
onOpenChange={setIsSheetOpen}
|
||||
onUserUpdated={() => { }}
|
||||
/>
|
||||
)}
|
||||
<AddUserDialog
|
||||
open={isAddUserOpen}
|
||||
onOpenChange={setIsAddUserOpen}
|
||||
onUserAdded={() => { }}
|
||||
/>
|
||||
<InviteUserDialog
|
||||
open={isInviteUserOpen}
|
||||
onOpenChange={setIsInviteUserOpen}
|
||||
onUserInvited={() => { }}
|
||||
/>
|
||||
{updateUser && (
|
||||
<UserProfileSheet
|
||||
|
||||
{isUpdateUser && (
|
||||
<UpdateUserSheet
|
||||
open={isUpdateOpen}
|
||||
onOpenChange={setIsUpdateOpen}
|
||||
userData={updateUser}
|
||||
onUserUpdated={() => { }}
|
||||
userData={isUpdateUser}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AddUserDialog
|
||||
open={isAddUserDialogOpen}
|
||||
onOpenChange={setIsAddUserDialogOpen}
|
||||
/>
|
||||
|
||||
<InviteUserDialog
|
||||
open={isInviteUserDialogOpen}
|
||||
onOpenChange={setIsInviteUserDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,35 +4,7 @@ import { Card, CardContent } from "@/app/_components/ui/card";
|
|||
import { Users, UserCheck, UserX } from "lucide-react";
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
import { useGetUsersQuery } from "../_queries/queries";
|
||||
|
||||
|
||||
function calculateUserStats(users: IUserSchema[] | undefined) {
|
||||
if (!users || !Array.isArray(users)) {
|
||||
return {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
inactiveUsers: 0,
|
||||
activePercentage: '0.0',
|
||||
inactivePercentage: '0.0',
|
||||
};
|
||||
}
|
||||
|
||||
const totalUsers = users.length;
|
||||
const activeUsers = users.filter(
|
||||
(user) => !user.banned_until && user.email_confirmed_at
|
||||
).length;
|
||||
const inactiveUsers = totalUsers - activeUsers;
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
inactiveUsers,
|
||||
activePercentage:
|
||||
totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : '0.0',
|
||||
inactivePercentage:
|
||||
totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : '0.0',
|
||||
};
|
||||
}
|
||||
import { calculateUserStats } from "@/app/_utils/common";
|
||||
|
||||
export function UserStats() {
|
||||
const { data: users, isPending, error } = useGetUsersQuery();
|
||||
|
|
|
@ -1,409 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import type { ColumnDef, HeaderContext } from "@tanstack/react-table"
|
||||
import type { IUserSchema, IUserFilterOptionsSchema } from "@/src/entities/models/users/users.model"
|
||||
import { ListFilter, MoreHorizontal, PenIcon as UserPen, Trash2, ShieldAlert, ShieldCheck } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuCheckboxItem,
|
||||
} from "@/app/_components/ui/dropdown-menu"
|
||||
import { Button } from "@/app/_components/ui/button"
|
||||
import { Input } from "@/app/_components/ui/input"
|
||||
import { Avatar } from "@/app/_components/ui/avatar"
|
||||
import Image from "next/image"
|
||||
import { Badge } from "@/app/_components/ui/badge"
|
||||
import { ConfirmDialog } from "@/app/_components/confirm-dialog"
|
||||
import { useCreateUserColumn } from "../_handlers/use-create-user-column"
|
||||
import { BanUserDialog } from "./ban-user-dialog"
|
||||
import { ValidBanDuration } from "@/app/_lib/types/ban-duration"
|
||||
|
||||
|
||||
export type UserTableColumn = ColumnDef<IUserSchema, IUserSchema>
|
||||
|
||||
export const createUserColumns = (
|
||||
filters: IUserFilterOptionsSchema,
|
||||
setFilters: (filters: IUserFilterOptionsSchema) => void,
|
||||
handleUserUpdate: (user: IUserSchema) => void,
|
||||
): UserTableColumn[] => {
|
||||
const {
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
|
||||
handleDeleteConfirm,
|
||||
isDeletePending,
|
||||
banDialogOpen,
|
||||
setBanDialogOpen,
|
||||
|
||||
handleBanConfirm,
|
||||
unbanDialogOpen,
|
||||
setUnbanDialogOpen,
|
||||
|
||||
isBanPending,
|
||||
isUnbanPending,
|
||||
handleUnbanConfirm,
|
||||
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
} = useCreateUserColumn()
|
||||
|
||||
return [
|
||||
{
|
||||
id: "email",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<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 }) => (
|
||||
<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 || "/placeholder.svg"}
|
||||
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.profile?.username}</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "phone",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<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.phone || "-",
|
||||
},
|
||||
{
|
||||
id: "lastSignIn",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<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 }) => {
|
||||
return row.original.last_sign_in_at ? new Date(row.original.last_sign_in_at).toLocaleString() : "Never"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "createdAt",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<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 }) => {
|
||||
return row.original.created_at ? new Date(row.original.created_at).toLocaleString() : "N/A"
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: ({ column }: HeaderContext<IUserSchema, IUserSchema>) => (
|
||||
<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 }) => {
|
||||
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 }) => (
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleUserUpdate(row.original)}>
|
||||
<UserPen className="h-4 w-4 mr-2 text-blue-500" />
|
||||
Update
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelectedUser({ id: row.original.id, email: row.original.email! })
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-2 text-destructive" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (row.original.banned_until != null) {
|
||||
setSelectedUser({ id: row.original.id, email: row.original.email! })
|
||||
setUnbanDialogOpen(true)
|
||||
} else {
|
||||
setSelectedUser({ id: row.original.id, email: row.original.email! })
|
||||
setBanDialogOpen(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4 mr-2 text-yellow-500" />
|
||||
{row.original.banned_until != null ? "Unban" : "Ban"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{/* Alert Dialog for Delete Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
title="Are you absolutely sure?"
|
||||
description="This action cannot be undone. This will permanently delete the user account and remove their data from our servers."
|
||||
confirmText="Delete"
|
||||
onConfirm={() => handleDeleteConfirm(row.original.id, row.original.email!)}
|
||||
isPending={isDeletePending}
|
||||
pendingText="Deleting..."
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
{/* Alert Dialog for Ban Confirmation */}
|
||||
<BanUserDialog
|
||||
open={banDialogOpen}
|
||||
onOpenChange={setBanDialogOpen}
|
||||
onConfirm={handleBanConfirm}
|
||||
isPending={isBanPending}
|
||||
/>
|
||||
|
||||
{/* Alert Dialog for Unban Confirmation */}
|
||||
<ConfirmDialog
|
||||
open={unbanDialogOpen}
|
||||
onOpenChange={setUnbanDialogOpen}
|
||||
title="Unban User"
|
||||
description="This will restore the user's access to the system. Are you sure you want to unban this user?"
|
||||
confirmText="Unban"
|
||||
onConfirm={() => handleUnbanConfirm(row.original.id, row.original.email!)}
|
||||
isPending={isUnbanPending}
|
||||
pendingText="Unbanning..."
|
||||
variant="default"
|
||||
size="sm"
|
||||
confirmIcon={<ShieldCheck className="h-4 w-4" />}
|
||||
/>
|
||||
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useBanUserMutation } from "../../_queries/mutations"
|
||||
import type { ValidBanDuration } from "@/app/_lib/types/ban-duration"
|
||||
import { toast } from "sonner"
|
||||
import { useUserActionsHandler } from "./use-user-actions"
|
||||
|
||||
export const useBanUserHandler = () => {
|
||||
const { selectedUser, invalidateUsers } = useUserActionsHandler()
|
||||
const [banDialogOpen, setBanDialogOpen] = useState(false)
|
||||
const { mutateAsync: banUser, isPending: isBanPending } = useBanUserMutation()
|
||||
|
||||
const handleBanConfirm = async (duration: ValidBanDuration) => {
|
||||
if (!selectedUser) return toast.error("No user selected to ban")
|
||||
|
||||
await banUser(
|
||||
{ id: selectedUser.id, ban_duration: duration },
|
||||
{
|
||||
onSuccess: () => {
|
||||
invalidateUsers()
|
||||
toast.success(`${selectedUser.email} has been banned`)
|
||||
setBanDialogOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to ban user. Please try again later.")
|
||||
setBanDialogOpen(false)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
banDialogOpen,
|
||||
setBanDialogOpen,
|
||||
handleBanConfirm,
|
||||
isBanPending,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { CreateUserSchema, type ICreateUserSchema } from "@/src/entities/models/users/create-user.model"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { toast } from "sonner"
|
||||
import { useUserActionsHandler } from "./use-user-actions"
|
||||
import { useCreateUserMutation } from "../../_queries/mutations"
|
||||
|
||||
export const useCreateUserHandler = () => {
|
||||
const { invalidateUsers } = useUserActionsHandler()
|
||||
const { mutateAsync: createUser, isPending } = useCreateUserMutation()
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
setError,
|
||||
getValues,
|
||||
clearErrors,
|
||||
watch,
|
||||
} = useForm<ICreateUserSchema>({
|
||||
resolver: zodResolver(CreateUserSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
email_confirm: true,
|
||||
},
|
||||
})
|
||||
|
||||
const emailConfirm = watch("email_confirm")
|
||||
|
||||
const handleCreateUser = async (onSuccess?: () => void, onError?: (error: Error) => void) => {
|
||||
return handleSubmit(async (data) => {
|
||||
await createUser(data, {
|
||||
onSuccess: () => {
|
||||
invalidateUsers()
|
||||
reset()
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: (error) => {
|
||||
reset()
|
||||
toast.error(error.message)
|
||||
onError?.(error)
|
||||
},
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
return {
|
||||
register,
|
||||
handleCreateUser,
|
||||
reset,
|
||||
errors,
|
||||
isPending,
|
||||
getValues,
|
||||
clearErrors,
|
||||
emailConfirm,
|
||||
handleSubmit,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { useState } from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useUserActionsHandler } from "./use-user-actions"
|
||||
import { useDeleteUserMutation } from "../../_queries/mutations"
|
||||
|
||||
export const useDeleteUserHandler = () => {
|
||||
const { selectedUser, invalidateUsers } = useUserActionsHandler()
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const { mutateAsync: deleteUser, isPending: isDeletePending } = useDeleteUserMutation()
|
||||
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!selectedUser) return toast.error("No user selected to delete")
|
||||
|
||||
await deleteUser(selectedUser.id, {
|
||||
onSuccess: () => {
|
||||
invalidateUsers()
|
||||
toast.success(`${selectedUser.email} has been deleted`)
|
||||
setDeleteDialogOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete user. Please try again later.")
|
||||
setDeleteDialogOpen(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
deleteDialogOpen,
|
||||
setDeleteDialogOpen,
|
||||
handleDeleteConfirm,
|
||||
isDeletePending,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import { toast } from "sonner"
|
||||
import { useUserActionsHandler } from "./use-user-actions"
|
||||
import { useState } from "react"
|
||||
import { useUnbanUserMutation } from "../../_queries/mutations"
|
||||
|
||||
export const useUnbanUserHandler = () => {
|
||||
const { selectedUser, invalidateUsers } = useUserActionsHandler()
|
||||
const [unbanDialogOpen, setUnbanDialogOpen] = useState(false)
|
||||
const { mutateAsync: unbanUser, isPending: isUnbanPending } = useUnbanUserMutation()
|
||||
|
||||
const handleUnbanConfirm = async () => {
|
||||
if (!selectedUser) return toast.error("No user selected to unban")
|
||||
|
||||
await unbanUser({ id: selectedUser.id }, {
|
||||
onSuccess: () => {
|
||||
invalidateUsers()
|
||||
toast.success(`${selectedUser.email} has been unbanned`)
|
||||
setUnbanDialogOpen(false)
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to unban user. Please try again later.")
|
||||
setUnbanDialogOpen(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
unbanDialogOpen,
|
||||
setUnbanDialogOpen,
|
||||
handleUnbanConfirm,
|
||||
isUnbanPending,
|
||||
}
|
||||
}
|
|
@ -0,0 +1,71 @@
|
|||
import { type IUpdateUserSchema, UpdateUserSchema } from "@/src/entities/models/users/update-user.model"
|
||||
import type { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useUpdateUserMutation } from "../../_queries/mutations"
|
||||
import { toast } from "sonner"
|
||||
import { useUserActionsHandler } from "./use-user-actions"
|
||||
|
||||
export const useUpdateUserHandler = (userData: IUserSchema) => {
|
||||
const { invalidateUsers } = useUserActionsHandler()
|
||||
const { mutateAsync: updateUser, isPending } = useUpdateUserMutation()
|
||||
|
||||
// Initialize form with user data
|
||||
const form = useForm<IUpdateUserSchema>({
|
||||
resolver: zodResolver(UpdateUserSchema),
|
||||
defaultValues: {
|
||||
email: userData?.email || undefined,
|
||||
encrypted_password: userData?.encrypted_password || undefined,
|
||||
role: (userData?.role as "user" | "staff" | "admin") || "user",
|
||||
phone: userData?.phone || undefined,
|
||||
invited_at: userData?.invited_at || undefined,
|
||||
confirmed_at: userData?.confirmed_at || undefined,
|
||||
// recovery_sent_at: userData?.recovery_sent_at || undefined,
|
||||
last_sign_in_at: userData?.last_sign_in_at || undefined,
|
||||
created_at: userData?.created_at || undefined,
|
||||
updated_at: userData?.updated_at || undefined,
|
||||
is_anonymous: userData?.is_anonymous || false,
|
||||
profile: {
|
||||
// id: userData?.profile?.id || undefined,
|
||||
// user_id: userData?.profile?.user_id || undefined,
|
||||
avatar: userData?.profile?.avatar || undefined,
|
||||
username: userData?.profile?.username || undefined,
|
||||
first_name: userData?.profile?.first_name || undefined,
|
||||
last_name: userData?.profile?.last_name || undefined,
|
||||
bio: userData?.profile?.bio || undefined,
|
||||
address: userData?.profile?.address || {
|
||||
street: "",
|
||||
city: "",
|
||||
state: "",
|
||||
country: "",
|
||||
postal_code: "",
|
||||
},
|
||||
birth_date: userData?.profile?.birth_date ? new Date(userData.profile.birth_date) : undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const handleUpdateUser = async (onSuccess?: () => void, onError?: () => void) => {
|
||||
await updateUser(
|
||||
{ id: userData.id, data: form.getValues() },
|
||||
{
|
||||
onSuccess: () => {
|
||||
invalidateUsers()
|
||||
toast.success("User updated successfully")
|
||||
onSuccess?.()
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to update user")
|
||||
onError?.()
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
form,
|
||||
handleUpdateUser,
|
||||
isPending,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
|
||||
// This is a shared hook that contains common functionality
|
||||
export const useUserActionsHandler = () => {
|
||||
const queryClient = useQueryClient()
|
||||
const [selectedUser, setSelectedUser] = useState<{ id: string, email: string } | null>(null)
|
||||
|
||||
const invalidateUsers = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
}
|
||||
|
||||
const invalidateUser = (userId: string) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "current", userId] })
|
||||
}
|
||||
|
||||
const invalidateCurrentUser = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "current"] })
|
||||
}
|
||||
|
||||
return {
|
||||
selectedUser,
|
||||
setSelectedUser,
|
||||
invalidateUsers,
|
||||
invalidateUser,
|
||||
invalidateCurrentUser,
|
||||
queryClient,
|
||||
}
|
||||
}
|
||||
|
|
@ -4,16 +4,19 @@ import { CreateUserSchema, ICreateUserSchema } from "@/src/entities/models/users
|
|||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { useUserActionsHandler } from "./actions/use-user-actions";
|
||||
|
||||
export const useAddUserDialogHandler = ({ onUserAdded, onOpenChange }: {
|
||||
onUserAdded: () => void;
|
||||
export const useAddUserDialogHandler = ({ onOpenChange }: {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateUsers } = useUserActionsHandler()
|
||||
|
||||
const { mutateAsync: createdUser, isPending } = useCreateUserMutation()
|
||||
|
||||
const [isAddUserDialogOpen, setIsAddUserDialogOpen] = useState(false)
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
|
@ -38,9 +41,10 @@ export const useAddUserDialogHandler = ({ onUserAdded, onOpenChange }: {
|
|||
|
||||
await createdUser(data, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
invalidateUsers();
|
||||
|
||||
toast.success("User created successfully");
|
||||
|
||||
onUserAdded();
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
|
@ -69,5 +73,7 @@ export const useAddUserDialogHandler = ({ onUserAdded, onOpenChange }: {
|
|||
clearErrors,
|
||||
emailConfirm,
|
||||
handleOpenChange,
|
||||
isAddUserDialogOpen,
|
||||
setIsAddUserDialogOpen,
|
||||
};
|
||||
}
|
|
@ -26,16 +26,18 @@ export const useCreateUserColumn = () => {
|
|||
// Store selected user info
|
||||
const [selectedUser, setSelectedUser] = useState<{ id: string, email: string } | null>(null)
|
||||
|
||||
const handleDeleteConfirm = async (userId: string, email: string) => {
|
||||
const handleDeleteConfirm = async () => {
|
||||
|
||||
if (!userId) return toast.error("No user selected to delete")
|
||||
if (!selectedUser?.id) return toast.error("No user selected to delete")
|
||||
|
||||
await deleteUser(userId, {
|
||||
await deleteUser(selectedUser?.id, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
|
||||
toast.success(`${email} has been deleted`)
|
||||
toast.success(`${selectedUser.email} has been deleted`)
|
||||
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to delete user. Please try again later.")
|
||||
|
@ -55,6 +57,8 @@ export const useCreateUserColumn = () => {
|
|||
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
|
||||
toast.success(`${selectedUser.email} has been banned`)
|
||||
|
||||
setBanDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
},
|
||||
|
@ -67,16 +71,18 @@ export const useCreateUserColumn = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const handleUnbanConfirm = async (userId: string, email: string) => {
|
||||
const handleUnbanConfirm = async () => {
|
||||
|
||||
if (!userId) return toast.error("No user selected to unban")
|
||||
if (!selectedUser?.id) return toast.error("No user selected to unban")
|
||||
|
||||
await unbanUser({ id: userId }, {
|
||||
await unbanUser({ id: selectedUser?.id }, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
|
||||
toast(`${email} has been unbanned`)
|
||||
toast(`${selectedUser?.email} has been unbanned`)
|
||||
|
||||
setUnbanDialogOpen(false)
|
||||
setSelectedUser(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Failed to unban user. Please try again later.")
|
|
@ -5,15 +5,15 @@ import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from "@/app
|
|||
import { ValidBanDuration } from "@/app/_lib/types/ban-duration";
|
||||
import { copyItem } from "@/app/_utils/common";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useUserActionsHandler } from "./actions/use-user-actions";
|
||||
|
||||
export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenChange }: {
|
||||
export const useUserDetailSheetHandlers = ({ open, user, onOpenChange }: {
|
||||
open: boolean;
|
||||
user: IUserSchema;
|
||||
onUserUpdated: () => void;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateUsers } = useUserActionsHandler()
|
||||
|
||||
const { mutateAsync: deleteUser, isPending: isDeletePending } = useDeleteUserMutation();
|
||||
const { mutateAsync: sendPasswordRecovery, isPending: isSendPasswordRecoveryPending } = useSendPasswordRecoveryMutation();
|
||||
|
@ -24,7 +24,7 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
|||
const handleDeleteUser = async () => {
|
||||
await deleteUser(user.id, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
invalidateUsers();
|
||||
toast.success(`${user.email} has been deleted`);
|
||||
|
||||
onOpenChange(false);
|
||||
|
@ -69,10 +69,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
|||
const handleBanUser = async (ban_duration: ValidBanDuration = "24h") => {
|
||||
await banUser({ id: user.id, ban_duration: ban_duration }, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
invalidateUsers();
|
||||
toast(`${user.email} has been banned`);
|
||||
|
||||
onUserUpdated();
|
||||
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -80,10 +80,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
|||
const handleUnbanUser = async () => {
|
||||
await unbanUser({ id: user.id }, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
invalidateUsers();
|
||||
toast(`${user.email} has been unbanned`);
|
||||
|
||||
onUserUpdated();
|
||||
|
||||
}
|
||||
});
|
||||
};
|
||||
|
@ -92,19 +92,19 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh
|
|||
if (user.banned_until) {
|
||||
await unbanUser({ id: user.id }, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
invalidateUsers();
|
||||
|
||||
toast(`${user.email} has been unbanned`);
|
||||
onUserUpdated();
|
||||
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await banUser({ id: user.id, ban_duration: ban_duration }, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
invalidateUsers();
|
||||
|
||||
toast(`${user.email} has been banned`);
|
||||
onUserUpdated();
|
||||
|
||||
}
|
||||
});
|
||||
}
|
|
@ -3,16 +3,21 @@ import { zodResolver } from "@hookform/resolvers/zod";
|
|||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { toast } from "sonner";
|
||||
import { useState } from "react";
|
||||
import { useUserActionsHandler } from "./actions/use-user-actions";
|
||||
import { useInviteUserMutation } from "../_queries/mutations";
|
||||
|
||||
export const useInviteUserHandler = ({ onUserInvited, onOpenChange }: {
|
||||
onUserInvited: () => void;
|
||||
|
||||
export const useInviteUserHandler = ({ onOpenChange }: {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { invalidateUsers } = useUserActionsHandler()
|
||||
|
||||
const { mutateAsync: inviteUser, isPending } = useInviteUserMutation();
|
||||
|
||||
const [isInviteUserDialogOpen, setIsInviteUserDialogOpen] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
|
@ -34,11 +39,10 @@ export const useInviteUserHandler = ({ onUserInvited, onOpenChange }: {
|
|||
await inviteUser(email, {
|
||||
onSuccess: () => {
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
invalidateUsers();
|
||||
|
||||
toast.success("Invitation sent");
|
||||
|
||||
onUserInvited();
|
||||
onOpenChange(false);
|
||||
reset();
|
||||
},
|
||||
|
@ -66,5 +70,7 @@ export const useInviteUserHandler = ({ onUserInvited, onOpenChange }: {
|
|||
watch,
|
||||
errors,
|
||||
isPending,
|
||||
isInviteUserDialogOpen,
|
||||
setIsInviteUserDialogOpen,
|
||||
};
|
||||
}
|
|
@ -13,6 +13,7 @@ import { useQueryClient } from "@tanstack/react-query"
|
|||
import { toast } from "sonner"
|
||||
import { CNumbers } from "@/app/_lib/const/number"
|
||||
import { CTexts } from "@/app/_lib/const/string"
|
||||
import { useUserActionsHandler } from "./actions/use-user-actions"
|
||||
|
||||
// Profile update form schema
|
||||
const profileFormSchema = z.object({
|
||||
|
@ -31,7 +32,7 @@ interface ProfileFormProps {
|
|||
}
|
||||
|
||||
export const useProfileFormHandlers = ({ user, onSuccess }: ProfileFormProps) => {
|
||||
const queryClient = useQueryClient()
|
||||
const { invalidateUsers, invalidateCurrentUser, invalidateUser } = useUserActionsHandler()
|
||||
|
||||
const {
|
||||
mutateAsync: updateUser,
|
||||
|
@ -127,8 +128,8 @@ export const useProfileFormHandlers = ({ user, onSuccess }: ProfileFormProps) =>
|
|||
form.setValue("avatar", uniquePublicUrl)
|
||||
resetAvatarValue()
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "current"] })
|
||||
invalidateUsers()
|
||||
invalidateCurrentUser()
|
||||
toast.success("Avatar uploaded successfully")
|
||||
} catch (error) {
|
||||
console.error("Error uploading avatar:", error)
|
||||
|
@ -172,7 +173,7 @@ export const useProfileFormHandlers = ({ user, onSuccess }: ProfileFormProps) =>
|
|||
toast.success("Profile updated successfully")
|
||||
|
||||
// Invalidate the user query to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ["user", "current", user.id] })
|
||||
invalidateUser(user.id)
|
||||
|
||||
// Call success callback
|
||||
onSuccess?.()
|
|
@ -5,15 +5,16 @@ import { useForm } from "react-hook-form";
|
|||
import { useUpdateUserMutation } from "../_queries/mutations";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { useUserActionsHandler } from "./actions/use-user-actions";
|
||||
|
||||
export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUserUpdated }: {
|
||||
|
||||
export const useUpdateUserSheetHandlers = ({ open, onOpenChange, userData }: {
|
||||
open: boolean;
|
||||
userData: IUserSchema;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUserUpdated: () => void;
|
||||
}) => {
|
||||
|
||||
const queryClient = useQueryClient()
|
||||
const { invalidateUsers } = useUserActionsHandler()
|
||||
|
||||
const {
|
||||
mutateAsync: updateUser,
|
||||
|
@ -59,11 +60,10 @@ export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUs
|
|||
await updateUser({ id: userData.id, data: form.getValues() }, {
|
||||
onSuccess: () => {
|
||||
|
||||
queryClient.invalidateQueries({ queryKey: ["users"] })
|
||||
invalidateUsers()
|
||||
|
||||
toast.success("User updated successfully")
|
||||
|
||||
onUserUpdated();
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: () => {
|
|
@ -3,12 +3,10 @@ import { useEffect, useState } from "react"
|
|||
|
||||
export const useUserManagementHandlers = () => {
|
||||
const [searchQuery, setSearchQuery] = useState("")
|
||||
const [detailUser, setDetailUser] = useState<IUserSchema | null>(null)
|
||||
const [updateUser, setUpdateUser] = useState<IUserSchema | null>(null)
|
||||
const [isDetailUser, setIsDetailUser] = useState<IUserSchema | null>(null)
|
||||
const [isUpdateUser, setIsUpdateUser] = useState<IUserSchema | null>(null)
|
||||
const [isSheetOpen, setIsSheetOpen] = useState(false)
|
||||
const [isUpdateOpen, setIsUpdateOpen] = useState(false)
|
||||
const [isAddUserOpen, setIsAddUserOpen] = useState(false)
|
||||
const [isInviteUserOpen, setIsInviteUserOpen] = useState(false)
|
||||
|
||||
// Filter states
|
||||
const [filters, setFilters] = useState<IUserFilterOptionsSchema>({
|
||||
|
@ -21,13 +19,13 @@ export const useUserManagementHandlers = () => {
|
|||
|
||||
// Handle opening the detail sheet
|
||||
const handleUserClick = (user: IUserSchema) => {
|
||||
setDetailUser(user)
|
||||
setIsDetailUser(user)
|
||||
setIsSheetOpen(true)
|
||||
}
|
||||
|
||||
// Handle opening the update sheet
|
||||
const handleUserUpdate = (user: IUserSchema) => {
|
||||
setUpdateUser(user)
|
||||
setIsUpdateUser(user)
|
||||
setIsUpdateOpen(true)
|
||||
}
|
||||
|
||||
|
@ -44,7 +42,7 @@ export const useUserManagementHandlers = () => {
|
|||
// Use a small delay to prevent flickering if another sheet is opening
|
||||
const timer = setTimeout(() => {
|
||||
if (!isSheetOpen && !isUpdateOpen) {
|
||||
setDetailUser(null)
|
||||
setIsDetailUser(null)
|
||||
}
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
|
@ -57,7 +55,7 @@ export const useUserManagementHandlers = () => {
|
|||
// Use a small delay to prevent flickering if another sheet is opening
|
||||
const timer = setTimeout(() => {
|
||||
if (!isUpdateOpen) {
|
||||
setUpdateUser(null)
|
||||
setIsUpdateUser(null)
|
||||
}
|
||||
}, 300)
|
||||
return () => clearTimeout(timer)
|
||||
|
@ -83,16 +81,12 @@ export const useUserManagementHandlers = () => {
|
|||
return {
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
detailUser,
|
||||
updateUser,
|
||||
isDetailUser,
|
||||
isUpdateUser,
|
||||
isSheetOpen,
|
||||
setIsSheetOpen,
|
||||
isUpdateOpen,
|
||||
setIsUpdateOpen,
|
||||
isAddUserOpen,
|
||||
setIsAddUserOpen,
|
||||
isInviteUserOpen,
|
||||
setIsInviteUserOpen,
|
||||
filters,
|
||||
setFilters,
|
||||
handleUserClick,
|
|
@ -1,698 +0,0 @@
|
|||
// import { useEffect, useState } from 'react';
|
||||
// import { IUserSchema, IUserFilterOptionsSchema } from '@/src/entities/models/users/users.model';
|
||||
// import { toast } from 'sonner';
|
||||
// import { set } from 'date-fns';
|
||||
// import { CreateUserSchema, defaulICreateUserSchemaValues, ICreateUserSchema } from '@/src/entities/models/users/create-user.model';
|
||||
// import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import { defaulIInviteUserSchemaValues, IInviteUserSchema, InviteUserSchema } from '@/src/entities/models/users/invite-user.model';
|
||||
// import { useQueryClient } from '@tanstack/react-query';
|
||||
// import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from '@/app/(pages)/(auth)/queries';
|
||||
// import { ValidBanDuration } from '@/app/_lib/types/ban-duration';
|
||||
// import { IUpdateUserSchema, UpdateUserSchema } from '@/src/entities/models/users/update-user.model';
|
||||
// import { useUsersAction } from './queries';
|
||||
// import { getUsersQuery, useGetUsersQuery, useUsersQuery } from './_queries/queries';
|
||||
|
||||
// export const useUsersHandlers = () => {
|
||||
// const queryClient = useQueryClient();
|
||||
|
||||
// // Core mutations
|
||||
// // const { updateUser, isPending: isUpdatePending, errors: isUpdateError } = useUpdateUserMutation();
|
||||
// // const { deleteUser, isPending: isDeletePending } = useDeleteUserMutation();
|
||||
// // const { sendPasswordRecovery, isPending: isSendPasswordRecoveryPending } = useSendPasswordRecoveryMutation();
|
||||
// // const { sendMagicLink, isPending: isSendMagicLinkPending } = useSendMagicLinkMutation();
|
||||
// // const { banUser, isPending: isBanPending } = useBanUserMutation();
|
||||
// // const { unbanUser, isPending: isUnbanPending } = useUnbanUserMutation();
|
||||
|
||||
// const {
|
||||
// getCurrentUser,
|
||||
// getUserById,
|
||||
// getUserByEmail,
|
||||
// getUserByUsername,
|
||||
// createUser,
|
||||
// inviteUser,
|
||||
// updateUser,
|
||||
// deleteUser,
|
||||
// banUser,
|
||||
// unbanUser
|
||||
// } = useUsersAction();
|
||||
|
||||
|
||||
// /**
|
||||
// * update a user by ID
|
||||
// */
|
||||
|
||||
// const handleUpdateUser = async (userId: string, data: IUpdateUserSchema, options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void
|
||||
// }) => {
|
||||
// await updateUser({ id: userId, data }, {
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
|
||||
// toast.success("User updated successfully");
|
||||
// options?.onSuccess?.();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error("Failed to update user");
|
||||
// options?.onError?.(error);
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
|
||||
// /**
|
||||
// * Deletes a user by ID
|
||||
// */
|
||||
// const handleDeleteUser = async (userId: string, options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void
|
||||
// }) => {
|
||||
// await deleteUser(userId, {
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
// toast.success("User deleted successfully");
|
||||
// options?.onSuccess?.();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error("Failed to delete user");
|
||||
// options?.onError?.(error);
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Sends a password recovery email to the user
|
||||
// */
|
||||
// const handleSendPasswordRecovery = async (email: string, options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void
|
||||
// }) => {
|
||||
// if (!email) {
|
||||
// toast.error("No email address provided");
|
||||
// options?.onError?.(new Error("No email address provided"));
|
||||
// return;
|
||||
// }
|
||||
|
||||
// await sendPasswordRecovery(email, {
|
||||
// onSuccess: () => {
|
||||
// toast.success("Recovery email sent");
|
||||
// options?.onSuccess?.();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error("Failed to send recovery email");
|
||||
// options?.onError?.(error);
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Sends a magic link to the user's email
|
||||
// */
|
||||
// const handleSendMagicLink = async (email: string, options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void
|
||||
// }) => {
|
||||
// if (!email) {
|
||||
// toast.error("No email address provided");
|
||||
// options?.onError?.(new Error("No email address provided"));
|
||||
// return;
|
||||
// }
|
||||
|
||||
// await sendMagicLink(email, {
|
||||
// onSuccess: () => {
|
||||
// toast.success("Magic link sent");
|
||||
// options?.onSuccess?.();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error("Failed to send magic link");
|
||||
// options?.onError?.(error);
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Bans a user for the specified duration
|
||||
// */
|
||||
// const handleBanUser = async (userId: string, banDuration: ValidBanDuration = "24h", options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void
|
||||
// }) => {
|
||||
// await banUser({ credential: { id: userId }, data: { ban_duration: banDuration } }, {
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
// toast.success("User banned successfully");
|
||||
// options?.onSuccess?.();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error("Failed to ban user");
|
||||
// options?.onError?.(error);
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Unbans a user
|
||||
// */
|
||||
// const handleUnbanUser = async (userId: string, options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void
|
||||
// }) => {
|
||||
// await unbanUser({ id: userId }, {
|
||||
// onSuccess: () => {
|
||||
// queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
// toast.success("User unbanned successfully");
|
||||
// options?.onSuccess?.();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// toast.error("Failed to unban user");
|
||||
// options?.onError?.(error);
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Toggles a user's ban status
|
||||
// */
|
||||
// const handleToggleBan = async (user: { id: string, banned_until?: ValidBanDuration }, options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void,
|
||||
// banDuration?: ValidBanDuration
|
||||
// }) => {
|
||||
// if (user.banned_until) {
|
||||
// await handleUnbanUser(user.id, options);
|
||||
// } else {
|
||||
// await handleBanUser(user.id, options?.banDuration, options);
|
||||
// }
|
||||
// };
|
||||
|
||||
// /**
|
||||
// * Copies text to clipboard
|
||||
// */
|
||||
// const handleCopyItem = (item: string, options?: {
|
||||
// onSuccess?: () => void,
|
||||
// onError?: (error: unknown) => void
|
||||
// }) => {
|
||||
// if (!navigator.clipboard) {
|
||||
// const error = new Error("Clipboard not supported");
|
||||
// toast.error("Clipboard not supported");
|
||||
// options?.onError?.(error);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// if (!item) {
|
||||
// const error = new Error("Nothing to copy");
|
||||
// toast.error("Nothing to copy");
|
||||
// options?.onError?.(error);
|
||||
// return;
|
||||
// }
|
||||
|
||||
// navigator.clipboard.writeText(item)
|
||||
// .then(() => {
|
||||
// toast.success("Copied to clipboard");
|
||||
// options?.onSuccess?.();
|
||||
// })
|
||||
// .catch((error) => {
|
||||
// toast.error("Failed to copy to clipboard");
|
||||
// options?.onError?.(error);
|
||||
// });
|
||||
// };
|
||||
|
||||
// return {
|
||||
// // Action handlers
|
||||
// updateUser: handleUpdateUser,
|
||||
// deleteUser: handleDeleteUser,
|
||||
// sendPasswordRecovery: handleSendPasswordRecovery,
|
||||
// sendMagicLink: handleSendMagicLink,
|
||||
// banUser: handleBanUser,
|
||||
// unbanUser: handleUnbanUser,
|
||||
// toggleBan: handleToggleBan,
|
||||
// copyToClipboard: handleCopyItem,
|
||||
|
||||
// // Loading states
|
||||
// isUpdatePending,
|
||||
// isDeletePending,
|
||||
// isSendPasswordRecoveryPending,
|
||||
// isSendMagicLinkPending,
|
||||
// isBanPending,
|
||||
// isUnbanPending,
|
||||
|
||||
// // Errors
|
||||
// isUpdateError,
|
||||
// };
|
||||
// };
|
||||
|
||||
// // Specific handler for the component
|
||||
|
||||
// export const useAddUserDialogHandler = ({ onUserAdded, onOpenChange }: {
|
||||
// onUserAdded: () => void;
|
||||
// onOpenChange: (open: boolean) => void;
|
||||
// }) => {
|
||||
|
||||
// const queryClient = useQueryClient();
|
||||
// const { createUser, isPending } = useCreateUserMutation();
|
||||
|
||||
// const {
|
||||
// register,
|
||||
// handleSubmit,
|
||||
// reset,
|
||||
// formState: { errors: errors },
|
||||
// setError,
|
||||
// getValues,
|
||||
// clearErrors,
|
||||
// watch,
|
||||
// } = useForm<ICreateUserSchema>({
|
||||
// resolver: zodResolver(CreateUserSchema),
|
||||
// defaultValues: {
|
||||
// email: "",
|
||||
// password: "",
|
||||
// email_confirm: true,
|
||||
// }
|
||||
// });
|
||||
|
||||
// const emailConfirm = watch("email_confirm");
|
||||
|
||||
// const onSubmit = handleSubmit(async (data) => {
|
||||
|
||||
// await createUser(data, {
|
||||
// onSuccess: () => {
|
||||
|
||||
// queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
|
||||
// toast.success("User created successfully.");
|
||||
|
||||
// onUserAdded();
|
||||
// onOpenChange(false);
|
||||
// reset();
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// reset();
|
||||
// toast.error(error.message);
|
||||
// },
|
||||
// });
|
||||
|
||||
// });
|
||||
|
||||
// const handleOpenChange = (open: boolean) => {
|
||||
// if (!open) {
|
||||
// reset();
|
||||
// }
|
||||
// onOpenChange(open);
|
||||
// };
|
||||
|
||||
// return {
|
||||
// register,
|
||||
// handleSubmit: onSubmit,
|
||||
// reset,
|
||||
// errors,
|
||||
// isPending,
|
||||
// getValues,
|
||||
// clearErrors,
|
||||
// emailConfirm,
|
||||
// handleOpenChange,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useInviteUserHandler = ({ onUserInvited, onOpenChange }: {
|
||||
// onUserInvited: () => void;
|
||||
// onOpenChange: (open: boolean) => void;
|
||||
// }) => {
|
||||
|
||||
// const queryClient = useQueryClient();
|
||||
// const { inviteUser, isPending } = useInviteUserMutation();
|
||||
|
||||
// const {
|
||||
// register,
|
||||
// handleSubmit,
|
||||
// reset,
|
||||
// formState: { errors: errors },
|
||||
// setError,
|
||||
// getValues,
|
||||
// clearErrors,
|
||||
// watch,
|
||||
// } = useForm<IInviteUserSchema>({
|
||||
// resolver: zodResolver(InviteUserSchema),
|
||||
// defaultValues: defaulIInviteUserSchemaValues
|
||||
// })
|
||||
|
||||
// const onSubmit = handleSubmit(async (data) => {
|
||||
// await inviteUser(data, {
|
||||
// onSuccess: () => {
|
||||
|
||||
// queryClient.invalidateQueries({ queryKey: ["users"] });
|
||||
|
||||
// toast.success("Invitation sent");
|
||||
|
||||
// onUserInvited();
|
||||
// onOpenChange(false);
|
||||
// reset();
|
||||
// },
|
||||
// onError: () => {
|
||||
// reset();
|
||||
// toast.error("Failed to send invitation");
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
|
||||
// const handleOpenChange = (open: boolean) => {
|
||||
// if (!open) {
|
||||
// reset();
|
||||
// }
|
||||
// onOpenChange(open);
|
||||
// };
|
||||
|
||||
// return {
|
||||
// register,
|
||||
// handleSubmit: onSubmit,
|
||||
// handleOpenChange,
|
||||
// reset,
|
||||
// getValues,
|
||||
// clearErrors,
|
||||
// watch,
|
||||
// errors,
|
||||
// isPending,
|
||||
// };
|
||||
// }
|
||||
|
||||
|
||||
// export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenChange }: {
|
||||
// open: boolean;
|
||||
// user: IUserSchema;
|
||||
// onUserUpdated: () => void;
|
||||
// onOpenChange: (open: boolean) => void;
|
||||
// }) => {
|
||||
// const {
|
||||
// deleteUser,
|
||||
// sendPasswordRecovery,
|
||||
// sendMagicLink,
|
||||
// banUser,
|
||||
// unbanUser,
|
||||
// toggleBan,
|
||||
// copyToClipboard,
|
||||
// isDeletePending,
|
||||
// isSendPasswordRecoveryPending,
|
||||
// isSendMagicLinkPending,
|
||||
// isBanPending,
|
||||
// isUnbanPending,
|
||||
// } = useUsersHandlers();
|
||||
|
||||
// const handleDeleteUser = async () => {
|
||||
// await deleteUser(user.id, {
|
||||
// onSuccess: () => {
|
||||
// onOpenChange(false);
|
||||
// }
|
||||
// });
|
||||
// };
|
||||
|
||||
// const handleSendPasswordRecovery = async () => {
|
||||
// if (!user.email) {
|
||||
// toast.error("User has no email address");
|
||||
// return;
|
||||
// }
|
||||
// await sendPasswordRecovery(user.email);
|
||||
// };
|
||||
|
||||
// const handleSendMagicLink = async () => {
|
||||
// if (!user.email) {
|
||||
// toast.error("User has no email address");
|
||||
// return;
|
||||
// }
|
||||
// await sendMagicLink(user.email);
|
||||
// };
|
||||
|
||||
// const handleBanUser = async () => {
|
||||
// await banUser(user.id, "24h", {
|
||||
// onSuccess: onUserUpdated
|
||||
// });
|
||||
// };
|
||||
|
||||
// const handleUnbanUser = async () => {
|
||||
// await unbanUser(user.id, {
|
||||
// onSuccess: onUserUpdated
|
||||
// });
|
||||
// };
|
||||
|
||||
// const handleToggleBan = async () => {
|
||||
// await toggleBan({ id: user.id }, {
|
||||
// onSuccess: onUserUpdated
|
||||
// });
|
||||
// };
|
||||
|
||||
// return {
|
||||
// handleDeleteUser,
|
||||
// handleSendPasswordRecovery,
|
||||
// handleSendMagicLink,
|
||||
// handleBanUser,
|
||||
// handleUnbanUser,
|
||||
// handleToggleBan,
|
||||
// handleCopyItem: copyToClipboard,
|
||||
// isDeletePending,
|
||||
// isSendPasswordRecoveryPending,
|
||||
// isSendMagicLinkPending,
|
||||
// isBanPending,
|
||||
// isUnbanPending,
|
||||
// };
|
||||
// };
|
||||
|
||||
// export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUserUpdated }: {
|
||||
// open: boolean;
|
||||
// userData: IUserSchema;
|
||||
// onOpenChange: (open: boolean) => void;
|
||||
// onUserUpdated: () => void;
|
||||
// }) => {
|
||||
|
||||
// const { updateUser, isUpdatePending, isUpdateError } = useUsersHandlers();
|
||||
|
||||
// // Initialize form with user data
|
||||
// const form = useForm<IUpdateUserSchema>({
|
||||
// resolver: zodResolver(UpdateUserSchema),
|
||||
// defaultValues: {
|
||||
// email: userData?.email || undefined,
|
||||
// encrypted_password: userData?.encrypted_password || undefined,
|
||||
// role: (userData?.role as "user" | "staff" | "admin") || "user",
|
||||
// phone: userData?.phone || undefined,
|
||||
// invited_at: userData?.invited_at || undefined,
|
||||
// confirmed_at: userData?.confirmed_at || undefined,
|
||||
// // recovery_sent_at: userData?.recovery_sent_at || undefined,
|
||||
// last_sign_in_at: userData?.last_sign_in_at || undefined,
|
||||
// created_at: userData?.created_at || undefined,
|
||||
// updated_at: userData?.updated_at || undefined,
|
||||
// is_anonymous: userData?.is_anonymous || false,
|
||||
// profile: {
|
||||
// // id: userData?.profile?.id || undefined,
|
||||
// // user_id: userData?.profile?.user_id || undefined,
|
||||
// avatar: userData?.profile?.avatar || undefined,
|
||||
// username: userData?.profile?.username || undefined,
|
||||
// first_name: userData?.profile?.first_name || undefined,
|
||||
// last_name: userData?.profile?.last_name || undefined,
|
||||
// bio: userData?.profile?.bio || undefined,
|
||||
// address: userData?.profile?.address || {
|
||||
// street: "",
|
||||
// city: "",
|
||||
// state: "",
|
||||
// country: "",
|
||||
// postal_code: "",
|
||||
// },
|
||||
// birth_date: userData?.profile?.birth_date ? new Date(userData.profile.birth_date) : undefined,
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
|
||||
// const handleUpdateUser = async () => {
|
||||
// await updateUser(userData.id, form.getValues(), {
|
||||
// onSuccess: () => {
|
||||
// onUserUpdated();
|
||||
// onOpenChange(false);
|
||||
// },
|
||||
// onError: () => {
|
||||
// onOpenChange(false);
|
||||
// },
|
||||
// });
|
||||
|
||||
// }
|
||||
|
||||
// return {
|
||||
// handleUpdateUser,
|
||||
// form,
|
||||
// isUpdatePending,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useUserManagementHandlers = (refetch: () => void) => {
|
||||
// const [searchQuery, setSearchQuery] = useState("")
|
||||
// const [detailUser, setDetailUser] = useState<IUserSchema | null>(null)
|
||||
// const [updateUser, setUpdateUser] = useState<IUserSchema | null>(null)
|
||||
// const [isSheetOpen, setIsSheetOpen] = useState(false)
|
||||
// const [isUpdateOpen, setIsUpdateOpen] = useState(false)
|
||||
// const [isAddUserOpen, setIsAddUserOpen] = useState(false)
|
||||
// const [isInviteUserOpen, setIsInviteUserOpen] = useState(false)
|
||||
|
||||
// // Filter states
|
||||
// const [filters, setFilters] = useState<IUserFilterOptionsSchema>({
|
||||
// email: "",
|
||||
// phone: "",
|
||||
// lastSignIn: "",
|
||||
// createdAt: "",
|
||||
// status: [],
|
||||
// })
|
||||
|
||||
// // Handle opening the detail sheet
|
||||
// const handleUserClick = (user: IUserSchema) => {
|
||||
// setDetailUser(user)
|
||||
// setIsSheetOpen(true)
|
||||
// }
|
||||
|
||||
// // Handle opening the update sheet
|
||||
// const handleUserUpdate = (user: IUserSchema) => {
|
||||
// setUpdateUser(user)
|
||||
// setIsUpdateOpen(true)
|
||||
// }
|
||||
|
||||
// // Close detail sheet when update sheet opens
|
||||
// useEffect(() => {
|
||||
// if (isUpdateOpen) {
|
||||
// setIsSheetOpen(false)
|
||||
// }
|
||||
// }, [isUpdateOpen])
|
||||
|
||||
// // Reset detail user when sheet closes
|
||||
// useEffect(() => {
|
||||
// if (!isSheetOpen) {
|
||||
// // Use a small delay to prevent flickering if another sheet is opening
|
||||
// const timer = setTimeout(() => {
|
||||
// if (!isSheetOpen && !isUpdateOpen) {
|
||||
// setDetailUser(null)
|
||||
// }
|
||||
// }, 300)
|
||||
// return () => clearTimeout(timer)
|
||||
// }
|
||||
// }, [isSheetOpen, isUpdateOpen])
|
||||
|
||||
// // Reset update user when update sheet closes
|
||||
// useEffect(() => {
|
||||
// if (!isUpdateOpen) {
|
||||
// // Use a small delay to prevent flickering if another sheet is opening
|
||||
// const timer = setTimeout(() => {
|
||||
// if (!isUpdateOpen) {
|
||||
// setUpdateUser(null)
|
||||
// }
|
||||
// }, 300)
|
||||
// return () => clearTimeout(timer)
|
||||
// }
|
||||
// }, [isUpdateOpen])
|
||||
|
||||
// const clearFilters = () => {
|
||||
// setFilters({
|
||||
// email: "",
|
||||
// phone: "",
|
||||
// lastSignIn: "",
|
||||
// createdAt: "",
|
||||
// status: [],
|
||||
// })
|
||||
// }
|
||||
|
||||
// const getActiveFilterCount = () => {
|
||||
// return Object.values(filters).filter(
|
||||
// (value) => (typeof value === "string" && value !== "") || (Array.isArray(value) && value.length > 0),
|
||||
// ).length
|
||||
// }
|
||||
|
||||
// return {
|
||||
// searchQuery,
|
||||
// setSearchQuery,
|
||||
// detailUser,
|
||||
// updateUser,
|
||||
// isSheetOpen,
|
||||
// setIsSheetOpen,
|
||||
// isUpdateOpen,
|
||||
// setIsUpdateOpen,
|
||||
// isAddUserOpen,
|
||||
// setIsAddUserOpen,
|
||||
// isInviteUserOpen,
|
||||
// setIsInviteUserOpen,
|
||||
// filters,
|
||||
// setFilters,
|
||||
// handleUserClick,
|
||||
// handleUserUpdate,
|
||||
// clearFilters,
|
||||
// getActiveFilterCount,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const filterUsers = (users: IUserSchema[], searchQuery: string, filters: IUserFilterOptionsSchema): IUserSchema[] => {
|
||||
// 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
|
||||
// })
|
||||
// }
|
|
@ -1,212 +0,0 @@
|
|||
// import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
// import {
|
||||
// banUser,
|
||||
// getCurrentUser,
|
||||
// getUserByEmail,
|
||||
// getUserById,
|
||||
// getUsers,
|
||||
// unbanUser,
|
||||
// inviteUser,
|
||||
// createUser,
|
||||
// updateUser,
|
||||
// deleteUser,
|
||||
// getUserByUsername
|
||||
// } from "./action";
|
||||
// import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
// import { IBanDuration, IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model";
|
||||
// import { ICredentialsUnbanUserSchema, IUnbanUserSchema } from "@/src/entities/models/users/unban-user.model";
|
||||
// import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model";
|
||||
// import { IUpdateUserSchema } from "@/src/entities/models/users/update-user.model";
|
||||
// import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model";
|
||||
// import { ICredentialGetUserByEmailSchema, ICredentialGetUserByIdSchema, ICredentialGetUserByUsernameSchema, IGetUserByEmailSchema, IGetUserByIdSchema, IGetUserByUsernameSchema } from "@/src/entities/models/users/read-user.model";
|
||||
|
||||
// const useUsersAction = () => {
|
||||
|
||||
// // For all users (no parameters needed)
|
||||
// const getUsersQuery = useQuery<IUserSchema[]>({
|
||||
// queryKey: ["users"],
|
||||
// queryFn: () => getUsers()
|
||||
// });
|
||||
|
||||
// // Current user query doesn't need parameters
|
||||
// const getCurrentUserQuery = useQuery<IUserSchema>({
|
||||
// queryKey: ["user", "current"],
|
||||
// queryFn: () => getCurrentUser()
|
||||
// });
|
||||
|
||||
// const getUserByIdQuery = (credential: ICredentialGetUserByIdSchema) => useQuery<IUserSchema>({
|
||||
// queryKey: ["user", "id", credential.id],
|
||||
// queryFn: () => getUserById(credential)
|
||||
// });
|
||||
|
||||
// const getUserByEmailQuery = (credential: IGetUserByEmailSchema) => useQuery<IUserSchema>({
|
||||
// queryKey: ["user", "email", credential.email],
|
||||
// queryFn: () => getUserByEmail(credential)
|
||||
// });
|
||||
|
||||
// const getUserByUsernameQuery = (credential: IGetUserByUsernameSchema) => useQuery<IUserSchema>({
|
||||
// queryKey: ["user", "username", credential.username],
|
||||
// queryFn: () => getUserByUsername(credential)
|
||||
// });
|
||||
|
||||
// // Mutations that don't need dynamic parameters
|
||||
// const banUserMutation = (credential: ICredentialsBanUserSchema, data: IBanUserSchema) => useMutation({
|
||||
// mutationKey: ["banUser"],
|
||||
// mutationFn: () => banUser(credential, data)
|
||||
// });
|
||||
|
||||
// const unbanUserMutation = useMutation({
|
||||
// mutationKey: ["unbanUser"],
|
||||
// mutationFn: async (credential: ICredentialsUnbanUserSchema) => await unbanUser(credential)
|
||||
// });
|
||||
|
||||
// // Create functions that return configured hooks
|
||||
// const inviteUserMutation = useMutation({
|
||||
// mutationKey: ["inviteUser"],
|
||||
// mutationFn: async (credential: ICredentialsInviteUserSchema) => await inviteUser(credential)
|
||||
// });
|
||||
|
||||
// const createUserMutation = useMutation({
|
||||
// mutationKey: ["createUser"],
|
||||
// mutationFn: async (data: ICreateUserSchema) => await createUser(data)
|
||||
// });
|
||||
|
||||
// const updateUserMutation = useMutation({
|
||||
// mutationKey: ["updateUser"],
|
||||
// mutationFn: async (params: { id: string; data: IUpdateUserSchema }) => updateUser(params.id, params.data)
|
||||
// });
|
||||
|
||||
// const deleteUserMutation = useMutation({
|
||||
// mutationKey: ["deleteUser"],
|
||||
// mutationFn: async (id: string) => await deleteUser(id)
|
||||
// });
|
||||
|
||||
// return {
|
||||
// getUsers: getUsersQuery,
|
||||
// getCurrentUser: getCurrentUserQuery,
|
||||
// getUserById: getUserByIdQuery,
|
||||
// getUserByEmailQuery,
|
||||
// getUserByUsernameQuery,
|
||||
// banUser: banUserMutation,
|
||||
// unbanUser: unbanUserMutation,
|
||||
// inviteUser: inviteUserMutation,
|
||||
// createUser: createUserMutation,
|
||||
// updateUser: updateUserMutation,
|
||||
// deleteUser: deleteUserMutation
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useGetUsersQuery = () => {
|
||||
// const { getUsers } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// data: getUsers.data,
|
||||
// isPending: getUsers.isPending,
|
||||
// error: getUsers.error,
|
||||
// refetch: getUsers.refetch,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useGetCurrentUserQuery = () => {
|
||||
// const { getCurrentUser } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// data: getCurrentUser.data,
|
||||
// isPending: getCurrentUser.isPending,
|
||||
// error: getCurrentUser.error,
|
||||
// refetch: getCurrentUser.refetch,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useGetUserByIdQuery = (credential: ICredentialGetUserByIdSchema) => {
|
||||
// const { getUserById } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// data: getUserById(credential).data,
|
||||
// isPending: getUserById(credential).isPending,
|
||||
// error: getUserById(credential).error,
|
||||
// refetch: getUserById(credential).refetch,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useGetUserByEmailQuery = (credential: ICredentialGetUserByEmailSchema) => {
|
||||
// const { getUserByEmailQuery } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// data: getUserByEmailQuery(credential).data,
|
||||
// isPending: getUserByEmailQuery(credential).isPending,
|
||||
// error: getUserByEmailQuery(credential).error,
|
||||
// refetch: getUserByEmailQuery(credential).refetch,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useGetUserByUsernameQuery = (credential: ICredentialGetUserByUsernameSchema) => {
|
||||
// const { getUserByUsernameQuery } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// data: getUserByUsernameQuery(credential).data,
|
||||
// isPending: getUserByUsernameQuery(credential).isPending,
|
||||
// error: getUserByUsernameQuery(credential).error,
|
||||
// refetch: getUserByUsernameQuery(credential).refetch,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useCreateUserMutation = () => {
|
||||
// const { createUser } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// createUser: createUser.mutateAsync,
|
||||
// isPending: createUser.isPending,
|
||||
// errors: createUser.error,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useInviteUserMutation = () => {
|
||||
// const { inviteUser } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// inviteUser: inviteUser.mutateAsync,
|
||||
// isPending: inviteUser.isPending,
|
||||
// errors: inviteUser.error,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useUpdateUserMutation = () => {
|
||||
// const { updateUser } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// updateUser: updateUser.mutateAsync,
|
||||
// isPending: updateUser.isPending,
|
||||
// errors: updateUser.error,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useBanUserMutation = () => {
|
||||
// const { banUser } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// banUser: banUser.mutateAsync,
|
||||
// isPending: banUser.isPending,
|
||||
// errors: banUser.error,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useUnbanUserMutation = () => {
|
||||
// const { unbanUser } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// unbanUser: unbanUser.mutateAsync,
|
||||
// isPending: unbanUser.isPending,
|
||||
// errors: unbanUser.error,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useDeleteUserMutation = () => {
|
||||
// const { deleteUser } = useUsersAction();
|
||||
|
||||
// return {
|
||||
// deleteUser: deleteUser.mutateAsync,
|
||||
// isPending: deleteUser.isPending,
|
||||
// errors: deleteUser.error,
|
||||
// }
|
||||
// }
|
|
@ -0,0 +1,30 @@
|
|||
"use client"
|
||||
|
||||
import type { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from "@/app/(pages)/(auth)/_queries/mutations"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const useSendMagicLinkHandler = (user: IUserSchema, onOpenChange: (open: boolean) => void) => {
|
||||
const { mutateAsync: sendMagicLink, isPending } = useSendMagicLinkMutation()
|
||||
|
||||
const handleSendMagicLink = async () => {
|
||||
if (user.email) {
|
||||
await sendMagicLink(user.email, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Magic link sent to ${user.email}`)
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
onOpenChange(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSendMagicLink,
|
||||
isPending,
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
"use client"
|
||||
|
||||
import type { IUserSchema } from "@/src/entities/models/users/users.model"
|
||||
import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from "@/app/(pages)/(auth)/_queries/mutations"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const useSendPasswordRecoveryHandler = (user: IUserSchema, onOpenChange: (open: boolean) => void) => {
|
||||
const { mutateAsync: sendPasswordRecovery, isPending } =
|
||||
useSendPasswordRecoveryMutation()
|
||||
|
||||
const handleSendPasswordRecovery = async () => {
|
||||
if (user.email) {
|
||||
await sendPasswordRecovery(user.email, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Password recovery email sent to ${user.email}`)
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
onOpenChange(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSendPasswordRecovery,
|
||||
isPending,
|
||||
}
|
||||
}
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
// import { AuthenticationError } from "@/src/entities/errors/auth";
|
||||
// import { useState } from "react";
|
||||
// import { useAuthActions } from './queries';
|
||||
// import { useForm } from 'react-hook-form';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';;
|
||||
// import { toast } from 'sonner';
|
||||
// import { useNavigations } from '@/app/_hooks/use-navigations';
|
||||
// import {
|
||||
// IVerifyOtpSchema,
|
||||
// verifyOtpSchema,
|
||||
// } from '@/src/entities/models/auth/verify-otp.model';
|
||||
|
||||
// /**
|
||||
// * Hook untuk menangani proses sign in
|
||||
// *
|
||||
// * @returns {Object} Object berisi handler dan state untuk form sign in
|
||||
// * @example
|
||||
// * const { handleSubmit, isPending, error } = useSignInHandler();
|
||||
// * <form onSubmit={handleSubmit}>...</form>
|
||||
// */
|
||||
// export function useSignInHandler() {
|
||||
// const { signIn } = useAuthActions();
|
||||
// const { router } = useNavigations();
|
||||
|
||||
// const [error, setError] = useState<string>();
|
||||
|
||||
// const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
// event.preventDefault();
|
||||
// if (signIn.isPending) return;
|
||||
|
||||
// setError(undefined);
|
||||
|
||||
// const formData = new FormData(event.currentTarget);
|
||||
// const email = formData.get('email')?.toString();
|
||||
|
||||
// const res = await signIn.mutateAsync(formData);
|
||||
|
||||
// if (!res?.error) {
|
||||
// toast('An email has been sent to you. Please check your inbox.');
|
||||
// if (email) router.push(`/verify-otp?email=${encodeURIComponent(email)}`);
|
||||
// } else {
|
||||
// setError(res.error);
|
||||
// }
|
||||
|
||||
|
||||
// };
|
||||
|
||||
// return {
|
||||
// // formData,
|
||||
// // handleChange,
|
||||
// handleSignIn: handleSubmit,
|
||||
// error,
|
||||
// isPending: signIn.isPending,
|
||||
// errors: !!error || signIn.error,
|
||||
// clearError: () => setError(undefined),
|
||||
// };
|
||||
// }
|
||||
|
||||
// export function useVerifyOtpHandler(email: string) {
|
||||
// const { router } = useNavigations();
|
||||
// const { verifyOtp } = useAuthActions();
|
||||
// const [error, setError] = useState<string>();
|
||||
|
||||
// const {
|
||||
// register,
|
||||
// handleSubmit: hookFormSubmit,
|
||||
// control,
|
||||
// formState: { errors },
|
||||
// setValue,
|
||||
// } = useForm<IVerifyOtpSchema>({
|
||||
// resolver: zodResolver(verifyOtpSchema),
|
||||
// defaultValues: {
|
||||
// email,
|
||||
// token: '',
|
||||
// },
|
||||
// });
|
||||
|
||||
// const handleOtpChange = (
|
||||
// value: string,
|
||||
// onChange: (value: string) => void
|
||||
// ) => {
|
||||
// onChange(value);
|
||||
|
||||
// if (value.length === 6) {
|
||||
// handleSubmit();
|
||||
// }
|
||||
|
||||
// // Clear error when user starts typing
|
||||
// if (error) {
|
||||
// setError(undefined);
|
||||
// }
|
||||
// };
|
||||
|
||||
// const handleSubmit = hookFormSubmit(async (data) => {
|
||||
// if (verifyOtp.isPending) return;
|
||||
|
||||
// setError(undefined);
|
||||
|
||||
// // Create FormData object
|
||||
// const formData = new FormData();
|
||||
// formData.append('email', data.email);
|
||||
// formData.append('token', data.token);
|
||||
|
||||
// await verifyOtp.mutateAsync(formData, {
|
||||
// onSuccess: () => {
|
||||
// toast.success('OTP verified successfully');
|
||||
// // Navigate to dashboard on success
|
||||
// router.push('/dashboard');
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// setError(error.message);
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
|
||||
// return {
|
||||
// register,
|
||||
// control,
|
||||
// handleVerifyOtp: handleSubmit,
|
||||
// handleOtpChange,
|
||||
// errors: {
|
||||
// ...errors,
|
||||
// token: error ? { message: error } : errors.token,
|
||||
// },
|
||||
// isPending: verifyOtp.isPending,
|
||||
// clearError: () => setError(undefined),
|
||||
// };
|
||||
// }
|
||||
|
||||
// export function useSignOutHandler() {
|
||||
// const { signOut } = useAuthActions();
|
||||
// const { router } = useNavigations();
|
||||
// const [error, setError] = useState<string>();
|
||||
|
||||
// const handleSignOut = async () => {
|
||||
// if (signOut.isPending) return;
|
||||
|
||||
// setError(undefined);
|
||||
|
||||
// await signOut.mutateAsync(undefined, {
|
||||
// onSuccess: () => {
|
||||
// toast.success('You have been signed out successfully');
|
||||
// router.push('/sign-in');
|
||||
// },
|
||||
// onError: (error) => {
|
||||
// if (error instanceof AuthenticationError) {
|
||||
// setError(error.message);
|
||||
// toast.error(error.message);
|
||||
// }
|
||||
// },
|
||||
// });
|
||||
// };
|
||||
|
||||
// return {
|
||||
// handleSignOut,
|
||||
// error,
|
||||
// isPending: signOut.isPending,
|
||||
// errors: !!error || signOut.error,
|
||||
// clearError: () => setError(undefined),
|
||||
// };
|
||||
// }
|
|
@ -1,89 +0,0 @@
|
|||
// import { useMutation } from '@tanstack/react-query';
|
||||
// import { sendMagicLink, sendPasswordRecovery, signIn, signOut, verifyOtp } from './action';
|
||||
|
||||
// export function useAuthActions() {
|
||||
// // Sign In Mutation
|
||||
// const signInMutation = useMutation({
|
||||
// mutationKey: ["signIn"],
|
||||
// mutationFn: async (formData: FormData) => await signIn(formData)
|
||||
// });
|
||||
|
||||
// // Verify OTP Mutation
|
||||
// const verifyOtpMutation = useMutation({
|
||||
// mutationKey: ["verifyOtp"],
|
||||
// mutationFn: async (formData: FormData) => await verifyOtp(formData)
|
||||
// });
|
||||
|
||||
// const signOutMutation = useMutation({
|
||||
// mutationKey: ["signOut"],
|
||||
// mutationFn: async () => await signOut()
|
||||
// });
|
||||
|
||||
// const sendMagicLinkMutation = useMutation({
|
||||
// mutationKey: ["sendMagicLink"],
|
||||
// mutationFn: async (email: string) => await sendMagicLink(email)
|
||||
// });
|
||||
|
||||
// const sendPasswordRecoveryMutation = useMutation({
|
||||
// mutationKey: ["sendPasswordRecovery"],
|
||||
// mutationFn: async (email: string) => await sendPasswordRecovery(email)
|
||||
// });
|
||||
|
||||
// return {
|
||||
// signIn: signInMutation,
|
||||
// verifyOtp: verifyOtpMutation,
|
||||
// signOut: signOutMutation,
|
||||
// sendMagicLink: sendMagicLinkMutation,
|
||||
// sendPasswordRecovery: sendPasswordRecoveryMutation
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useSignInMutation = () => {
|
||||
// const { signIn } = useAuthActions();
|
||||
|
||||
// return {
|
||||
// signIn: signIn.mutateAsync,
|
||||
// isPending: signIn.isPending,
|
||||
// error: signIn.error,
|
||||
// };
|
||||
// }
|
||||
|
||||
// export const useVerifyOtpMutation = () => {
|
||||
// const { verifyOtp } = useAuthActions();
|
||||
|
||||
// return {
|
||||
// verifyOtp: verifyOtp.mutateAsync,
|
||||
// isPending: verifyOtp.isPending,
|
||||
// error: verifyOtp.error,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useSignOutMutation = () => {
|
||||
// const { signOut } = useAuthActions();
|
||||
|
||||
// return {
|
||||
// signOut: signOut.mutateAsync,
|
||||
// isPending: signOut.isPending
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useSendMagicLinkMutation = () => {
|
||||
// const { sendMagicLink } = useAuthActions();
|
||||
|
||||
// return {
|
||||
// sendMagicLink: sendMagicLink.mutateAsync,
|
||||
// isPending: sendMagicLink.isPending,
|
||||
// error: sendMagicLink.error,
|
||||
// }
|
||||
// }
|
||||
|
||||
// export const useSendPasswordRecoveryMutation = () => {
|
||||
// const { sendPasswordRecovery } = useAuthActions();
|
||||
|
||||
// return {
|
||||
// sendPasswordRecovery: sendPasswordRecovery.mutateAsync,
|
||||
// isPending: sendPasswordRecovery.isPending,
|
||||
// error: sendPasswordRecovery.error,
|
||||
// }
|
||||
// }
|
||||
|
|
@ -1,10 +1,28 @@
|
|||
import { SignInForm } from "@/app/(pages)/(auth)/_components/signin-form";
|
||||
import { Message } from "@/app/_components/form-message";
|
||||
import { Button } from "@/app/_components/ui/button";
|
||||
import { GalleryVerticalEnd, Globe } from "lucide-react";
|
||||
import { Carousel, CarouselContent, CarouselItem, CarouselNext, CarouselPrevious } from "@/app/_components/ui/carousal";
|
||||
import { GalleryVerticalEnd, Globe, QuoteIcon } from "lucide-react";
|
||||
|
||||
const carouselContent = [
|
||||
{
|
||||
quote: "Tried @supabase for the first time yesterday. Amazing tool! I was able to get my Posgres DB up in no time and their documentation on operating on the DB is super easy! 👏 Can't wait for Cloud functions to arrive! It's gonna be a great Firebase alternative!",
|
||||
author: "@codewithbhargav",
|
||||
image: "https://github.com/shadcn.png",
|
||||
},
|
||||
{
|
||||
quote: "Check out this amazing product @supabase. A must give try #newidea #opportunity",
|
||||
author: "@techenthusiast",
|
||||
image: "https://github.com/shadcn.png",
|
||||
},
|
||||
{
|
||||
quote: "Check out this amazing product @supabase. A must give try #newidea #opportunity",
|
||||
author: "@dataguru",
|
||||
image: "https://github.com/shadcn.png",
|
||||
},
|
||||
];
|
||||
|
||||
export default async function Login(props: { searchParams: Promise<Message> }) {
|
||||
|
||||
return (
|
||||
<div className="grid min-h-svh lg:grid-cols-5">
|
||||
<div className="flex flex-col gap-4 p-6 md:p-10 bg-[#171717] lg:col-span-2 relative border border-r-2 border-r-gray-400 border-opacity-20">
|
||||
|
@ -31,22 +49,28 @@ export default async function Login(props: { searchParams: Promise<Message> }) {
|
|||
<Globe className="mr-0 h-4 w-4" />
|
||||
Showcase
|
||||
</Button>
|
||||
<div className="flex flex-col max-w-md">
|
||||
<div className="text-6xl text-gray-600 mb-8">"</div>
|
||||
<h2 className="text-4xl font-bold text-white mb-8">
|
||||
@Sigap Tech. Is the best to manage your crime data and report.
|
||||
</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src="https://github.com/shadcn.png"
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white font-medium">@codewithbhargav</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Carousel showDots autoPlay autoPlayInterval={10000} className="w-full max-w-md" >
|
||||
<CarouselContent className="py-8">
|
||||
{carouselContent.map((item, index) => (
|
||||
<CarouselItem key={index} className="flex flex-col items-center justify-center">
|
||||
<div className="relative flex flex-col items-start text-start">
|
||||
<QuoteIcon className="absolute h-20 w-20 text-primary opacity-10 -z-10 top-[-30px] transform rotate-180 " />
|
||||
<h2 className="text-3xl font-medium text-white mb-8">{item.quote}</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<img
|
||||
src={item.image}
|
||||
alt="Profile"
|
||||
className="w-12 h-12 rounded-full"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-white font-medium">{item.author}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
|
@ -50,6 +48,7 @@ interface DataTableProps<TData, TValue> {
|
|||
onRowClick?: (row: TData) => void;
|
||||
onActionClick?: (row: TData, action: string) => void;
|
||||
pageSize?: number;
|
||||
onCurrentPageDataCountChange?: (count: number) => void;
|
||||
}
|
||||
|
||||
export function DataTable<TData, TValue>({
|
||||
|
@ -59,6 +58,7 @@ export function DataTable<TData, TValue>({
|
|||
onRowClick,
|
||||
onActionClick,
|
||||
pageSize = 5,
|
||||
onCurrentPageDataCountChange, // Terima prop ini
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||
|
@ -87,6 +87,17 @@ export function DataTable<TData, TValue>({
|
|||
},
|
||||
});
|
||||
|
||||
// Hitung jumlah data di halaman saat ini
|
||||
const currentPageDataCount = table.getRowModel().rows.length;
|
||||
|
||||
// Panggil callback jika jumlah data berubah
|
||||
useEffect(() => {
|
||||
if (onCurrentPageDataCountChange) {
|
||||
onCurrentPageDataCountChange(currentPageDataCount);
|
||||
}
|
||||
}, [currentPageDataCount, onCurrentPageDataCountChange]);
|
||||
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="border rounded-md">
|
||||
|
@ -99,9 +110,9 @@ export function DataTable<TData, TValue>({
|
|||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
|
@ -112,7 +123,7 @@ export function DataTable<TData, TValue>({
|
|||
<TableRow key={index}>
|
||||
{columns.map((_, colIndex) => (
|
||||
<TableCell key={colIndex}>
|
||||
<Skeleton className="h-6 w-full" />
|
||||
<Skeleton className="h-6 w-full bg-muted" />
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
@ -223,7 +234,7 @@ export function DataTable<TData, TValue>({
|
|||
to{" "}
|
||||
{Math.min(
|
||||
(table.getState().pagination.pageIndex + 1) *
|
||||
table.getState().pagination.pageSize,
|
||||
table.getState().pagination.pageSize,
|
||||
table.getFilteredRowModel().rows.length
|
||||
)}{" "}
|
||||
of {table.getFilteredRowModel().rows.length} entries
|
|
@ -0,0 +1,306 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import useEmblaCarousel, { type UseEmblaCarouselType } from "embla-carousel-react"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react"
|
||||
import { cn } from "@/app/_lib/utils"
|
||||
import { Button } from "./button"
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1]
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
|
||||
type CarouselOptions = UseCarouselParameters[0]
|
||||
type CarouselPlugin = UseCarouselParameters[1]
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions
|
||||
plugins?: CarouselPlugin
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
autoPlay?: boolean
|
||||
autoPlayInterval?: number
|
||||
showDots?: boolean
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
|
||||
api: ReturnType<typeof useEmblaCarousel>[1]
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
} & CarouselProps
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & CarouselProps>(
|
||||
(
|
||||
{
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
autoPlay = false,
|
||||
autoPlayInterval = 3000,
|
||||
showDots = false,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
)
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false)
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
setCanScrollPrev(api.canScrollPrev())
|
||||
setCanScrollNext(api.canScrollNext())
|
||||
}, [])
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev()
|
||||
}, [api])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext()
|
||||
}, [api])
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault()
|
||||
scrollPrev()
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault()
|
||||
scrollNext()
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) {
|
||||
return
|
||||
}
|
||||
|
||||
setApi(api)
|
||||
}, [api, setApi])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) {
|
||||
return
|
||||
}
|
||||
|
||||
onSelect(api)
|
||||
api.on("reInit", onSelect)
|
||||
api.on("select", onSelect)
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect)
|
||||
}
|
||||
}, [api, onSelect])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!autoPlay || !api) {
|
||||
return
|
||||
}
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (api.canScrollNext()) {
|
||||
api.scrollNext()
|
||||
} else {
|
||||
api.scrollTo(0) // Reset to the first slide
|
||||
}
|
||||
}, autoPlayInterval)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [autoPlay, autoPlayInterval, api])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation: orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
showDots,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
},
|
||||
)
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { carouselRef, orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div ref={carouselRef} className="overflow-hidden">
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex", orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { orientation } = useCarousel()
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
className={cn("min-w-0 shrink-0 grow-0 basis-full", orientation === "horizontal" ? "pl-4" : "pt-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-1/2 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
)
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<HTMLButtonElement, React.ComponentProps<typeof Button>>(
|
||||
({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-1/2 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
)
|
||||
},
|
||||
)
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
const CarouselDots = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
||||
({ className, ...props }, ref) => {
|
||||
const { api, showDots } = useCarousel()
|
||||
const [selectedIndex, setSelectedIndex] = React.useState(0)
|
||||
const [scrollSnaps, setScrollSnaps] = React.useState<number[]>([])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !showDots) return
|
||||
|
||||
setScrollSnaps(api.scrollSnapList())
|
||||
|
||||
const onSelect = () => {
|
||||
setSelectedIndex(api.selectedScrollSnap())
|
||||
}
|
||||
|
||||
api.on("select", onSelect)
|
||||
onSelect()
|
||||
|
||||
return () => {
|
||||
api.off("select", onSelect)
|
||||
}
|
||||
}, [api, showDots])
|
||||
|
||||
if (!showDots) return null
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("flex justify-center gap-1 mt-2", className)} {...props}>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
"w-2 h-2 rounded-full transition-colors",
|
||||
selectedIndex === index ? "bg-primary" : "bg-muted",
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
)
|
||||
CarouselDots.displayName = "CarouselDots"
|
||||
|
||||
export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, CarouselDots }
|
||||
|
|
@ -2,6 +2,7 @@ import { format } from "date-fns";
|
|||
import { redirect } from "next/navigation";
|
||||
import { DateFormatOptions, DateFormatPattern } from "../_lib/types/date-format.interface";
|
||||
import { toast } from "sonner";
|
||||
import { IUserSchema } from "@/src/entities/models/users/users.model";
|
||||
|
||||
/**
|
||||
* Redirects to a specified path with an encoded message as a query parameter.
|
||||
|
@ -301,3 +302,31 @@ export const getInitials = (firstName: string, lastName: string, email: string):
|
|||
return "U";
|
||||
}
|
||||
|
||||
|
||||
export function calculateUserStats(users: IUserSchema[] | undefined) {
|
||||
if (!users || !Array.isArray(users)) {
|
||||
return {
|
||||
totalUsers: 0,
|
||||
activeUsers: 0,
|
||||
inactiveUsers: 0,
|
||||
activePercentage: '0.0',
|
||||
inactivePercentage: '0.0',
|
||||
};
|
||||
}
|
||||
|
||||
const totalUsers = users.length;
|
||||
const activeUsers = users.filter(
|
||||
(user) => !user.banned_until && user.email_confirmed_at
|
||||
).length;
|
||||
const inactiveUsers = totalUsers - activeUsers;
|
||||
|
||||
return {
|
||||
totalUsers,
|
||||
activeUsers,
|
||||
inactiveUsers,
|
||||
activePercentage:
|
||||
totalUsers > 0 ? ((activeUsers / totalUsers) * 100).toFixed(1) : '0.0',
|
||||
inactivePercentage:
|
||||
totalUsers > 0 ? ((inactiveUsers / totalUsers) * 100).toFixed(1) : '0.0',
|
||||
};
|
||||
}
|
||||
|
|
|
@ -36,6 +36,7 @@
|
|||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^12.4.7",
|
||||
|
@ -6028,6 +6029,34 @@
|
|||
"integrity": "sha512-AidaH9JETVRr9DIPGfp1kAarm/W6hRJTPuCnkF+2MqhF4KaAgRIcBc8nvjk+YMXZhwfISof/7WG29eS4iGxQLQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/embla-carousel": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.5.2.tgz",
|
||||
"integrity": "sha512-xQ9oVLrun/eCG/7ru3R+I5bJ7shsD8fFwLEY7yPe27/+fDHCNj0OT5EoG5ZbFyOxOcG6yTwW8oTz/dWyFnyGpg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/embla-carousel-react": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.5.2.tgz",
|
||||
"integrity": "sha512-Tmx+uY3MqseIGdwp0ScyUuxpBgx5jX1f7od4Cm5mDwg/dptEiTKf9xp6tw0lZN2VA9JbnVMl/aikmbc53c6QFA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"embla-carousel": "8.5.2",
|
||||
"embla-carousel-reactive-utils": "8.5.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/embla-carousel-reactive-utils": {
|
||||
"version": "8.5.2",
|
||||
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.5.2.tgz",
|
||||
"integrity": "sha512-QC8/hYSK/pEmqEdU1IO5O+XNc/Ptmmq7uCB44vKplgLKhB/l0+yvYx0+Cv0sF6Ena8Srld5vUErZkT+yTahtDg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"embla-carousel": "8.5.2"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "9.2.2",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
|
||||
|
|
|
@ -41,6 +41,7 @@
|
|||
"class-variance-authority": "^0.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"embla-carousel-react": "^8.5.2",
|
||||
"input-otp": "^1.4.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
"motion": "^12.4.7",
|
||||
|
|
Loading…
Reference in New Issue