Refactor lagi anjir

This commit is contained in:
vergiLgood1 2025-04-02 21:00:11 +07:00
parent 99692c37da
commit 741c44ebe5
66 changed files with 2934 additions and 2308 deletions

View File

@ -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">

View File

@ -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>
);
},
},
];

View File

@ -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}>

View File

@ -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}>

View File

@ -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" />}
/>
</>
)
}

View File

@ -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>
);
}

View File

@ -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}>

View File

@ -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>
);
}

View File

@ -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>
// )
// }

View File

@ -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>
)
}

View File

@ -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"}</>
}

View File

@ -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>
)
}

View File

@ -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"}</>
}

View File

@ -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 || "-"}</>
}

View File

@ -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>
}

View File

@ -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>
// );
// },
// },
// ];

View File

@ -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} />
}
}

View File

@ -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} />
}
}

View File

@ -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} />
)
}
}

View File

@ -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} />
}
}

View File

@ -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),
]
}

View File

@ -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
}
/>
)
}
}

View File

@ -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} />
}
}

View File

@ -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} />
}
}

View File

@ -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>
</>
)
}

View File

@ -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>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
</>
)
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
}

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -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>
)
);
}

View File

@ -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();

View File

@ -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>
),
},
]
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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,
};
}

View File

@ -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.")

View File

@ -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();
}
});
}

View File

@ -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,
};
}

View File

@ -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?.()

View File

@ -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: () => {

View File

@ -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,

View File

@ -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
// })
// }

View File

@ -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,
// }
// }

View File

@ -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,
}
}

View File

@ -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,
}
}

View File

@ -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),
// };
// }

View File

@ -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,
// }
// }

View File

@ -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>
);

View File

@ -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

View File

@ -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 }

View File

@ -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',
};
}

View File

@ -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",

View File

@ -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",