diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/profile-form.tsx b/sigap-website/app/(pages)/(admin)/_components/profile-form.tsx similarity index 62% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/profile-form.tsx rename to sigap-website/app/(pages)/(admin)/_components/profile-form.tsx index a0a692a..d54fe02 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/profile-form.tsx +++ b/sigap-website/app/(pages)/(admin)/_components/profile-form.tsx @@ -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( - // user?.profile?.avatar || null - // ); - - // const fileInputRef = useRef(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({ - // 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) => { - // 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) { ) : ( - {getInitials(firstName, lastName, email)} + {getInitials(firstName, lastName, email)} )}
diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/column.tsx deleted file mode 100644 index 56f414c..0000000 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/column.tsx +++ /dev/null @@ -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[] = [ - { - id: "select", - header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> - ), - cell: ({ row }) => ( - row.toggleSelected(!!value)} - aria-label="Select row" - onClick={(e) => e.stopPropagation()} - /> - ), - enableSorting: false, - enableHiding: false, - }, - { - accessorKey: "email", - header: "Email", - cell: ({ row }) => ( -
{row.getValue("email")}
- ), - }, - { - accessorKey: "first_name", - header: "First Name", - cell: ({ row }) =>
{row.getValue("first_name") || "-"}
, - }, - { - accessorKey: "last_name", - header: "Last Name", - cell: ({ row }) =>
{row.getValue("last_name") || "-"}
, - }, - { - accessorKey: "role", - header: "Role", - cell: ({ row }) => ( - - {row.getValue("role")} - - ), - }, - { - accessorKey: "created_at", - header: "Created At", - cell: ({ row }) => ( -
- {formatDate(new Date(row.getValue("created_at")), "yyyy-MM-dd")} -
- ), - }, - { - accessorKey: "email_confirmed_at", - header: "Email Verified", - cell: ({ row }) => { - const verified = row.getValue("email_confirmed_at") !== null; - return ( - - {verified ? "Verified" : "Unverified"} - - ); - }, - }, - { - id: "actions", - cell: ({ row }) => { - const user = row.original; - - return ( - - e.stopPropagation()}> - - - - Actions - Edit user - - Reset password - Send magic link - - - Delete user - - - - ); - }, - }, -]; diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/add-user-dialog.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/add-user-dialog.tsx similarity index 90% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/add-user-dialog.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/add-user-dialog.tsx index 5e7ad5f..5c633d5 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/add-user-dialog.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/add-user-dialog.tsx @@ -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 ( diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/ban-user-dialog.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/ban-user-dialog.tsx similarity index 100% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/ban-user-dialog.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/ban-user-dialog.tsx diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/invite-user.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/invite-user-dialog.tsx similarity index 92% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/invite-user.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/invite-user-dialog.tsx index 157ee6c..d102abf 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/invite-user.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/invite-user-dialog.tsx @@ -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 ( diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/user-dialogs.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/user-dialogs.tsx new file mode 100644 index 0000000..a6212e4 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/dialogs/user-dialogs.tsx @@ -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 ( + <> + + + + + {/* Alert Dialog for Delete Confirmation */} + + + {/* Alert Dialog for Ban Confirmation */} + + + {/* Alert Dialog for Unban Confirmation */} + } + /> + + ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheet.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheet.tsx deleted file mode 100644 index afe8a44..0000000 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheet.tsx +++ /dev/null @@ -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 ( - - - - - {user.email} - - {user.banned_until && Banned} - {!user.email_confirmed_at && ( - Unconfirmed - )} - {!user.banned_until && user.email_confirmed_at && ( - Active - )} - - - -
- {/* User Information Section */} -
-

User Information

- -
-
- User UID -
- {user.id} - -
-
- -
- Created at - {formatDate(user.created_at)} -
- -
- Updated at - - {formatDate(user.updated_at)} - -
- -
- Invited at - {formatDate(user.invited_at)} -
- -
- - Confirmation sent at - - {formatDate(user.email_confirmed_at)} -
- -
- Confirmed at - {formatDate(user.email_confirmed_at)} -
- -
- Last signed in - {formatDate(user.last_sign_in_at)} -
- -
- SSO - -
-
-
- - - - {/* Provider Information Section */} -
-

Provider Information

-

- The user has the following providers -

- -
-
-
- -
-
Email
-
- Signed in with a email account -
-
-
- - Enabled - -
-
- -
-
-
-

Reset password

-

- Send a password recovery email to the user -

-
- -
- - - -
-
-

Send magic link

-

- Passwordless login via email for the user -

-
- -
-
-
- - - - {/* Danger Zone Section */} -
-

- Danger zone -

-

- Be wary of the following features as they cannot be undone. -

- -
-
-
-

Ban user

-

- Revoke access to the project for a set duration -

-
- -
- -
-
-

Delete user

-

- User will no longer have access to the project -

-
- } - 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" - /> -
-
-
-
- - {/* - - */} -
-
- ); -} diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/update-user.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/update-user-sheet.tsx similarity index 95% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/update-user.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/update-user-sheet.tsx index 13fe0f4..491ae85 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/update-user.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/update-user-sheet.tsx @@ -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 -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 ( diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/user-information-sheet.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/user-information-sheet.tsx new file mode 100644 index 0000000..545555c --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/sheets/user-information-sheet.tsx @@ -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 Banned; + } + if (!user.email_confirmed_at) { + return Unconfirmed; + } + return Active; + }; + + return ( + + + + + {user.email} + + {getUserStatusBadge()} + + + + + + Overview + Logs + Details + + + + + + + + + + + + + + + + + ); +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/access-cell.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/access-cell.tsx new file mode 100644 index 0000000..e3fa4d0 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/access-cell.tsx @@ -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 = ({ user }) => { +// const userAccess = ACCESS.filter(access => user.access?.includes(access)) + +// return ( +//
+// {userAccess.map(access => ( +// +// {access} +// +// ))} +// {user.banned_until && Banned} +// {!user.email_confirmed_at && Unconfirmed} +//
+// ) +// } \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/actions-cell.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/actions-cell.tsx new file mode 100644 index 0000000..93c07ed --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/actions-cell.tsx @@ -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 = ({ user, onUpdate }) => { + + const { + deleteDialogOpen, + setDeleteDialogOpen, + handleDeleteConfirm, + isDeletePending, + banDialogOpen, + setBanDialogOpen, + handleBanConfirm, + unbanDialogOpen, + setUnbanDialogOpen, + isBanPending, + isUnbanPending, + handleUnbanConfirm, + selectedUser, + setSelectedUser, + } = useCreateUserColumn() + + return ( +
e.stopPropagation()}> + + + + + + onUpdate(user)}> + + Update + + { + setSelectedUser({ id: user.id, email: user.email! }) + setDeleteDialogOpen(true) + }} + > + + Delete + + { + if (user.banned_until != null) { + setSelectedUser({ id: user.id, email: user.email! }) + setUnbanDialogOpen(true) + } else { + setSelectedUser({ id: user.id, email: user.email! }) + setBanDialogOpen(true) + } + }} + > + + {user.banned_until != null ? "Unban" : "Ban"} + + + + + {/* Alert Dialog for Delete Confirmation */} + + + {/* Alert Dialog for Ban Confirmation */} + + + {/* Alert Dialog for Unban Confirmation */} + } + /> +
+ ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/created-at-cell.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/created-at-cell.tsx new file mode 100644 index 0000000..6ab4b46 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/created-at-cell.tsx @@ -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 = ({ createdAt }) => { + return <>{createdAt ? new Date(createdAt).toLocaleString() : "N/A"} +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/email-cell.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/email-cell.tsx new file mode 100644 index 0000000..9d5ee0a --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/email-cell.tsx @@ -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 = ({ user }) => { + return ( +
+ + {user.profile?.avatar ? ( + Avatar + ) : ( + user.email?.[0]?.toUpperCase() || "?" + )} + +
+
{user.email || "No email"}
+
{user.profile?.username}
+
+
+ ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/last-sign-in-cell.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/last-sign-in-cell.tsx new file mode 100644 index 0000000..21518d7 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/last-sign-in-cell.tsx @@ -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 = ({ lastSignInAt }) => { + return <>{lastSignInAt ? new Date(lastSignInAt).toLocaleString() : "Never"} +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/phone-cell.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/phone-cell.tsx new file mode 100644 index 0000000..719eba0 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/phone-cell.tsx @@ -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 = ({ phone }) => { + return <>{phone || "-"} +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/status-cell.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/status-cell.tsx new file mode 100644 index 0000000..1dd518f --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/cells/status-cell.tsx @@ -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 = ({ user }) => { + if (user.banned_until) { + return Banned + } + if (!user.email_confirmed_at) { + return Unconfirmed + } + return Active +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/column.tsx new file mode 100644 index 0000000..30421d4 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/column.tsx @@ -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[] = [ +// { +// id: "select", +// header: ({ table }) => ( +// table.toggleAllPageRowsSelected(!!value)} +// aria-label="Select all" +// /> +// ), +// cell: ({ row }) => ( +// row.toggleSelected(!!value)} +// aria-label="Select row" +// onClick={(e) => e.stopPropagation()} +// /> +// ), +// enableSorting: false, +// enableHiding: false, +// }, +// { +// accessorKey: "email", +// header: "Email", +// cell: ({ row }) => ( +//
{row.getValue("email")}
+// ), +// }, +// { +// accessorKey: "first_name", +// header: "First Name", +// cell: ({ row }) =>
{row.getValue("first_name") || "-"}
, +// }, +// { +// accessorKey: "last_name", +// header: "Last Name", +// cell: ({ row }) =>
{row.getValue("last_name") || "-"}
, +// }, +// { +// accessorKey: "role", +// header: "Role", +// cell: ({ row }) => ( +// +// {row.getValue("role")} +// +// ), +// }, +// { +// accessorKey: "created_at", +// header: "Created At", +// cell: ({ row }) => ( +//
+// {formatDate(new Date(row.getValue("created_at")), "yyyy-MM-dd")} +//
+// ), +// }, +// { +// accessorKey: "email_confirmed_at", +// header: "Email Verified", +// cell: ({ row }) => { +// const verified = row.getValue("email_confirmed_at") !== null; +// return ( +// +// {verified ? "Verified" : "Unverified"} +// +// ); +// }, +// }, +// { +// id: "actions", +// cell: ({ row }) => { +// const user = row.original; + +// return ( +// +// e.stopPropagation()}> +// +// +// +// Actions +// Edit user +// +// Reset password +// Send magic link +// +// +// Delete user +// +// +// +// ); +// }, +// }, +// ]; diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/access-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/access-column.tsx new file mode 100644 index 0000000..f7e1ead --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/access-column.tsx @@ -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) => ( +
+ Access + + setFilters({ ...filters, status: values })} + onClear={() => setFilters({ ...filters, status: [] })} + /> + +
+ ), + cell: ({ row }: { row: { original: IUserSchema } }) => + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/actions-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/actions-column.tsx new file mode 100644 index 0000000..701614e --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/actions-column.tsx @@ -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 } }) => + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/created-at-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/created-at-column.tsx new file mode 100644 index 0000000..ae8627d --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/created-at-column.tsx @@ -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) => ( +
+ Created At + + setFilters({ ...filters, createdAt: value })} + onClear={() => setFilters({ ...filters, createdAt: "" })} + /> + +
+ ), + cell: ({ row }: { row: { original: IUserSchema } }) => ( + + ) + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/email-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/email-column.tsx new file mode 100644 index 0000000..db6c5a1 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/email-column.tsx @@ -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) => ( +
+ Email + + setFilters({ ...filters, email: value })} + onClear={() => setFilters({ ...filters, email: "" })} + /> + +
+ ), + cell: ({ row }: { row: { original: IUserSchema } }) => + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/index.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/index.ts new file mode 100644 index 0000000..cfc9c50 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/index.ts @@ -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 + +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), + ] +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/last-sign-in-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/last-sign-in-column.tsx new file mode 100644 index 0000000..75913f8 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/last-sign-in-column.tsx @@ -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) => ( +
+ Last Sign In + + setFilters({ ...filters, lastSignIn: value })} + onClear={() => setFilters({ ...filters, lastSignIn: "" })} + includeNever={true} + /> + +
+ ), + cell: ({ row }: { row: { original: IUserSchema } }) => ( + + ) + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/phone-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/phone-column.tsx new file mode 100644 index 0000000..06f074a --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/phone-column.tsx @@ -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) => ( +
+ Phone + + setFilters({ ...filters, phone: value })} + onClear={() => setFilters({ ...filters, phone: "" })} + /> + +
+ ), + cell: ({ row }: { row: { original: IUserSchema } }) => + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/status-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/status-column.tsx new file mode 100644 index 0000000..cdbec60 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/columns/status-column.tsx @@ -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) => ( +
+ Status + + setFilters({ ...filters, status: values })} + onClear={() => setFilters({ ...filters, status: [] })} + /> + +
+ ), + cell: ({ row }: { row: { original: IUserSchema } }) => + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/access-filter.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/access-filter.tsx new file mode 100644 index 0000000..c7753ed --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/access-filter.tsx @@ -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 = ({ 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) => ( + toggleStatus(access)} + > + {access} + + ))} + + Clear filter + + ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/column-filter.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/column-filter.tsx new file mode 100644 index 0000000..8421276 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/column-filter.tsx @@ -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 = ({ children }) => { + return ( + + + + + + {children} + + + ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/date-filter.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/date-filter.tsx new file mode 100644 index 0000000..1e1d70f --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/date-filter.tsx @@ -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 = ({ value, onChange, onClear, includeNever = false }) => { + return ( + <> + onChange(value === "today" ? "" : "today")} + > + Today + + onChange(value === "week" ? "" : "week")} + > + Last 7 days + + onChange(value === "month" ? "" : "month")} + > + Last 30 days + + {includeNever && ( + onChange(value === "never" ? "" : "never")} + > + Never + + )} + + Clear filter + + ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/status-filter.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/status-filter.tsx new file mode 100644 index 0000000..d720daf --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/status-filter.tsx @@ -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 = ({ 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 ( + <> + toggleStatus("active")} + > + Active + + toggleStatus("unconfirmed")} + > + Unconfirmed + + toggleStatus("banned")} + > + Banned + + + Clear filter + + ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/text-filter.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/text-filter.tsx new file mode 100644 index 0000000..ef2a176 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/table/filters/text-filter.tsx @@ -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 = ({ placeholder, value, onChange, onClear }) => { + return ( + <> +
+ onChange(e.target.value)} + className="w-full" + /> +
+ + Clear filter + + ) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-detail-tab.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-detail-tab.tsx new file mode 100644 index 0000000..ef27021 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-detail-tab.tsx @@ -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 ( +
+ {/* Basic Information */} +
+
+

User Details

+ +
+ +
+
+

Basic Information

+
+
+

Email

+

{user.email || "—"}

+
+ +
+

Phone

+

{user.phone || "—"}

+
+ +
+

Role

+

{user.role || "—"}

+
+ + {user.is_anonymous !== undefined && ( +
+

Anonymous

+ + {user.is_anonymous ? "Yes" : "No"} + +
+ )} +
+
+ +
+

Profile Information

+
+
+

Username

+

{user.profile?.username || "—"}

+
+ +
+

First Name

+

{user.profile?.first_name || "—"}

+
+ +
+

Last Name

+

{user.profile?.last_name || "—"}

+
+ +
+

Birth Date

+

+ {user.profile?.birth_date ? formatDate(user.profile.birth_date) : "—"} +

+
+
+
+
+
+ + + + {/* Address Information */} +
+

Address Information

+
+
+
+

Street

+

{user.profile?.address?.street || "—"}

+
+ +
+

City

+

{user.profile?.address?.city || "—"}

+
+ +
+

State

+

{user.profile?.address?.state || "—"}

+
+ +
+

Country

+

{user.profile?.address?.country || "—"}

+
+ +
+

Postal Code

+

{user.profile?.address?.postal_code || "—"}

+
+
+
+
+ + + + {/* Advanced Information */} +
+
+

System Information

+ +
+ +
+
+
+

User ID

+

{user.id}

+
+ + {showSensitiveInfo && user.encrypted_password && ( +
+

Password Status

+ + {user.encrypted_password ? "Set" : "Not Set"} + +
+ )} +
+
+ +
+

Timestamps

+
+
+

Created At

+

{formatDate(user.created_at)}

+
+ +
+

Updated At

+

{formatDate(user.updated_at)}

+
+ +
+

Last Sign In

+

{formatDate(user.last_sign_in_at)}

+
+ +
+

Email Confirmed At

+

{formatDate(user.email_confirmed_at)}

+
+ +
+

Invited At

+

{formatDate(user.invited_at)}

+
+ +
+

Recovery Sent At

+

{formatDate(user.recovery_sent_at)}

+
+ +
+

Confirmed At

+

{formatDate(user.confirmed_at)}

+
+ + {user.banned_until && ( +
+

Banned Until

+

{formatDate(user.banned_until)}

+
+ )} +
+
+ + {user.profile?.bio && ( +
+

Bio

+

{user.profile.bio}

+
+ )} +
+
+ ); +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-log-tab.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-log-tab.tsx new file mode 100644 index 0000000..f326937 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-log-tab.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [activeTab, setActiveTab] = useState("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 ; + case 'logout': + return ; + case 'password_reset': + return ; + case 'email_change': + return ; + case 'profile_update': + return ; + case 'account_creation': + return ; + case 'token_request': + return ; + case 'insert': + return ; + case 'update': + return ; + case 'delete': + return ; + case 'view': + return ; + 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 ( +
+
+
+

User Activity Logs

+

Latest logs from activity for this user in the past hour

+
+
+ + +
+ + Show all + Authentication logs + Action logs + +
+ +
+
+
+ + {isLoading ? ( +
+ {Array.from({ length: 1 }).map((_, index) => ( +
+ + + +
+ ))} +
+ ) : filteredLogs.length === 0 ? ( +
+ No logs found with the current filters. +
+ ) : ( +
+ + {/* + + + + + + */} + + {filteredLogs.slice(0, 10).map((log) => ( + + + + + + ))} + +
TimestampStatusDetails
+ {formatDate(log.timestamp)} + + + {log.status_code || '-'} + + +
+ {getLogIcon(log.type)} + + {log.endpoint && ( + {log.endpoint} + )} + {log.endpoint && log.details && ' | '} + {log.details} + +
+ {/* {log.resource && ( +
+ Resource: {log.resource} +
+ )} */} + {/* {log.ip_address && ( +
+ IP: {log.ip_address} +
+ )} + {log.user_agent && ( +
+ {log.user_agent} +
+ )} */} +
+ {/* {filteredLogs.length > 5 && ( */} +
+ +
+ {/* )} */} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-overview-tab.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-overview-tab.tsx new file mode 100644 index 0000000..7997bf9 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/tabs/user-overview-tab.tsx @@ -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 ( +
+ {/* User Information Section */} +
+

User Information

+ +
+
+ User UID +
+ {user.id} + +
+
+ +
+ Created at + {formatDate(user.created_at)} +
+ +
+ Last signed in + {formatDate(user.last_sign_in_at)} +
+ +
+ SSO + +
+
+
+ + + + {/* Provider Information Section */} +
+

Provider Information

+

+ The user has the following providers +

+ +
+
+
+ +
+
Email
+
+ Signed in with a email account +
+
+
+ + Enabled + +
+
+ +
+
+
+

Reset password

+

+ Send a password recovery email to the user +

+
+ +
+ + + +
+
+

Send magic link

+

+ Passwordless login via email for the user +

+
+ +
+
+
+ + + + {/* Danger Zone Section */} +
+

+ Danger zone +

+

+ Be wary of the following features as they cannot be undone. +

+ +
+
+
+

Ban user

+

+ Revoke access to the project for a set duration +

+
+ +
+ +
+
+

Delete user

+

+ User will no longer have access to the project +

+
+ } + 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" + /> +
+
+
+
+ ); +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/action.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/action.tsx new file mode 100644 index 0000000..578c286 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/action.tsx @@ -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 = ({ + setIsAddUserDialogOpen, + setIsInviteUserDialogOpen, +}) => { + return ( + + + + + + setIsAddUserDialogOpen(true)}> + + Create new user + + setIsInviteUserDialogOpen(true)}> + + Send invitation + + + + ); +}; \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/filter-button.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/filter-button.tsx new file mode 100644 index 0000000..7c72980 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/filter-button.tsx @@ -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 = ({ + activeFilterCount, + clearFilters, +}) => { + return ( + + ); +}; \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/search-input.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/search-input.tsx new file mode 100644 index 0000000..4ef1210 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/search-input.tsx @@ -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 = ({ + searchQuery, + setSearchQuery, +}) => { + return ( +
+ + setSearchQuery(e.target.value)} + /> + {searchQuery && ( + + )} +
+ ); +}; \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/user-management-toolbar.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/user-management-toolbar.tsx new file mode 100644 index 0000000..3befa4c --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/toolbars/user-management-toolbar.tsx @@ -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 = ({ + searchQuery, + setSearchQuery, + setIsAddUserDialogOpen, + setIsInviteUserDialogOpen, + activeFilterCount, + clearFilters, + currentPageDataCount, +}) => { + + return ( +
+
+

Users in table {currentPageDataCount}

+
+
+ + + +
+
+ ); +}; \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx index 2755d9e..f430c3f 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx @@ -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 (
-
-
- - setSearchQuery(e.target.value)} - /> - {searchQuery && ( - - )} -
-
- - - - - - setIsAddUserOpen(true)}> - - Create new user - - setIsInviteUserOpen(true)}> - - Send invitation - - - + - -
-
handleUserClick(user)} + onCurrentPageDataCountChange={setCurrentPageDataCount} /> - {detailUser && ( - { }} /> )} - { }} - /> - { }} - /> - {updateUser && ( - { }} + userData={isUpdateUser} /> )} + + + +
- ) + ); } diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx index aade99a..61ee8d9 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-stats.tsx @@ -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(); diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/users-table.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/users-table.tsx deleted file mode 100644 index 62133ff..0000000 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/users-table.tsx +++ /dev/null @@ -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 - -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) => ( -
- Email - - - - - -
- setFilters({ ...filters, email: e.target.value })} - className="w-full" - /> -
- - setFilters({ ...filters, email: "" })}>Clear filter -
-
-
- ), - cell: ({ row }) => ( -
- - {row.original.profile?.avatar ? ( - Avatar - ) : ( - row.original.email?.[0]?.toUpperCase() || "?" - )} - -
-
{row.original.email || "No email"}
-
{row.original.profile?.username}
-
-
- ), - }, - { - id: "phone", - header: ({ column }: HeaderContext) => ( -
- Phone - - - - - -
- setFilters({ ...filters, phone: e.target.value })} - className="w-full" - /> -
- - setFilters({ ...filters, phone: "" })}>Clear filter -
-
-
- ), - cell: ({ row }) => row.original.phone || "-", - }, - { - id: "lastSignIn", - header: ({ column }: HeaderContext) => ( -
- Last Sign In - - - - - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "today" ? "" : "today", - }) - } - > - Today - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "week" ? "" : "week", - }) - } - > - Last 7 days - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "month" ? "" : "month", - }) - } - > - Last 30 days - - - setFilters({ - ...filters, - lastSignIn: filters.lastSignIn === "never" ? "" : "never", - }) - } - > - Never - - - setFilters({ ...filters, lastSignIn: "" })}> - Clear filter - - - -
- ), - cell: ({ row }) => { - return row.original.last_sign_in_at ? new Date(row.original.last_sign_in_at).toLocaleString() : "Never" - }, - }, - { - id: "createdAt", - header: ({ column }: HeaderContext) => ( -
- Created At - - - - - - - setFilters({ - ...filters, - createdAt: filters.createdAt === "today" ? "" : "today", - }) - } - > - Today - - - setFilters({ - ...filters, - createdAt: filters.createdAt === "week" ? "" : "week", - }) - } - > - Last 7 days - - - setFilters({ - ...filters, - createdAt: filters.createdAt === "month" ? "" : "month", - }) - } - > - Last 30 days - - - setFilters({ ...filters, createdAt: "" })}> - Clear filter - - - -
- ), - cell: ({ row }) => { - return row.original.created_at ? new Date(row.original.created_at).toLocaleString() : "N/A" - }, - }, - { - id: "status", - header: ({ column }: HeaderContext) => ( -
- Status - - - - - - { - const newStatus = [...filters.status] - if (newStatus.includes("active")) { - newStatus.splice(newStatus.indexOf("active"), 1) - } else { - newStatus.push("active") - } - setFilters({ ...filters, status: newStatus }) - }} - > - Active - - { - const newStatus = [...filters.status] - if (newStatus.includes("unconfirmed")) { - newStatus.splice(newStatus.indexOf("unconfirmed"), 1) - } else { - newStatus.push("unconfirmed") - } - setFilters({ ...filters, status: newStatus }) - }} - > - Unconfirmed - - { - const newStatus = [...filters.status] - if (newStatus.includes("banned")) { - newStatus.splice(newStatus.indexOf("banned"), 1) - } else { - newStatus.push("banned") - } - setFilters({ ...filters, status: newStatus }) - }} - > - Banned - - - setFilters({ ...filters, status: [] })}>Clear filter - - -
- ), - cell: ({ row }) => { - if (row.original.banned_until) { - return Banned - } - if (!row.original.email_confirmed_at) { - return Unconfirmed - } - return Active - }, - }, - { - id: "actions", - header: "", - cell: ({ row }) => ( -
e.stopPropagation()}> - - - - - - handleUserUpdate(row.original)}> - - Update - - { - setSelectedUser({ id: row.original.id, email: row.original.email! }) - setDeleteDialogOpen(true) - }} - > - - Delete - - { - 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) - } - }} - > - - {row.original.banned_until != null ? "Unban" : "Ban"} - - - - - {/* Alert Dialog for Delete Confirmation */} - handleDeleteConfirm(row.original.id, row.original.email!)} - isPending={isDeletePending} - pendingText="Deleting..." - variant="destructive" - size="sm" - /> - - {/* Alert Dialog for Ban Confirmation */} - - - {/* Alert Dialog for Unban Confirmation */} - handleUnbanConfirm(row.original.id, row.original.email!)} - isPending={isUnbanPending} - pendingText="Unbanning..." - variant="default" - size="sm" - confirmIcon={} - /> - -
- ), - }, - ] -} - diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-ban-user.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-ban-user.ts new file mode 100644 index 0000000..db695d9 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-ban-user.ts @@ -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, + } +} + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-create-user.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-create-user.ts new file mode 100644 index 0000000..1f91cf9 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-create-user.ts @@ -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({ + 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, + } +} + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-delete-user.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-delete-user.ts new file mode 100644 index 0000000..0fc0bd0 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-delete-user.ts @@ -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, + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-invite-user.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-invite-user.ts new file mode 100644 index 0000000..e69de29 diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-unban-user.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-unban-user.ts new file mode 100644 index 0000000..924dfbc --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-unban-user.ts @@ -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, + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-update-user.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-update-user.ts new file mode 100644 index 0000000..9d0d591 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-update-user.ts @@ -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({ + 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, + } +} + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-user-actions.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-user-actions.ts new file mode 100644 index 0000000..a711a83 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/actions/use-user-actions.ts @@ -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, + } +} + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.ts similarity index 78% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.ts index 8a4041b..598f1c4 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.ts @@ -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, }; } \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.ts similarity index 82% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.ts index e97ff3d..1cce219 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.ts @@ -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.") diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts similarity index 84% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts index 74ce225..205f5fe 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts @@ -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(); + } }); } diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.ts similarity index 80% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.ts index 7b4d79f..243c1f8 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.ts @@ -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, }; } \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.ts similarity index 95% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.ts index 8707fa0..07633c7 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.ts @@ -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?.() diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.ts similarity index 91% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.ts index d533df6..cc6e2d3 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.ts @@ -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: () => { diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.ts similarity index 91% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.tsx rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.ts index 3b9956b..caf83f9 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.tsx +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.ts @@ -3,12 +3,10 @@ import { useEffect, useState } from "react" export const useUserManagementHandlers = () => { const [searchQuery, setSearchQuery] = useState("") - const [detailUser, setDetailUser] = useState(null) - const [updateUser, setUpdateUser] = useState(null) + const [isDetailUser, setIsDetailUser] = useState(null) + const [isUpdateUser, setIsUpdateUser] = useState(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({ @@ -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, diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/handler.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/handler.tsx deleted file mode 100644 index dc652c0..0000000 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/handler.tsx +++ /dev/null @@ -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({ -// 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({ -// 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({ -// 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(null) -// const [updateUser, setUpdateUser] = useState(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({ -// 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 -// }) -// } \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/queries.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/queries.ts deleted file mode 100644 index ae5a949..0000000 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/queries.ts +++ /dev/null @@ -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({ -// queryKey: ["users"], -// queryFn: () => getUsers() -// }); - -// // Current user query doesn't need parameters -// const getCurrentUserQuery = useQuery({ -// queryKey: ["user", "current"], -// queryFn: () => getCurrentUser() -// }); - -// const getUserByIdQuery = (credential: ICredentialGetUserByIdSchema) => useQuery({ -// queryKey: ["user", "id", credential.id], -// queryFn: () => getUserById(credential) -// }); - -// const getUserByEmailQuery = (credential: IGetUserByEmailSchema) => useQuery({ -// queryKey: ["user", "email", credential.email], -// queryFn: () => getUserByEmail(credential) -// }); - -// const getUserByUsernameQuery = (credential: IGetUserByUsernameSchema) => useQuery({ -// 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, -// } -// } \ No newline at end of file diff --git a/sigap-website/app/(pages)/(auth)/_handlers/use-send-magic-link.ts b/sigap-website/app/(pages)/(auth)/_handlers/use-send-magic-link.ts new file mode 100644 index 0000000..c50a193 --- /dev/null +++ b/sigap-website/app/(pages)/(auth)/_handlers/use-send-magic-link.ts @@ -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, + } +} + diff --git a/sigap-website/app/(pages)/(auth)/_handlers/use-send-password-recovery.ts b/sigap-website/app/(pages)/(auth)/_handlers/use-send-password-recovery.ts new file mode 100644 index 0000000..dc23a8a --- /dev/null +++ b/sigap-website/app/(pages)/(auth)/_handlers/use-send-password-recovery.ts @@ -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, + } +} + diff --git a/sigap-website/app/(pages)/(auth)/handler.tsx b/sigap-website/app/(pages)/(auth)/handler.tsx deleted file mode 100644 index 7817f5a..0000000 --- a/sigap-website/app/(pages)/(auth)/handler.tsx +++ /dev/null @@ -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(); -// *
...
-// */ -// export function useSignInHandler() { -// const { signIn } = useAuthActions(); -// const { router } = useNavigations(); - -// const [error, setError] = useState(); - -// const handleSubmit = async (event: React.FormEvent) => { -// 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(); - -// const { -// register, -// handleSubmit: hookFormSubmit, -// control, -// formState: { errors }, -// setValue, -// } = useForm({ -// 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(); - -// 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), -// }; -// } diff --git a/sigap-website/app/(pages)/(auth)/queries.ts b/sigap-website/app/(pages)/(auth)/queries.ts deleted file mode 100644 index 2196f00..0000000 --- a/sigap-website/app/(pages)/(auth)/queries.ts +++ /dev/null @@ -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, -// } -// } - diff --git a/sigap-website/app/(pages)/(auth)/sign-in/page.tsx b/sigap-website/app/(pages)/(auth)/sign-in/page.tsx index 4d5df87..1f56e08 100644 --- a/sigap-website/app/(pages)/(auth)/sign-in/page.tsx +++ b/sigap-website/app/(pages)/(auth)/sign-in/page.tsx @@ -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 }) { - return (
@@ -31,22 +49,28 @@ export default async function Login(props: { searchParams: Promise }) { Showcase -
-
"
-

- @Sigap Tech. Is the best to manage your crime data and report. -

-
- Profile -
-

@codewithbhargav

-
-
-
+ + + {carouselContent.map((item, index) => ( + +
+ +

{item.quote}

+
+ Profile +
+

{item.author}

+
+
+
+
+ ))} +
+
); diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/data-table.tsx b/sigap-website/app/_components/data-table.tsx similarity index 93% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/data-table.tsx rename to sigap-website/app/_components/data-table.tsx index 3f47d79..aab6e7c 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/data-table.tsx +++ b/sigap-website/app/_components/data-table.tsx @@ -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 { onRowClick?: (row: TData) => void; onActionClick?: (row: TData, action: string) => void; pageSize?: number; + onCurrentPageDataCountChange?: (count: number) => void; } export function DataTable({ @@ -59,6 +58,7 @@ export function DataTable({ onRowClick, onActionClick, pageSize = 5, + onCurrentPageDataCountChange, // Terima prop ini }: DataTableProps) { const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); @@ -87,6 +87,17 @@ export function DataTable({ }, }); + // 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 (
@@ -99,9 +110,9 @@ export function DataTable({ {header.isPlaceholder ? null : flexRender( - header.column.columnDef.header, - header.getContext() - )} + header.column.columnDef.header, + header.getContext() + )} ))} @@ -112,7 +123,7 @@ export function DataTable({ {columns.map((_, colIndex) => ( - + ))} @@ -223,7 +234,7 @@ export function DataTable({ 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 diff --git a/sigap-website/app/_components/ui/carousal.tsx b/sigap-website/app/_components/ui/carousal.tsx new file mode 100644 index 0000000..d6d4139 --- /dev/null +++ b/sigap-website/app/_components/ui/carousal.tsx @@ -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 +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[0] + api: ReturnType[1] + scrollPrev: () => void + scrollNext: () => void + canScrollPrev: boolean + canScrollNext: boolean +} & CarouselProps + +const CarouselContext = React.createContext(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + + if (!context) { + throw new Error("useCarousel must be used within a ") + } + + return context +} + +const Carousel = React.forwardRef & 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) => { + 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 ( + +
+ {children} +
+
+ ) + }, +) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef>( + ({ className, ...props }, ref) => { + const { carouselRef, orientation } = useCarousel() + + return ( +
+
+
+ ) + }, +) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = React.forwardRef>( + ({ className, ...props }, ref) => { + const { orientation } = useCarousel() + + return ( +
+ ) + }, +) +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef>( + ({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) + }, +) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef>( + ({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) + }, +) +CarouselNext.displayName = "CarouselNext" + +const CarouselDots = React.forwardRef>( + ({ className, ...props }, ref) => { + const { api, showDots } = useCarousel() + const [selectedIndex, setSelectedIndex] = React.useState(0) + const [scrollSnaps, setScrollSnaps] = React.useState([]) + + 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 ( +
+ {scrollSnaps.map((_, index) => ( +
+ ) + }, +) +CarouselDots.displayName = "CarouselDots" + +export { type CarouselApi, Carousel, CarouselContent, CarouselItem, CarouselPrevious, CarouselNext, CarouselDots } + diff --git a/sigap-website/app/_utils/common.ts b/sigap-website/app/_utils/common.ts index daceea0..cd4ba9a 100644 --- a/sigap-website/app/_utils/common.ts +++ b/sigap-website/app/_utils/common.ts @@ -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', + }; +} diff --git a/sigap-website/package-lock.json b/sigap-website/package-lock.json index 00ed8e6..b55307a 100644 --- a/sigap-website/package-lock.json +++ b/sigap-website/package-lock.json @@ -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", diff --git a/sigap-website/package.json b/sigap-website/package.json index f767e8f..15752d4 100644 --- a/sigap-website/package.json +++ b/sigap-website/package.json @@ -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",