diff --git a/src/hooks/setting/useSettingNotifications.ts b/src/hooks/setting/useSettingNotifications.ts new file mode 100644 index 00000000..f532a6ec --- /dev/null +++ b/src/hooks/setting/useSettingNotifications.ts @@ -0,0 +1,376 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; + +import type { IApiErrorResponse } from "@/types/common/common"; +import type { IUpdateOrgNotificationSettingsRequest } from "@/types/setting/notification"; +import { + DEFAULT_CHANNEL, + DEFAULT_ORG_NOTIF, + DEFAULT_WORKSPACE_NOTIF, + type IChannelNotificationSettings, + type IOrgNotificationSettings, + type IWorkspaceNotificationSettings, +} from "@/types/setting/settingPage"; + +import { useCoreQuery } from "@/hooks/customQuery"; +import { useMyNotificationSettings } from "@/hooks/setting/useMyNotificationSettings"; +import { useUpdateAlertsNotificationSettings } from "@/hooks/setting/useUpdateAlertsNotificationSettings"; +import { useUpdateChannelNotificationSettings } from "@/hooks/setting/useUpdateChannelNotificationSettings"; +import { useUpdateMasterNotificationSettings } from "@/hooks/setting/useUpdateMasterNotificationSetting"; +import { useUpdateOrgNotificationSettings } from "@/hooks/setting/useUpdateOrgNotificationSettings"; + +import { getMyWorkspaces } from "@/api/workspace/org"; +import { QUERY_KEYS } from "@/lib/queryKeys"; +import useWorkspaceStore from "@/store/useWorkspaceStore"; + +export default function useSettingNotifications() { + const [savedChannel, setSavedChannel] = + useState(DEFAULT_CHANNEL); + const [draftChannel, setDraftChannel] = + useState(DEFAULT_CHANNEL); + const [savedWorkspaceNotif, setSavedWorkspaceNotif] = + useState(DEFAULT_WORKSPACE_NOTIF); + const [draftWorkspaceNotif, setDraftWorkspaceNotif] = + useState(DEFAULT_WORKSPACE_NOTIF); + const [savedOrgNotif, setSavedOrgNotif] = + useState(DEFAULT_ORG_NOTIF); + const [draftOrgNotif, setDraftOrgNotif] = + useState(DEFAULT_ORG_NOTIF); + + const [slackWebhookUrl, setSlackWebhookUrl] = useState(""); + const [slackWebhookError, setSlackWebhookError] = useState(""); + const [discordWebhookUrl, setDiscordWebhookUrl] = useState(""); + const [discordWebhookError, setDiscordWebhookError] = useState(""); + const [pendingOrgAction, setPendingOrgAction] = useState< + "slack" | "discord" | null + >(null); + + const selectedOrgId = useWorkspaceStore((s) => s.selectedOrgId); + + const myRole = useWorkspaceStore((s) => s.myRole); + const isAdmin = myRole === "ADMIN"; + + const { data: workspaces, isLoading: isWorkspacesLoading } = useCoreQuery( + QUERY_KEYS.workspace.list(), + getMyWorkspaces, + ); + + const { + data: notificationSettings, + isLoading: isNotificationLoading, + isRefetching: isNotificationRefetching, + isError: isNotificationError, + error: notificationError, + errorUpdatedAt: notificationErrorUpdatedAt, + refetch: refetchNotificationSettings, + } = useMyNotificationSettings(); + + const lastNotifiedNotificationErrorAtRef = useRef(0); + const prevOrgIdRef = useRef(selectedOrgId); + + const updateChannels = useUpdateChannelNotificationSettings(); + const updateAlerts = useUpdateAlertsNotificationSettings(); + const updateOrg = useUpdateOrgNotificationSettings(); + const updateMaster = useUpdateMasterNotificationSettings(); + + const currentWorkspaceName = useMemo(() => { + if (selectedOrgId === null) return null; + return workspaces?.find((w) => w.orgId === selectedOrgId)?.name ?? null; + }, [selectedOrgId, workspaces]); + + const workspaceNotifiDisabled = + selectedOrgId == null || (!isWorkspacesLoading && !currentWorkspaceName); + + const isNotificationSectionLoading = + selectedOrgId !== null && + (isNotificationLoading || isNotificationRefetching); + + const buildOrgBody = ( + overrides: Partial = {}, + ): IUpdateOrgNotificationSettingsRequest => ({ + isSlackEnabled: savedOrgNotif.slackEnabled, + slackWebhookUrl: "", + disconnectSlack: false, + isDiscordEnabled: savedOrgNotif.discordEnabled, + discordWebhookUrl: "", + disconnectDiscord: false, + alertClicks: savedWorkspaceNotif.clickAlarm ?? false, + alertReport: savedWorkspaceNotif.weeklyReport ?? false, + ...overrides, + }); + + const handleConnectSlack = async () => { + const url = slackWebhookUrl.trim(); + if (!url) { + setSlackWebhookError("Webhook URL을 입력해주세요"); + return; + } + + let parsed: URL; + try { + parsed = new URL(url); + } catch { + setSlackWebhookError("올바른 URL 형식으로 입력해주세요"); + return; + } + if (parsed.protocol !== "https:" || parsed.hostname !== "hooks.slack.com") { + setSlackWebhookError("슬랙 Webhook URL을 입력해주세요"); + return; + } + + setPendingOrgAction("slack"); + try { + await updateOrg.mutateAsync( + buildOrgBody({ + isSlackEnabled: true, + slackWebhookUrl: url, + disconnectSlack: false, + }), + ); + toast.success("슬랙이 연동되었습니다"); + setSlackWebhookUrl(""); + setSlackWebhookError(""); + } catch (e) { + const error = e as IApiErrorResponse; + toast.error(error.message ?? "슬랙 연동에 실패했습니다"); + } finally { + setPendingOrgAction(null); + } + }; + + const handleDisconnectSlack = async () => { + setPendingOrgAction("slack"); + try { + await updateOrg.mutateAsync( + buildOrgBody({ + isSlackEnabled: false, + slackWebhookUrl: "", + disconnectSlack: true, + }), + ); + toast.success("슬랙 연동이 해제되었습니다"); + } catch (e) { + const error = e as IApiErrorResponse; + toast.error(error.message ?? "슬랙 연동 해제에 실패했습니다"); + } finally { + setPendingOrgAction(null); + } + }; + + const handleConnectDiscord = async () => { + const url = discordWebhookUrl.trim(); + if (!url) { + setDiscordWebhookError("Webhook URL을 입력해주세요"); + return; + } + if (!url.startsWith("https://")) { + setDiscordWebhookError("올바른 URL 형식으로 입력해주세요"); + return; + } + + setPendingOrgAction("discord"); + try { + await updateOrg.mutateAsync( + buildOrgBody({ + isDiscordEnabled: true, + discordWebhookUrl: url, + disconnectDiscord: false, + }), + ); + toast.success("디스코드가 연동되었습니다"); + setDiscordWebhookUrl(""); + setDiscordWebhookError(""); + } catch (e) { + const error = e as IApiErrorResponse; + toast.error(error.message ?? "디스코드 연동에 실패했습니다"); + } finally { + setPendingOrgAction(null); + } + }; + + const handleDisconnectDiscord = async () => { + setPendingOrgAction("discord"); + try { + await updateOrg.mutateAsync( + buildOrgBody({ + isDiscordEnabled: false, + discordWebhookUrl: "", + disconnectDiscord: true, + }), + ); + toast.success("디스코드 연동이 해제되었습니다"); + } catch (e) { + const error = e as IApiErrorResponse; + toast.error(error.message ?? "디스코드 연동 해제에 실패했습니다"); + } finally { + setPendingOrgAction(null); + } + }; + + const handleMasterEnableChange = (value: boolean) => { + if (!value) { + setDraftOrgNotif((prev) => ({ + ...prev, + masterEnabled: false, + slackEnabled: false, + discordEnabled: false, + })); + setDraftChannel({ browserPush: false, emailNotif: false }); + setDraftWorkspaceNotif({ + clickAlarm: false, + weeklyReport: false, + }); + return; + } + setDraftOrgNotif((prev) => ({ + ...prev, + masterEnabled: true, + slackEnabled: prev.slackConnected && savedOrgNotif.slackEnabled, + discordEnabled: prev.discordConnected && savedOrgNotif.discordEnabled, + })); + setDraftChannel(savedChannel); + setDraftWorkspaceNotif(savedWorkspaceNotif); + }; + + const hasChannelChanges = useMemo(() => { + return ( + savedChannel.browserPush !== draftChannel.browserPush || + savedChannel.emailNotif !== draftChannel.emailNotif + ); + }, [savedChannel, draftChannel]); + + const hasWorkspaceNotifChanges = useMemo(() => { + return ( + savedWorkspaceNotif.clickAlarm !== draftWorkspaceNotif.clickAlarm || + savedWorkspaceNotif.weeklyReport !== draftWorkspaceNotif.weeklyReport + ); + }, [savedWorkspaceNotif, draftWorkspaceNotif]); + + const hasMasterChanges = + savedOrgNotif.masterEnabled !== draftOrgNotif.masterEnabled; + + const hasOrgToggleChanges = + savedOrgNotif.slackEnabled !== draftOrgNotif.slackEnabled || + savedOrgNotif.discordEnabled !== draftOrgNotif.discordEnabled; + + const hasNotificationChanges = + hasChannelChanges || + hasWorkspaceNotifChanges || + hasMasterChanges || + hasOrgToggleChanges; + + useEffect(() => { + //워크스페이스 미선택시에만 기본값 + if (selectedOrgId === null) { + setSavedChannel(DEFAULT_CHANNEL); + setDraftChannel(DEFAULT_CHANNEL); + setSavedWorkspaceNotif(DEFAULT_WORKSPACE_NOTIF); + setDraftWorkspaceNotif(DEFAULT_WORKSPACE_NOTIF); + setSavedOrgNotif(DEFAULT_ORG_NOTIF); + setDraftOrgNotif(DEFAULT_ORG_NOTIF); + setSlackWebhookUrl(""); + setSlackWebhookError(""); + setDiscordWebhookUrl(""); + setDiscordWebhookError(""); + prevOrgIdRef.current = null; + return; + } + + if (!notificationSettings) return; //로딩,에러면 손대지 않음 + + const masterOn = notificationSettings.isMasterEnabled; + const nextChannel = { + browserPush: masterOn && notificationSettings.isBrowserPushEnabled, + emailNotif: masterOn && notificationSettings.isEmailEnabled, + }; + const nextWorkspace = { + clickAlarm: masterOn && notificationSettings.alertClicks, + weeklyReport: masterOn && notificationSettings.alertReport, + }; + const nextOrg = { + masterEnabled: masterOn, + slackEnabled: masterOn && notificationSettings.isSlackEnabled, + slackConnected: notificationSettings.isSlackConnected, + discordEnabled: masterOn && notificationSettings.isDiscordEnabled, + discordConnected: notificationSettings.isDiscordConnected, + }; + + setSavedChannel(nextChannel); + setSavedWorkspaceNotif(nextWorkspace); + setSavedOrgNotif(nextOrg); + + const orgChanged = prevOrgIdRef.current !== selectedOrgId; + if (orgChanged || !hasNotificationChanges) { + setDraftChannel(nextChannel); + setDraftWorkspaceNotif(nextWorkspace); + setDraftOrgNotif(nextOrg); + } + + prevOrgIdRef.current = selectedOrgId; + }, [selectedOrgId, notificationSettings, hasNotificationChanges]); + + useEffect(() => { + if (!isNotificationError || notificationErrorUpdatedAt === 0) return; + if ( + lastNotifiedNotificationErrorAtRef.current === notificationErrorUpdatedAt + ) + return; + + lastNotifiedNotificationErrorAtRef.current = notificationErrorUpdatedAt; + toast.error( + notificationError?.message ?? "알림 설정을 불러오는데 실패했습니다", + ); + }, [isNotificationError, notificationError, notificationErrorUpdatedAt]); + + return { + isAdmin, + selectedOrgId, + currentWorkspaceName, + workspaceNotifiDisabled, + isNotificationSectionLoading, + isNotificationError, + notificationError, + refetchNotificationSettings, + + draftChannel, + setDraftChannel, + draftWorkspaceNotif, + setDraftWorkspaceNotif, + draftOrgNotif, + setDraftOrgNotif, + savedChannel, + savedWorkspaceNotif, + savedOrgNotif, + setSavedChannel, + setSavedWorkspaceNotif, + setSavedOrgNotif, + + slackWebhookUrl, + slackWebhookError, + discordWebhookUrl, + discordWebhookError, + pendingOrgAction, + setSlackWebhookUrl, + setSlackWebhookError, + setDiscordWebhookUrl, + setDiscordWebhookError, + + handleMasterEnableChange, + handleConnectSlack, + handleDisconnectSlack, + handleConnectDiscord, + handleDisconnectDiscord, + + hasChannelChanges, + hasWorkspaceNotifChanges, + hasMasterChanges, + hasOrgToggleChanges, + hasNotificationChanges, + + updateChannels, + updateAlerts, + updateOrg, + updateMaster, + buildOrgBody, + }; +} diff --git a/src/hooks/setting/useSettingPassword.ts b/src/hooks/setting/useSettingPassword.ts new file mode 100644 index 00000000..ed54b47f --- /dev/null +++ b/src/hooks/setting/useSettingPassword.ts @@ -0,0 +1,61 @@ +import { useState } from "react"; + +const EMPTY_ERRORS = { + currentPassword: "", + newPassword: "", + confirmNewPassword: "", +}; +export default function useSettingPassword() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmNewPassword, setConfirmNewPassword] = useState(""); + const [passwordErrors, setPasswordErrors] = useState(EMPTY_ERRORS); + + const hasPasswordChanges = + !!currentPassword || !!newPassword || !!confirmNewPassword; + + const validatePassword = () => { + const errors = { ...EMPTY_ERRORS }; + + if (!currentPassword) { + errors.currentPassword = "현재 비밀번호를 입력해주세요"; + } + if ( + !newPassword.match(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,16}$/) + ) { + errors.newPassword = + "영문, 숫자, 특수문자를 포함하여 8~16자로 입력해주세요"; + } + if (currentPassword && newPassword && currentPassword === newPassword) { + errors.newPassword = "현재 비밀번호와 다른 비밀번호를 입력해주세요"; + } + if (newPassword !== confirmNewPassword) { + errors.confirmNewPassword = "새 비밀번호가 일치하지 않습니다"; + } + setPasswordErrors(errors); + return !Object.values(errors).some(Boolean); + }; + + const clearPasswordErrors = () => { + setPasswordErrors(EMPTY_ERRORS); + }; + const clearPassword = () => { + setCurrentPassword(""); + setNewPassword(""); + setConfirmNewPassword(""); + }; + + return { + currentPassword, + setCurrentPassword, + newPassword, + setNewPassword, + confirmNewPassword, + setConfirmNewPassword, + passwordErrors, + hasPasswordChanges, + validatePassword, + clearPasswordErrors, + clearPassword, + }; +} diff --git a/src/hooks/setting/useSettingProfile.ts b/src/hooks/setting/useSettingProfile.ts new file mode 100644 index 00000000..cd84a937 --- /dev/null +++ b/src/hooks/setting/useSettingProfile.ts @@ -0,0 +1,108 @@ +import { type ChangeEvent, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; + +import type { IDraftProfile, ISavedProfile } from "@/types/setting/settingPage"; + +import { useImageUploader } from "@/hooks/common/useImageUploader"; + +import { getMyInfo } from "@/api/auth/auth"; + +export default function useSettingProfile() { + const [savedProfile, setSavedProfile] = useState({ + name: "", + profileImageUrl: null, + }); + const [draftProfile, setDraftProfile] = useState({ + name: "", + email: "", + phoneNumber: "", + }); + + const [isImageDeleted, setIsImageDeleted] = useState(false); + const [isLoading, setIsLoading] = useState(false); + + const { + fileRef, + file, + preview, + setPreview, + openFilePicker, + onPickFile, + resetImage, + } = useImageUploader(); + + const hasProfileChanges = useMemo(() => { + return ( + savedProfile.name !== draftProfile.name || + savedProfile.profileImageUrl !== preview || + !!file + ); + }, [savedProfile, draftProfile, preview, file]); + + const setName = (name: string) => + setDraftProfile((prev) => ({ ...prev, name })); + + const resetProfileImage = () => { + resetImage(); + setIsImageDeleted(true); + }; + + const handlePickFile = (e: ChangeEvent) => { + setIsImageDeleted(false); + onPickFile(e); + }; + + const applyAccountSaveSuccess = (res: ISavedProfile) => { + setSavedProfile({ + name: res.name, + profileImageUrl: res.profileImageUrl, + }); + setDraftProfile((prev) => ({ + ...prev, + name: res.name, + })); + setPreview(res.profileImageUrl); + setIsImageDeleted(false); + }; + + useEffect(() => { + const fetchMyInfo = async () => { + try { + setIsLoading(true); + const res = await getMyInfo(); + + setSavedProfile({ + name: res.data.name, + profileImageUrl: res.data.profileImageUrl, + }); + setDraftProfile({ + name: res.data.name, + email: res.data.email, + phoneNumber: res.data.phoneNumber, + }); + setPreview(res.data.profileImageUrl); + } catch (error) { + toast.error("회원 정보를 불러오는데 실패했습니다"); + console.error(error); + } finally { + setIsLoading(false); + } + }; + fetchMyInfo(); + }, [setPreview]); + + return { + isLoading, + draftProfile, + setName, + fileRef, + file, + preview, + openFilePicker, + handlePickFile, + resetProfileImage, + isImageDeleted, + hasProfileChanges, + applyAccountSaveSuccess, + }; +} diff --git a/src/hooks/setting/useSettingSave.ts b/src/hooks/setting/useSettingSave.ts new file mode 100644 index 00000000..a1fa0ca3 --- /dev/null +++ b/src/hooks/setting/useSettingSave.ts @@ -0,0 +1,186 @@ +import { useRef, useState } from "react"; +import { toast } from "sonner"; + +import type { IApiErrorResponse } from "@/types/common/common"; + +import type useSettingNotifications from "@/hooks/setting/useSettingNotifications"; +import type useSettingPassword from "@/hooks/setting/useSettingPassword"; +import type useSettingProfile from "@/hooks/setting/useSettingProfile"; + +import { updateMyInfo } from "@/api/auth/auth"; + +type TProfile = ReturnType; +type TPassword = ReturnType; +type TNotifications = ReturnType; + +interface IUseSettingSaveParams { + profile: TProfile; + password: TPassword; + notifications: TNotifications; +} + +export default function useSettingSave({ + profile, + password, + notifications, +}: IUseSettingSaveParams) { + const [isSaving, setIsSaving] = useState(false); + + const isSavingRef = useRef(false); + + const hasAccountChanges = + profile.hasProfileChanges || password.hasPasswordChanges; + + const hasChanges = + hasAccountChanges || + (!notifications.isNotificationError && + notifications.hasNotificationChanges); + + const handleSave = async () => { + if (isSavingRef.current) return; + + if (password.hasPasswordChanges) { + if (!password.validatePassword()) return; + } else { + password.clearPasswordErrors(); + } + + isSavingRef.current = true; + setIsSaving(true); + + let savedAccount = false; + let savedAnyNotification = false; + + try { + if (hasAccountChanges) { + try { + const res = await updateMyInfo({ + name: profile.draftProfile.name, + oldPassword: password.currentPassword || undefined, + newPassword: password.newPassword || undefined, + isImageDeleted: profile.isImageDeleted, + imageFile: profile.file, + }); + profile.applyAccountSaveSuccess(res.data); + password.clearPassword(); + savedAccount = true; + } catch (e) { + const error = e as IApiErrorResponse; + toast.error(error.message ?? "회원정보수정에 실패했습니다"); + return; + } + } + + const canSaveNotification = !notifications.isNotificationError; + + const { selectedOrgId, isAdmin } = notifications; + const shouldSaveChannel = + canSaveNotification && + notifications.hasChannelChanges && + selectedOrgId != null; + const shouldSaveAlerts = + canSaveNotification && + notifications.hasWorkspaceNotifChanges && + selectedOrgId != null; + + const shouldSaveMaster = + canSaveNotification && + notifications.hasMasterChanges && + selectedOrgId != null; + const shouldSaveOrg = + canSaveNotification && + notifications.hasOrgToggleChanges && + selectedOrgId != null && + isAdmin; + + if ( + shouldSaveChannel || + shouldSaveAlerts || + shouldSaveMaster || + shouldSaveOrg + ) { + const savedSteps: string[] = []; + try { + if (shouldSaveChannel) { + await notifications.updateChannels.mutateAsync({ + isBrowserPushEnabled: notifications.draftChannel.browserPush, + isEmailEnabled: notifications.draftChannel.emailNotif, + }); + notifications.setSavedChannel(notifications.draftChannel); + savedSteps.push("알림 채널"); + } + if (shouldSaveAlerts) { + await notifications.updateAlerts.mutateAsync({ + alertClicks: notifications.draftWorkspaceNotif.clickAlarm, + alertReport: notifications.draftWorkspaceNotif.weeklyReport, + }); + notifications.setSavedWorkspaceNotif( + notifications.draftWorkspaceNotif, + ); + savedSteps.push("워크스페이스 알림"); + } + if (shouldSaveMaster) { + await notifications.updateMaster.mutateAsync({ + isMasterEnabled: notifications.draftOrgNotif.masterEnabled, + }); + notifications.setSavedOrgNotif((prev) => ({ + ...prev, + masterEnabled: notifications.draftOrgNotif.masterEnabled, + })); + savedSteps.push("마스터 알림"); + } + if (shouldSaveOrg) { + await notifications.updateOrg.mutateAsync( + notifications.buildOrgBody({ + isSlackEnabled: notifications.draftOrgNotif.slackEnabled, + slackWebhookUrl: "", + disconnectSlack: false, + isDiscordEnabled: notifications.draftOrgNotif.discordEnabled, + discordWebhookUrl: "", + disconnectDiscord: false, + }), + ); + notifications.setSavedOrgNotif((prev) => ({ + ...prev, + slackEnabled: notifications.draftOrgNotif.slackEnabled, + discordEnabled: notifications.draftOrgNotif.discordEnabled, + })); + savedSteps.push("슬랙/디스코드 설정"); + } + savedAnyNotification = true; + } catch (e) { + const error = e as IApiErrorResponse; + if (savedAccount) { + toast.success("회원정보가 수정되었습니다"); + } + const reason = error.message ?? "알림 저장에 실패했습니다."; + toast.error( + savedSteps.length > 0 + ? `${savedSteps.join(", ")}은(는) 저장됐지만, 이후 단계에서 실패했습니다: ${reason}` + : reason, + ); + return; + } + } + + if (savedAccount || savedAnyNotification) { + toast.success( + savedAccount && savedAnyNotification + ? "설정이 저장되었습니다" + : savedAnyNotification + ? "알림 설정이 저장되었습니다" + : "회원정보가 수정되었습니다", + ); + } + } finally { + isSavingRef.current = false; + setIsSaving(false); + } + }; + + return { + isSaving, + hasChanges, + handleSave, + }; +} diff --git a/src/pages/setting/Setting.tsx b/src/pages/setting/Setting.tsx index 4f8b1a94..b45fd4bc 100644 --- a/src/pages/setting/Setting.tsx +++ b/src/pages/setting/Setting.tsx @@ -1,17 +1,10 @@ -import { type ChangeEvent, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; - -import type { IApiErrorResponse } from "@/types/common/common"; -import type { IUpdateOrgNotificationSettingsRequest } from "@/types/setting/notification"; +import { useState } from "react"; import { useDeleteMyAccount } from "@/hooks/auth/useDeleteMyAccount"; -import { useImageUploader } from "@/hooks/common/useImageUploader"; -import { useCoreQuery } from "@/hooks/customQuery"; -import { useMyNotificationSettings } from "@/hooks/setting/useMyNotificationSettings"; -import { useUpdateAlertsNotificationSettings } from "@/hooks/setting/useUpdateAlertsNotificationSettings"; -import { useUpdateChannelNotificationSettings } from "@/hooks/setting/useUpdateChannelNotificationSettings"; -import { useUpdateMasterNotificationSettings } from "@/hooks/setting/useUpdateMasterNotificationSetting"; -import { useUpdateOrgNotificationSettings } from "@/hooks/setting/useUpdateOrgNotificationSettings"; +import useSettingNotification from "@/hooks/setting/useSettingNotifications"; +import useSettingPassword from "@/hooks/setting/useSettingPassword"; +import useSettingProfile from "@/hooks/setting/useSettingProfile"; +import useSettingSave from "@/hooks/setting/useSettingSave"; import Button from "@/components/common/button/Button"; import AreaErrorFallback from "@/components/common/error/AreaErrorFallback"; @@ -23,574 +16,70 @@ import ProfileSection from "@/components/setting/ProfileSection"; import ProfileSectionSkeleton from "@/components/setting/ProfileSectionSkeleton"; import WithdrawConfirmModal from "@/components/setting/WithdrawConfirmModal"; -import { getMyInfo, updateMyInfo } from "@/api/auth/auth"; -import { getMyWorkspaces } from "@/api/workspace/org"; -import { QUERY_KEYS } from "@/lib/queryKeys"; -import useWorkspaceStore from "@/store/useWorkspaceStore"; - -interface IChannelNotificationSettings { - browserPush: boolean; - emailNotif: boolean; -} - -interface IWorkspaceNotificationSettings { - clickAlarm: boolean; - weeklyReport: boolean; -} - -interface IOrgNotificationSettings { - masterEnabled: boolean; - slackEnabled: boolean; - slackConnected: boolean; - discordEnabled: boolean; - discordConnected: boolean; -} - -const DEFAULT_CHANNEL: IChannelNotificationSettings = { - browserPush: false, - emailNotif: false, -}; - -const DEFAULT_WORKSPACE_NOTIF: IWorkspaceNotificationSettings = { - clickAlarm: false, - weeklyReport: false, -}; - -const DEFAULT_ORG_NOTIF: IOrgNotificationSettings = { - masterEnabled: true, - slackEnabled: false, - slackConnected: false, - discordEnabled: false, - discordConnected: false, -}; - -interface IDraftProfile { - name: string; - email: string; - phoneNumber: string; -} - -interface ISavedProfile { - name: string; - profileImageUrl: string | null; -} - export default function Setting() { - const [savedProfile, setSavedProfile] = useState({ - name: "", - profileImageUrl: null, - }); - const [draftProfile, setDraftProfile] = useState({ - name: "", - email: "", - phoneNumber: "", - }); - const [currentPassword, setCurrentPassword] = useState(""); - const [newPassword, setNewPassword] = useState(""); - const [confirmNewPassword, setConfirmNewPassword] = useState(""); - const [savedChannel, setSavedChannel] = - useState(DEFAULT_CHANNEL); - const [draftChannel, setDraftChannel] = - useState(DEFAULT_CHANNEL); - const [savedWorkspaceNotif, setSavedWorkspaceNotif] = - useState(DEFAULT_WORKSPACE_NOTIF); - const [draftWorkspaceNotif, setDraftWorkspaceNotif] = - useState(DEFAULT_WORKSPACE_NOTIF); - const [savedOrgNotif, setSavedOrgNotif] = - useState(DEFAULT_ORG_NOTIF); - const [draftOrgNotif, setDraftOrgNotif] = - useState(DEFAULT_ORG_NOTIF); - - const [slackWebhookUrl, setSlackWebhookUrl] = useState(""); - const [slackWebhookError, setSlackWebhookError] = useState(""); - const [discordWebhookUrl, setDiscordWebhookUrl] = useState(""); - const [discordWebhookError, setDiscordWebhookError] = useState(""); - const [pendingOrgAction, setPendingOrgAction] = useState< - "slack" | "discord" | null - >(null); - - const [isImageDeleted, setIsImageDeleted] = useState(false); - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); - const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); + const profile = useSettingProfile(); + const password = useSettingPassword(); + const notifications = useSettingNotification(); + const { isSaving, hasChanges, handleSave } = useSettingSave({ + profile, + password, + notifications, + }); + const { mutate: deleteMyAccountMutate, isPending: isWithdrawPending } = + useDeleteMyAccount(); + const { + isLoading, + draftProfile, + setName, fileRef, - file, preview, - setPreview, openFilePicker, - onPickFile, - resetImage, - } = useImageUploader(); - - const selectedOrgId = useWorkspaceStore((s) => s.selectedOrgId); - const { data: workspaces, isLoading: isWorkspacesLoading } = useCoreQuery( - QUERY_KEYS.workspace.list(), - getMyWorkspaces, - ); - - const myRole = useWorkspaceStore((s) => s.myRole); - const isAdmin = myRole === "ADMIN"; + handlePickFile, + resetProfileImage, + } = profile; const { - data: notificationSettings, - isLoading: isNotificationLoading, - isRefetching: isNotificationRefetching, - isError: isNotificationError, - error: notificationError, - errorUpdatedAt: notificationErrorUpdatedAt, - refetch: refetchNotificationSettings, - } = useMyNotificationSettings(); - - const lastNotifiedNotificationErrorAtRef = useRef(0); - const isSavingRef = useRef(false); - - const currentWorkspaceName = useMemo(() => { - if (selectedOrgId === null) return null; - return workspaces?.find((w) => w.orgId === selectedOrgId)?.name ?? null; - }, [selectedOrgId, workspaces]); - - const workspaceNotifiDisabled = - selectedOrgId == null || (!isWorkspacesLoading && !currentWorkspaceName); - - const isNotificationSectionLoading = - selectedOrgId !== null && - (isNotificationLoading || isNotificationRefetching); - - const updateChannels = useUpdateChannelNotificationSettings(); - const updateAlerts = useUpdateAlertsNotificationSettings(); - const updateOrg = useUpdateOrgNotificationSettings(); - const updateMaster = useUpdateMasterNotificationSettings(); - - const { mutate: deleteMyAccountMutate, isPending: isWithdrawPending } = - useDeleteMyAccount(); - - const buildOrgBody = ( - overrides: Partial = {}, - ): IUpdateOrgNotificationSettingsRequest => ({ - isSlackEnabled: draftOrgNotif.slackEnabled, - slackWebhookUrl: "", - disconnectSlack: false, - isDiscordEnabled: draftOrgNotif.discordEnabled, - discordWebhookUrl: "", - disconnectDiscord: false, - alertClicks: draftWorkspaceNotif.clickAlarm ?? false, - alertReport: draftWorkspaceNotif.weeklyReport ?? false, - ...overrides, - }); - - const handlePickFile = (e: ChangeEvent) => { - setIsImageDeleted(false); - onPickFile(e); - }; + currentPassword, + setCurrentPassword, + newPassword, + setNewPassword, + confirmNewPassword, + setConfirmNewPassword, + passwordErrors, + } = password; - const handleConnectSlack = async () => { - const url = slackWebhookUrl.trim(); - if (!url) { - setSlackWebhookError("Webhook URL을 입력해주세요"); - return; - } - if (!url.startsWith("https://")) { - setSlackWebhookError("올바른 URL 형식으로 입력해주세요"); - return; - } - - setPendingOrgAction("slack"); - try { - await updateOrg.mutateAsync( - buildOrgBody({ - isSlackEnabled: true, - slackWebhookUrl: url, - disconnectSlack: false, - }), - ); - toast.success("슬랙이 연동되었습니다"); - setSlackWebhookUrl(""); - setSlackWebhookError(""); - } catch (e) { - const error = e as IApiErrorResponse; - toast.error(error.message ?? "슬랙 연동에 실패했습니다"); - } finally { - setPendingOrgAction(null); - } - }; - - const handleDisconnectSlack = async () => { - setPendingOrgAction("slack"); - try { - await updateOrg.mutateAsync( - buildOrgBody({ - isSlackEnabled: false, - slackWebhookUrl: "", - disconnectSlack: true, - }), - ); - toast.success("슬랙 연동이 해제되었습니다"); - } catch (e) { - const error = e as IApiErrorResponse; - toast.error(error.message ?? "슬랙 연동 해제에 실패했습니다"); - } finally { - setPendingOrgAction(null); - } - }; - - const handleConnectDiscord = async () => { - const url = discordWebhookUrl.trim(); - if (!url) { - setDiscordWebhookError("Webhook URL을 입력해주세요"); - return; - } - if (!url.startsWith("https://")) { - setDiscordWebhookError("올바른 URL 형식으로 입력해주세요"); - return; - } - - setPendingOrgAction("discord"); - try { - await updateOrg.mutateAsync( - buildOrgBody({ - isDiscordEnabled: true, - discordWebhookUrl: url, - disconnectDiscord: false, - }), - ); - toast.success("디스코드가 연동되었습니다"); - setDiscordWebhookUrl(""); - setDiscordWebhookError(""); - } catch (e) { - const error = e as IApiErrorResponse; - toast.error(error.message ?? "디스코드 연동에 실패했습니다"); - } finally { - setPendingOrgAction(null); - } - }; - - const handleDisconnectDiscord = async () => { - setPendingOrgAction("discord"); - try { - await updateOrg.mutateAsync( - buildOrgBody({ - isDiscordEnabled: false, - discordWebhookUrl: "", - disconnectDiscord: true, - }), - ); - toast.success("디스코드 연동이 해제되었습니다"); - } catch (e) { - const error = e as IApiErrorResponse; - toast.error(error.message ?? "디스코드 연동 해제에 실패했습니다"); - } finally { - setPendingOrgAction(null); - } - }; - - const hasPasswordChanges = - !!currentPassword || !!newPassword || !!confirmNewPassword; - - const hasProfileChanges = useMemo(() => { - return ( - savedProfile.name !== draftProfile.name || - savedProfile.profileImageUrl !== preview || - !!file - ); - }, [savedProfile, draftProfile, preview, file]); - - const hasChannelChanges = useMemo(() => { - return ( - savedChannel.browserPush !== draftChannel.browserPush || - savedChannel.emailNotif !== draftChannel.emailNotif - ); - }, [savedChannel, draftChannel]); - - const hasWorkspaceNotifChanges = useMemo(() => { - return ( - savedWorkspaceNotif.clickAlarm !== draftWorkspaceNotif.clickAlarm || - savedWorkspaceNotif.weeklyReport !== draftWorkspaceNotif.weeklyReport - ); - }, [savedWorkspaceNotif, draftWorkspaceNotif]); - - const hasMasterChanges = - savedOrgNotif.masterEnabled !== draftOrgNotif.masterEnabled; - - const hasOrgToggleChanges = - savedOrgNotif.slackEnabled !== draftOrgNotif.slackEnabled || - savedOrgNotif.discordEnabled !== draftOrgNotif.discordEnabled; - - const hasAccountChanges = hasProfileChanges || hasPasswordChanges; - - const hasNotificationChanges = - hasChannelChanges || - hasWorkspaceNotifChanges || - hasMasterChanges || - hasOrgToggleChanges; - - const hasChanges = - hasAccountChanges || (!isNotificationError && hasNotificationChanges); - - const [passwordErrors, setPasswordErrors] = useState({ - currentPassword: "", - newPassword: "", - confirmNewPassword: "", - }); - - const validatePassword = () => { - const errors = { - currentPassword: "", - newPassword: "", - confirmNewPassword: "", - }; - if (!currentPassword) { - errors.currentPassword = "현재 비밀번호를 입력해주세요"; - } - if ( - !newPassword.match(/^(?=.*[A-Za-z])(?=.*\d)(?=.*[^A-Za-z\d]).{8,16}$/) - ) { - errors.newPassword = - "영문, 숫자, 특수문자를 포함하여 8~16자로 입력해주세요"; - } - if (currentPassword && newPassword && currentPassword === newPassword) { - errors.newPassword = "현재 비밀번호와 다른 비밀번호를 입력해주세요"; - } - if (newPassword !== confirmNewPassword) { - errors.confirmNewPassword = "새 비밀번호가 일치하지 않습니다"; - } - return errors; - }; - - const handleSave = async () => { - if (isSavingRef.current) return; - - if (hasPasswordChanges) { - const errors = validatePassword(); - setPasswordErrors(errors); - if (Object.values(errors).some(Boolean)) return; - } else { - setPasswordErrors({ - currentPassword: "", - newPassword: "", - confirmNewPassword: "", - }); - } - - isSavingRef.current = true; - setIsSaving(true); - - let savedAccount = false; - let savedAnyNotification = false; - - try { - if (hasAccountChanges) { - try { - const res = await updateMyInfo({ - name: draftProfile.name, - oldPassword: currentPassword || undefined, - newPassword: newPassword || undefined, - isImageDeleted, - imageFile: file, - }); - setSavedProfile({ - name: res.data.name, - profileImageUrl: res.data.profileImageUrl, - }); - setDraftProfile((prev) => ({ - ...prev, - name: res.data.name, - })); - setPreview(res.data.profileImageUrl); - setCurrentPassword(""); - setNewPassword(""); - setConfirmNewPassword(""); - setIsImageDeleted(false); - savedAccount = true; - } catch (e) { - const error = e as IApiErrorResponse; - toast.error(error.message ?? "회원정보수정에 실패했습니다"); - return; - } - } - - const canSaveNotification = !isNotificationError; - const shouldSaveChannel = - canSaveNotification && hasChannelChanges && selectedOrgId != null; - const shouldSaveAlerts = - canSaveNotification && - hasWorkspaceNotifChanges && - selectedOrgId != null; - - const shouldSaveMaster = - canSaveNotification && hasMasterChanges && selectedOrgId != null; - const shouldSaveOrg = - canSaveNotification && - hasOrgToggleChanges && - selectedOrgId != null && - isAdmin; - - if ( - shouldSaveChannel || - shouldSaveAlerts || - shouldSaveMaster || - shouldSaveOrg - ) { - const savedSteps: string[] = []; - try { - if (shouldSaveChannel) { - await updateChannels.mutateAsync({ - isBrowserPushEnabled: draftChannel.browserPush, - isEmailEnabled: draftChannel.emailNotif, - }); - setSavedChannel(draftChannel); - savedSteps.push("알림 채널"); - } - if (shouldSaveAlerts) { - await updateAlerts.mutateAsync({ - alertClicks: draftWorkspaceNotif.clickAlarm, - alertReport: draftWorkspaceNotif.weeklyReport, - }); - setSavedWorkspaceNotif(draftWorkspaceNotif); - savedSteps.push("워크스페이스 알림"); - } - if (shouldSaveMaster) { - await updateMaster.mutateAsync({ - isMasterEnabled: draftOrgNotif.masterEnabled, - }); - setSavedOrgNotif((prev) => ({ - ...prev, - masterEnabled: draftOrgNotif.masterEnabled, - })); - savedSteps.push("마스터 알림"); - } - if (shouldSaveOrg) { - await updateOrg.mutateAsync( - buildOrgBody({ - isSlackEnabled: draftOrgNotif.slackEnabled, - slackWebhookUrl: "", - disconnectSlack: false, - isDiscordEnabled: draftOrgNotif.discordEnabled, - discordWebhookUrl: "", - disconnectDiscord: false, - }), - ); - setSavedOrgNotif((prev) => ({ - ...prev, - slackEnabled: draftOrgNotif.slackEnabled, - discordEnabled: draftOrgNotif.discordEnabled, - })); - savedSteps.push("슬랙/디스코드 설정"); - } - savedAnyNotification = true; - } catch (e) { - const error = e as IApiErrorResponse; - if (savedAccount) { - toast.success("회원정보가 수정되었습니다"); - } - toast.error( - savedSteps.length > 0 - ? `${savedSteps.join(", ")}은(는) 저장됐지만, 이후 단계에서 실패했습니다` - : (error.message ?? "알림 설정 저장에 실패했습니다"), - ); - return; - } - } - - if (savedAccount || savedAnyNotification) { - toast.success( - savedAccount && savedAnyNotification - ? "설정이 저장되었습니다" - : savedAnyNotification - ? "알림 설정이 저장되었습니다" - : "회원정보가 수정되었습니다", - ); - } - } finally { - isSavingRef.current = false; - setIsSaving(false); - } - }; - - useEffect(() => { - const fetchMyInfo = async () => { - try { - setIsLoading(true); - const res = await getMyInfo(); - - const profileData = { - name: res.data.name, - email: res.data.email, - phoneNumber: res.data.phoneNumber, - }; - setSavedProfile({ - name: res.data.name, - profileImageUrl: res.data.profileImageUrl, - }); - setDraftProfile(profileData); - setPreview(res.data.profileImageUrl); - } catch (error) { - toast.error("회원 정보를 불러오는데 실패했습니다"); - console.error(error); - } finally { - setIsLoading(false); - } - }; - fetchMyInfo(); - }, [setPreview]); - - useEffect(() => { - //워크스페이스 미선택시에만 기본값 - if (selectedOrgId === null) { - setSavedChannel(DEFAULT_CHANNEL); - setDraftChannel(DEFAULT_CHANNEL); - setSavedWorkspaceNotif(DEFAULT_WORKSPACE_NOTIF); - setDraftWorkspaceNotif(DEFAULT_WORKSPACE_NOTIF); - setSavedOrgNotif(DEFAULT_ORG_NOTIF); - setDraftOrgNotif(DEFAULT_ORG_NOTIF); - setSlackWebhookUrl(""); - setSlackWebhookError(""); - setDiscordWebhookUrl(""); - setDiscordWebhookError(""); - return; - } - - if (!notificationSettings) return; //로딩,에러면 손대지 않음 - - const masterOn = notificationSettings.isMasterEnabled; - const nextChannel = { - browserPush: masterOn && notificationSettings.isBrowserPushEnabled, - emailNotif: masterOn && notificationSettings.isEmailEnabled, - }; - const nextWorkspace = { - clickAlarm: masterOn && notificationSettings.alertClicks, - weeklyReport: masterOn && notificationSettings.alertReport, - }; - const nextOrg = { - masterEnabled: masterOn, - slackEnabled: masterOn && notificationSettings.isSlackEnabled, - slackConnected: notificationSettings.isSlackConnected, - discordEnabled: masterOn && notificationSettings.isDiscordEnabled, - discordConnected: notificationSettings.isDiscordConnected, - }; - - setSavedChannel(nextChannel); - setDraftChannel(nextChannel); - setSavedWorkspaceNotif(nextWorkspace); - setDraftWorkspaceNotif(nextWorkspace); - setSavedOrgNotif(nextOrg); - setDraftOrgNotif(nextOrg); - }, [selectedOrgId, notificationSettings]); - - useEffect(() => { - if (!isNotificationError || notificationErrorUpdatedAt === 0) return; - if ( - lastNotifiedNotificationErrorAtRef.current === notificationErrorUpdatedAt - ) - return; - - lastNotifiedNotificationErrorAtRef.current = notificationErrorUpdatedAt; - toast.error( - notificationError?.message ?? "알림 설정을 불러오는데 실패했습니다", - ); - }, [isNotificationError, notificationError, notificationErrorUpdatedAt]); + const { + isAdmin, + currentWorkspaceName, + workspaceNotifiDisabled, + isNotificationSectionLoading, + isNotificationError, + notificationError, + refetchNotificationSettings, + draftChannel, + setDraftChannel, + draftWorkspaceNotif, + setDraftWorkspaceNotif, + draftOrgNotif, + setDraftOrgNotif, + slackWebhookUrl, + slackWebhookError, + discordWebhookUrl, + discordWebhookError, + pendingOrgAction, + setSlackWebhookUrl, + setSlackWebhookError, + setDiscordWebhookUrl, + setDiscordWebhookError, + handleMasterEnableChange, + handleConnectSlack, + handleDisconnectSlack, + handleConnectDiscord, + handleDisconnectDiscord, + } = notifications; return (
@@ -600,17 +89,14 @@ export default function Setting() { ) : ( setDraftProfile((prev) => ({ ...prev, name: v }))} + setName={setName} email={draftProfile.email} phoneNumber={draftProfile.phoneNumber} fileRef={fileRef} preview={preview} onPickFile={handlePickFile} openFilePicker={openFilePicker} - resetImage={() => { - resetImage(); - setIsImageDeleted(true); - }} + resetImage={resetProfileImage} /> )} @@ -656,32 +142,7 @@ export default function Setting() { email={draftProfile.email} isAdmin={isAdmin} masterEnabled={draftOrgNotif.masterEnabled} - onMasterEnabledChange={(value) => { - if (!value) { - setDraftOrgNotif((prev) => ({ - ...prev, - masterEnabled: false, - slackEnabled: false, - discordEnabled: false, - })); - setDraftChannel({ browserPush: false, emailNotif: false }); - setDraftWorkspaceNotif({ - clickAlarm: false, - weeklyReport: false, - }); - return; - } - setDraftOrgNotif((prev) => ({ - ...prev, - masterEnabled: true, - slackEnabled: - prev.slackConnected && savedOrgNotif.slackEnabled, - discordEnabled: - prev.discordConnected && savedOrgNotif.discordEnabled, - })); - setDraftChannel(savedChannel); - setDraftWorkspaceNotif(savedWorkspaceNotif); - }} + onMasterEnabledChange={handleMasterEnableChange} browserPush={draftChannel.browserPush} emailNotif={draftChannel.emailNotif} onBrowserPushChange={(value) => diff --git a/src/types/setting/settingPage.ts b/src/types/setting/settingPage.ts new file mode 100644 index 00000000..209beee1 --- /dev/null +++ b/src/types/setting/settingPage.ts @@ -0,0 +1,46 @@ +export interface IChannelNotificationSettings { + browserPush: boolean; + emailNotif: boolean; +} + +export interface IWorkspaceNotificationSettings { + clickAlarm: boolean; + weeklyReport: boolean; +} + +export interface IOrgNotificationSettings { + masterEnabled: boolean; + slackEnabled: boolean; + slackConnected: boolean; + discordEnabled: boolean; + discordConnected: boolean; +} + +export const DEFAULT_CHANNEL: IChannelNotificationSettings = { + browserPush: false, + emailNotif: false, +}; + +export const DEFAULT_WORKSPACE_NOTIF: IWorkspaceNotificationSettings = { + clickAlarm: false, + weeklyReport: false, +}; + +export const DEFAULT_ORG_NOTIF: IOrgNotificationSettings = { + masterEnabled: true, + slackEnabled: false, + slackConnected: false, + discordEnabled: false, + discordConnected: false, +}; + +export interface IDraftProfile { + name: string; + email: string; + phoneNumber: string; +} + +export interface ISavedProfile { + name: string; + profileImageUrl: string | null; +}