From 9380c371f85b29d3e2f49a875f875f1ff0a5aecc Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Sat, 29 Mar 2025 21:34:48 +0700 Subject: [PATCH] refactor createUsersTable --- .../_components/navigations/nav-user.tsx | 18 +- .../_components/settings/profile-settings.tsx | 162 ++++++------ .../_components/settings/setting-dialog.tsx | 6 +- .../_components/ban-user-dialog.tsx | 169 +++++++++++++ .../_components/profile-form.tsx | 236 +++++++++--------- .../user-management/_components/sheet.tsx | 78 ++---- .../_components/user-management.tsx | 6 +- .../_components/users-table.tsx | 88 ++++++- ...user-dialog.ts => use-add-user-dialog.tsx} | 0 .../_handlers/use-create-user-column.tsx | 120 +++++++++ ...e-detail-sheet.ts => use-detail-sheet.tsx} | 57 ++++- ...use-invite-user.ts => use-invite-user.tsx} | 0 .../_handlers/use-profile-form.tsx | 195 +++++++++++++++ ...profile-sheet.ts => use-profile-sheet.tsx} | 12 + ...-management.ts => use-user-management.tsx} | 0 .../user-management/_queries/mutations.ts | 9 +- .../dashboard/user-management/action.ts | 49 ++++ sigap-website/app/(pages)/layout.tsx | 2 +- .../app/_components/alert-dialog.tsx | 99 ++++++++ .../app/_components/ui/radio-group.tsx | 43 ++++ sigap-website/app/_lib/const/number.ts | 2 + sigap-website/app/_lib/const/string.ts | 1 + sigap-website/app/_utils/common.ts | 159 +++++++++++- sigap-website/di/modules/users.module.ts | 14 ++ sigap-website/di/types.ts | 6 + sigap-website/package-lock.json | 33 +++ sigap-website/package.json | 1 + .../users.repository.interface.ts | 1 + .../use-cases/users/upload-avatar.use-case.ts | 21 ++ .../repositories/users.repository.ts | 53 +++- ...n.controller.tsx => sign-in.controller.ts} | 0 ...ontroller.tsx => verify-otp.controller.ts} | 0 .../users/upload-avatar.controller.ts | 31 +++ 33 files changed, 1375 insertions(+), 296 deletions(-) create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/ban-user-dialog.tsx rename sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/{use-add-user-dialog.ts => use-add-user-dialog.tsx} (100%) create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.tsx rename sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/{use-detail-sheet.ts => use-detail-sheet.tsx} (66%) rename sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/{use-invite-user.ts => use-invite-user.tsx} (100%) create mode 100644 sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.tsx rename sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/{use-profile-sheet.ts => use-profile-sheet.tsx} (89%) rename sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/{use-user-management.ts => use-user-management.tsx} (100%) create mode 100644 sigap-website/app/_components/alert-dialog.tsx create mode 100644 sigap-website/app/_components/ui/radio-group.tsx create mode 100644 sigap-website/src/application/use-cases/users/upload-avatar.use-case.ts rename sigap-website/src/interface-adapters/controllers/auth/{sign-in.controller.tsx => sign-in.controller.ts} (100%) rename sigap-website/src/interface-adapters/controllers/auth/{verify-otp.controller.tsx => verify-otp.controller.ts} (100%) create mode 100644 sigap-website/src/interface-adapters/controllers/users/upload-avatar.controller.ts diff --git a/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx b/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx index 320ac47..9b04ad8 100644 --- a/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx +++ b/sigap-website/app/(pages)/(admin)/_components/navigations/nav-user.tsx @@ -39,9 +39,10 @@ export function NavUser({ user }: { user: IUserSchema | null }) { const lastName = user?.profile?.last_name || ""; const userEmail = user?.email || ""; const userAvatar = user?.profile?.avatar || ""; + const username = user?.profile?.username || ""; const getFullName = () => { - return `${firstName} ${lastName}`.trim() || "User"; + return `${firstName} ${lastName}`.trim() || username || "User"; }; // Generate initials for avatar fallback @@ -58,12 +59,6 @@ export function NavUser({ user }: { user: IUserSchema | null }) { return "U"; }; - // Handle dialog close after successful profile update - const handleProfileUpdateSuccess = () => { - setIsDialogOpen(false); - // You might want to refresh the user data here - }; - const { handleSignOut, isPending, errors, error } = useSignOutHandler(); function LogoutButton({ handleSignOut, isPending }: { handleSignOut: () => void; isPending: boolean }) { @@ -99,7 +94,6 @@ export function NavUser({ user }: { user: IUserSchema | null }) { onClick={() => { handleSignOut(); - // Tutup dialog setelah tombol Log out diklik if (!isPending) { setOpen(false); } @@ -133,13 +127,13 @@ export function NavUser({ user }: { user: IUserSchema | null }) { className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground" > - + {getInitials()}
- {getFullName()} + {username} {userEmail}
@@ -154,14 +148,14 @@ export function NavUser({ user }: { user: IUserSchema | null }) {
- + {getInitials()}
- {getFullName()} + {username} {userEmail}
diff --git a/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx b/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx index 3fe9f27..5deab88 100644 --- a/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx +++ b/sigap-website/app/(pages)/(admin)/_components/settings/profile-settings.tsx @@ -31,6 +31,8 @@ import { ScrollArea } from "@/app/_components/ui/scroll-area"; import { updateUser, } from "@/app/(pages)/(admin)/dashboard/user-management/action"; +import { useProfileFormHandlers } from "../../dashboard/user-management/_handlers/use-profile-form"; +import { CTexts } from "@/app/_lib/const/string"; const profileFormSchema = z.object({ username: z.string().nullable().optional(), @@ -44,71 +46,83 @@ interface ProfileSettingsProps { } export function ProfileSettings({ user }: ProfileSettingsProps) { - const [isUploading, setIsUploading] = useState(false); - const fileInputRef = useRef(null); + // const [isPending, setIsepisPending] = useState(false); + // const fileInputRef = useRef(null); - // Use profile data with fallbacks + // // Use profile data with fallbacks + // const username = user?.profile?.username || ""; + // const email = user?.email || ""; + // const userAvatar = user?.profile?.avatar || ""; + + // const form = useForm({ + // resolver: zodResolver(profileFormSchema), + // defaultValues: { + // username: username || "", + // avatar: userAvatar || "", + // }, + // }); + + // const handleFileChange = async (e: React.ChangeEvent) => { + // const file = e.target.files?.[0]; + + // if (!file || !user?.id || !user?.email) return; + + // try { + // setIsepisPending(true); + + // // Upload avatar to storage + // // const publicUrl = await uploadAvatar(user.id, user.email, file); + + // // console.log("publicUrl", publicUrl); + + // // Update the form value + // // form.setValue("avatar", publicUrl); + // } catch (error) { + // console.error("Error uploading avatar:", error); + // } finally { + // setIsepisPending(false); + // } + // }; + + // const handleAvatarClick = () => { + // fileInputRef.current?.click(); + // }; + + // async function onSubmit(data: ProfileFormValues) { + // try { + // if (!user?.id) return; + + // // Update profile in database + // const { error } = await updateUser(user.id, { + // profile: { + // avatar: data.avatar || undefined, + // username: data.username || undefined, + // }, + // }); + + // if (error) throw error; + // } catch (error) { + // console.error("Error updating profile:", error); + // } + + const email = user?.email || ""; const username = user?.profile?.username || ""; - const userEmail = user?.email || ""; - const userAvatar = user?.profile?.avatar || ""; - const form = useForm({ - resolver: zodResolver(profileFormSchema), - defaultValues: { - username: username || "", - avatar: userAvatar || "", - }, - }); + const { + form, + fileInputRef, + handleFileChange, + handleAvatarClick, + isPending, + onSubmit, + } = useProfileFormHandlers({ user }); - const handleFileChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - - if (!file || !user?.id || !user?.email) return; - - try { - setIsUploading(true); - - // Upload avatar to storage - // const publicUrl = await uploadAvatar(user.id, user.email, file); - - // console.log("publicUrl", publicUrl); - - // Update the form value - // form.setValue("avatar", publicUrl); - } catch (error) { - console.error("Error uploading avatar:", error); - } finally { - setIsUploading(false); - } - }; - - const handleAvatarClick = () => { - fileInputRef.current?.click(); - }; - - async function onSubmit(data: ProfileFormValues) { - try { - if (!user?.id) return; - - // Update profile in database - const { error } = await updateUser(user.id, { - profile: { - avatar: data.avatar || undefined, - username: data.username || undefined, - }, - }); - - if (error) throw error; - } catch (error) { - console.error("Error updating profile:", error); - } - } return (
- + { }} className="space-y-8">

Account

@@ -120,16 +134,21 @@ export function ProfileSettings({ user }: ProfileSettingsProps) { onClick={handleAvatarClick} > - - - {username?.[0]?.toUpperCase() || - userEmail?.[0]?.toUpperCase()} - + {isPending ? ( +
+ ) : ( + <> + + + {username?.[0]?.toUpperCase() || email?.[0]?.toUpperCase()} + + + )}
- {isUploading ? ( + {isPending ? ( ) : ( @@ -139,10 +158,10 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
@@ -154,10 +173,11 @@ export function ProfileSettings({ user }: ProfileSettingsProps) { @@ -172,7 +192,7 @@ export function ProfileSettings({ user }: ProfileSettingsProps) { variant="outline" size="sm" className="text-xs" - disabled={isUploading || form.formState.isSubmitting} + disabled={isPending || form.formState.isSubmitting} > {form.formState.isSubmitting ? ( <> @@ -195,7 +215,7 @@ export function ProfileSettings({ user }: ProfileSettingsProps) {
-

{userEmail}

+

{email}

@@ -108,7 +102,7 @@ export function UserDetailSheet({ variant="ghost" size="icon" className="h-4 w-4 ml-2" - onClick={() => handleCopyItem(user.id)} + onClick={() => handleCopyItem(user.id, "UID")} > @@ -172,7 +166,7 @@ export function UserDetailSheet({
Email
- Signed in with a email account via OAuth + Signed in with a email account
@@ -290,56 +284,18 @@ export function UserDetailSheet({ User will no longer have access to the project

- - - - - - - - Are you absolutely sure? - - - This action cannot be undone. This will permanently - delete the user account and remove their data from our - servers. - - - - Cancel - - {isDeletePending ? ( - <> - - Deleting... - - ) : ( - "Delete" - )} - - - - + } + 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/user-management.tsx b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_components/user-management.tsx index 0d1ed22..2755d9e 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 @@ -151,7 +151,11 @@ export default function UserManagement() { onOpenChange={setIsAddUserOpen} onUserAdded={() => { }} /> - refetch()} /> + { }} + /> {updateUser && ( @@ -25,10 +28,26 @@ export const createUserColumns = ( setFilters: (filters: IUserFilterOptionsSchema) => void, handleUserUpdate: (user: IUserSchema) => void, ): UserTableColumn[] => { - - const { mutateAsync: deleteUser } = useDeleteUserMutation(); - const { mutateAsync: banUser } = useBanUserMutation(); - const { mutateAsync: unbanUser } = useUnbanUserMutation(); + const { + deleteDialogOpen, + setDeleteDialogOpen, + userToDelete, + setUserToDelete, + handleDeleteConfirm, + isDeletePending, + banDialogOpen, + setBanDialogOpen, + userToBan, + setUserToBan, + handleBanConfirm, + unbanDialogOpen, + setUnbanDialogOpen, + userToUnban, + setUserToUnban, + isBanPending, + isUnbanPending, + handleUnbanConfirm, + } = useCreateUserColumn() return [ { @@ -319,7 +338,10 @@ export const createUserColumns = ( Update deleteUser(row.original.id)} + onClick={() => { + setUserToDelete(row.original.id) + setDeleteDialogOpen(true) + }} > Delete @@ -327,9 +349,11 @@ export const createUserColumns = ( { if (row.original.banned_until != null) { - unbanUser({ id: row.original.id }) + setUserToUnban(row.original.id) + setUnbanDialogOpen(true) } else { - banUser({ id: row.original.id, ban_duration: "24h" }) + setUserToBan(row.original.id) + setBanDialogOpen(true) } }} > @@ -338,6 +362,50 @@ export const createUserColumns = ( + + {/* Alert Dialog for Delete Confirmation */} + {deleteDialogOpen && userToDelete === row.original.id && ( + + )} + + {/* Alert Dialog for Ban Confirmation */} + {banDialogOpen && userToBan === row.original.id && ( + handleBanConfirm(duration)} + isPending={isBanPending} + userId={row.original.id} + /> + )} + + {/* Alert Dialog for Unban Confirmation */} + {unbanDialogOpen && userToUnban === row.original.id && ( + } + /> + )}
), }, diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.tsx similarity index 100% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.ts rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-add-user-dialog.tsx 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.tsx new file mode 100644 index 0000000..cabe2c2 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-create-user-column.tsx @@ -0,0 +1,120 @@ +import { useState } from "react" +import { useBanUserMutation, useDeleteUserMutation, useUnbanUserMutation } from "../_queries/mutations" +import { ValidBanDuration } from "@/app/_lib/types/ban-duration" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" + +export const useCreateUserColumn = () => { + + const queryClient = useQueryClient() + + // Delete user state and handlers + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [userToDelete, setUserToDelete] = useState(null) + const { mutateAsync: deleteUser, isPending: isDeletePending } = useDeleteUserMutation() + + // Ban user state and handlers + const [banDialogOpen, setBanDialogOpen] = useState(false) + const [userToBan, setUserToBan] = useState(null) + const { mutateAsync: banUser, isPending: isBanPending } = useBanUserMutation() + + // Unban user state and handlers + const [unbanDialogOpen, setUnbanDialogOpen] = useState(false) + const [userToUnban, setUserToUnban] = useState(null) + const { mutateAsync: unbanUser, isPending: isUnbanPending } = useUnbanUserMutation() + + const handleDeleteConfirm = async () => { + if (userToDelete) { + await deleteUser(userToDelete, { + onSuccess: () => { + if (isDeletePending) { + queryClient.invalidateQueries({ queryKey: ["users"] }) + + toast.success(`${userToDelete} has been deleted`) + setDeleteDialogOpen(false) + setUserToDelete(null) + } + }, + onError: (error) => { + + toast.error("Failed to delete user. Please try again later.") + + setDeleteDialogOpen(false) + setUserToDelete(null) + } + }) + } + } + + const handleBanConfirm = async (duration: ValidBanDuration) => { + if (userToBan) { + await banUser({ id: userToBan, ban_duration: duration }, { + onSuccess: () => { + if (!isBanPending) { + queryClient.invalidateQueries({ queryKey: ["users"] }) + + toast(`${userToBan} has been banned`) + + setBanDialogOpen(false) + setUserToBan(null) + } + }, + onError: (error) => { + toast.error("Failed to ban user. Please try again later.") + + setBanDialogOpen(false) + setUserToBan(null) + }, + }) + } + } + + const handleUnbanConfirm = async () => { + if (userToUnban) { + await unbanUser({ id: userToUnban }, { + onSuccess: () => { + if (!isUnbanPending) { + queryClient.invalidateQueries({ queryKey: ["users"] }) + + toast(`${userToUnban} has been unbanned`) + + setUnbanDialogOpen(false) + setUserToUnban(null) + } + }, + onError: (error) => { + toast.error("Failed to unban user. Please try again later.") + + setUnbanDialogOpen(false) + setUserToUnban(null) + } + }) + } + } + + return { + // Delete + deleteDialogOpen, + setDeleteDialogOpen, + userToDelete, + setUserToDelete, + handleDeleteConfirm, + isDeletePending, + + // Ban + banDialogOpen, + setBanDialogOpen, + userToBan, + setUserToBan, + handleBanConfirm, + isBanPending, + + // Unban + unbanDialogOpen, + setUnbanDialogOpen, + userToUnban, + setUserToUnban, + handleUnbanConfirm, + isUnbanPending, + } +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.tsx similarity index 66% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.tsx index cd8ae07..74ce225 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-detail-sheet.tsx @@ -3,7 +3,7 @@ import { toast } from "sonner"; import { useBanUserMutation, useDeleteUserMutation, useUnbanUserMutation } from "../_queries/mutations"; import { useSendMagicLinkMutation, useSendPasswordRecoveryMutation } from "@/app/(pages)/(auth)/_queries/mutations"; import { ValidBanDuration } from "@/app/_lib/types/ban-duration"; -import { handleCopyItem } from "@/app/_utils/common"; +import { copyItem } from "@/app/_utils/common"; import { useQueryClient } from "@tanstack/react-query"; export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenChange }: { @@ -24,30 +24,54 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh const handleDeleteUser = async () => { await deleteUser(user.id, { onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + toast.success(`${user.email} has been deleted`); + onOpenChange(false); } }); }; const handleSendPasswordRecovery = async () => { - if (!user.email) { - toast.error("User has no email address"); - return; - } - await sendPasswordRecovery(user.email); - }; + 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); + } + }); + }; + } const handleSendMagicLink = async () => { - if (!user.email) { - toast.error("User has no email address"); - return; + 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); + } + }); } - await sendMagicLink(user.email); }; const handleBanUser = async (ban_duration: ValidBanDuration = "24h") => { await banUser({ id: user.id, ban_duration: ban_duration }, { onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + toast(`${user.email} has been banned`); + onUserUpdated(); } }); @@ -55,7 +79,12 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh const handleUnbanUser = async () => { await unbanUser({ id: user.id }, { - onSuccess: onUserUpdated + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["users"] }); + toast(`${user.email} has been unbanned`); + + onUserUpdated(); + } }); }; @@ -81,6 +110,10 @@ export const useUserDetailSheetHandlers = ({ open, user, onUserUpdated, onOpenCh } }; + const handleCopyItem = async (item: string, label: string) => { + if (item) copyItem(item, { label: label }); + } + return { handleDeleteUser, handleSendPasswordRecovery, diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.tsx similarity index 100% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.ts rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-invite-user.tsx 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.tsx new file mode 100644 index 0000000..8707fa0 --- /dev/null +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-form.tsx @@ -0,0 +1,195 @@ +"use client" + +import type React from "react" + +import { createClient } from "@/app/_utils/supabase/client" +import type { IUserSchema } from "@/src/entities/models/users/users.model" +import { zodResolver } from "@hookform/resolvers/zod" +import { useRef, useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" +import { useUnbanUserMutation, useUpdateUserMutation, useUploadAvatarMutation } from "../_queries/mutations" +import { useQueryClient } from "@tanstack/react-query" +import { toast } from "sonner" +import { CNumbers } from "@/app/_lib/const/number" +import { CTexts } from "@/app/_lib/const/string" + +// Profile update form schema +const profileFormSchema = z.object({ + username: z.string().nullable().optional(), + first_name: z.string().nullable().optional(), + last_name: z.string().nullable().optional(), + bio: z.string().nullable().optional(), + avatar: z.string().nullable().optional(), +}) + +type ProfileFormValues = z.infer + +interface ProfileFormProps { + user: IUserSchema | null + onSuccess?: () => void +} + +export const useProfileFormHandlers = ({ user, onSuccess }: ProfileFormProps) => { + const queryClient = useQueryClient() + + const { + mutateAsync: updateUser, + isPending, + error + } = useUpdateUserMutation() + + const [avatarPreview, setAvatarPreview] = useState(user?.profile?.avatar || null) + + const fileInputRef = useRef(null) + + // Setup form with react-hook-form and zod validation + const form = useForm({ + resolver: zodResolver(profileFormSchema), + defaultValues: { + first_name: user?.profile?.first_name || "", + last_name: user?.profile?.last_name || "", + bio: user?.profile?.bio || "", + avatar: user?.profile?.avatar || "", + }, + }) + + const resetAvatarValue = () => { + if (fileInputRef.current) { + fileInputRef.current.value = "" + } + } + + // Handle avatar file upload + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + + if (!file || !user?.id) { + + toast.error("No file selected") + + resetAvatarValue() + return + } + + // Validate file size + if (file.size > CNumbers.MAX_FILE_AVATAR_SIZE) { + toast.error(`File size must be less than ${CNumbers.MAX_FILE_AVATAR_SIZE / 1024 / 1024} MB`) + + // Reset the file input + resetAvatarValue() + return + } + + // Validate file type + if (!CTexts.ALLOWED_FILE_TYPES.includes(file.type)) { + toast.error("Invalid file type. Only PNG and JPG are allowed") + + // Reset the file input + resetAvatarValue() + return + } + + try { + + // 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 = `AVR-${user.email?.split("@")[0]}` + const filePath = `${user.id}/${fileName}` + + const supabase = createClient() + + const { error: uploadError, data } = await supabase.storage.from("avatars").upload(filePath, file, { + upsert: true, + contentType: file.type, + }) + + if (uploadError) { + + toast.error("Error uploading avatar. Please try again.") + + throw uploadError + } + + // Get the public URL + const { + data: { publicUrl }, + } = supabase.storage.from("avatars").getPublicUrl(filePath) + + const uniquePublicUrl = `${publicUrl}?t=${Date.now()}` + + await updateUser({ id: user.id, data: { profile: { avatar: uniquePublicUrl } } }) + + form.setValue("avatar", uniquePublicUrl) + resetAvatarValue() + + queryClient.invalidateQueries({ queryKey: ["users"] }) + queryClient.invalidateQueries({ queryKey: ["user", "current"] }) + toast.success("Avatar uploaded successfully") + } catch (error) { + console.error("Error uploading avatar:", error) + + // Show error toast + toast.error("Error uploading avatar. Please try again.") + + // Revert to previous avatar if upload fails + setAvatarPreview(user?.profile?.avatar || null) + + // Reset the file input + resetAvatarValue() + } + } + + // Trigger file input click + const handleAvatarClick = () => { + fileInputRef.current?.click() + } + + // Handle form submission + async function onSubmit(data: ProfileFormValues) { + try { + if (!user?.id) return + + const supabase = createClient() + + // 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 + + toast.success("Profile updated successfully") + + // Invalidate the user query to refresh data + queryClient.invalidateQueries({ queryKey: ["user", "current", user.id] }) + + // Call success callback + onSuccess?.() + } catch (error) { + console.error("Error updating profile:", error) + toast.error("Error updating profile") + } + } + + return { + form, + handleFileChange, + handleAvatarClick, + onSubmit, + isPending, + avatarPreview, + fileInputRef, + } +} + diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.tsx similarity index 89% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.ts rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.tsx index 26a72ab..d533df6 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-profile-sheet.tsx @@ -3,6 +3,8 @@ import { 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 { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUserUpdated }: { open: boolean; @@ -11,6 +13,8 @@ export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUs onUserUpdated: () => void; }) => { + const queryClient = useQueryClient() + const { mutateAsync: updateUser, isPending, @@ -54,10 +58,18 @@ export const useUserProfileSheetHandlers = ({ open, onOpenChange, userData, onUs const handleUpdateUser = async () => { await updateUser({ id: userData.id, data: form.getValues() }, { onSuccess: () => { + + queryClient.invalidateQueries({ queryKey: ["users"] }) + + toast.success("User updated successfully") + onUserUpdated(); onOpenChange(false); }, onError: () => { + + toast.error("Failed to update user") + onOpenChange(false); }, }); diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.tsx similarity index 100% rename from sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.ts rename to sigap-website/app/(pages)/(admin)/dashboard/user-management/_handlers/use-user-management.tsx diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_queries/mutations.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_queries/mutations.ts index 7514d7a..6d75f96 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/_queries/mutations.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/_queries/mutations.ts @@ -1,6 +1,6 @@ import { ICreateUserSchema } from "@/src/entities/models/users/create-user.model"; import { useMutation } from "@tanstack/react-query"; -import { banUser, createUser, deleteUser, inviteUser, unbanUser, updateUser } from "../action"; +import { banUser, createUser, deleteUser, inviteUser, unbanUser, updateUser, uploadAvatar } from "../action"; import { IUpdateUserSchema } from "@/src/entities/models/users/update-user.model"; import { ICredentialsInviteUserSchema } from "@/src/entities/models/users/invite-user.model"; import { IBanUserSchema, ICredentialsBanUserSchema } from "@/src/entities/models/users/ban-user.model"; @@ -49,3 +49,10 @@ export const useUnbanUserMutation = () => { mutationFn: (credential: ICredentialsUnbanUserSchema) => unbanUser(credential), }) } + +export const useUploadAvatarMutation = () => { + return useMutation({ + mutationKey: ["user", "upload-avatar"], + mutationFn: (args: { userId: string; file: File }) => uploadAvatar(args.userId, args.file), + }) +} \ No newline at end of file diff --git a/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts b/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts index be05ac3..3fa5660 100644 --- a/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts +++ b/sigap-website/app/(pages)/(admin)/dashboard/user-management/action.ts @@ -498,3 +498,52 @@ export async function deleteUser(id: string) { } ); } + + +export async function uploadAvatar(id: string, file: File) { + const instrumentationService = getInjection('IInstrumentationService'); + return await instrumentationService.instrumentServerAction( + 'uploadAvatar', + { recordResponse: true }, + async () => { + try { + const uploadAvatarController = getInjection('IUploadAvatarController'); + const newAvatar = await uploadAvatarController(id, file); + + return { success: true, newAvatar }; + + } catch (err) { + if (err instanceof InputParseError) { + // return { + // error: err.message, + // }; + + throw new InputParseError(err.message); + } + + if (err instanceof UnauthenticatedError) { + // return { + // error: 'Must be logged in to create a user.', + // }; + throw new UnauthenticatedError('Must be logged in to upload an avatar.'); + } + + if (err instanceof AuthenticationError) { + // return { + // error: 'User not found.', + // }; + + throw new AuthenticationError('There was an error with the credentials. Please try again or contact support.'); + } + + const crashReporterService = getInjection('ICrashReporterService'); + crashReporterService.report(err); + // return { + // error: + // 'An error happened. The developers have been notified. Please try again later.', + // }; + throw new Error('An error happened. The developers have been notified. Please try again later.'); + } + } + ); +} diff --git a/sigap-website/app/(pages)/layout.tsx b/sigap-website/app/(pages)/layout.tsx index 93a0fef..e11864a 100644 --- a/sigap-website/app/(pages)/layout.tsx +++ b/sigap-website/app/(pages)/layout.tsx @@ -54,7 +54,7 @@ export default function RootLayout({ */}
{children} - +
{/*