사용자 삭제 기능 추가

- 헤더 오른쪽 버튼 시스템 구현
  - ContextType에 setOnRightClick 추가
  - useSetOnRightClick 훅 추가
  - HeaderNavigationProps에 onRightClick 추가
  - HeaderType.LeftArrow에 오른쪽 삭제 버튼 렌더링

- 사용자 삭제 API 및 타입 추가
  - UserDeleteParams, UserDeleteResponse 인터페이스 추가
  - use-user-delete-mutation 훅 생성
  - API_URL_USER.deleteUser() 엔드포인트 사용

- 사용자 설정 페이지에서 삭제 기능 구현
  - 현재 로그인한 사용자가 아닐 경우에만 삭제 버튼 표시
  - showConfirm 다이얼로그로 삭제 확인
  - 삭제 성공 시 토스트 메시지 표시 및 목록 페이지로 이동
  - 목록 페이지에서 refresh 상태로 자동 갱신

- showConfirm 위젯 추가
  - Promise 기반의 확인 다이얼로그
  - 취소/확인 버튼 지원
  - 다국어 지원 (common.cancel, common.confirm)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jay Sheen
2025-11-14 18:22:22 +09:00
parent c6d22e9856
commit f7db3236e8
9 changed files with 188 additions and 13 deletions

View File

@@ -1,5 +1,6 @@
import { ChangeEvent, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useLocation } from 'react-router-dom';
import { PATHS } from '@/shared/constants/paths';
import { useNavigate } from '@/shared/lib/hooks/use-navigate';
import { DEFAULT_PAGE_PARAM } from '@/entities/common/model/constant';
@@ -13,13 +14,14 @@ import { snackBar } from '@/shared/lib';
export const UserManageWrap = () => {
const { t } = useTranslation();
const location = useLocation();
const { navigate } = useNavigate();
const midOptionsWithoutGids = useStore.getState().UserStore.selectOptionsMidsWithoutGids;
const userMid = useStore.getState().UserStore.mid;
let midItem = midOptionsWithoutGids.filter((value, index) => {
return value.value === userMid;
});
const [mid, setMid] = useState<string>((midItem.length > 0)? userMid: '');
const [userItems, setUserItems] = useState<Array<UserListItem>>([]);
const [pageParam, setPageParam] = useState(DEFAULT_PAGE_PARAM);
@@ -56,7 +58,7 @@ export const UserManageWrap = () => {
if(!!mid){
callList();
}
}, [mid]);
}, [mid, location.state?.refresh]);
return (
<>

View File

@@ -69,6 +69,7 @@ export interface ButtonItemProp {
};
export interface HeaderNavigationProps {
onBack?: (() => void) | undefined;
onRightClick?: (() => void) | undefined;
headerTitle?: string;
headerLeft?: React.ReactNode;
headerRight?: React.ReactNode;

View File

@@ -0,0 +1,65 @@
import axios from 'axios';
import { API_URL_USER } from '@/shared/api/api-url-user';
import { NiceAxiosError } from '@/shared/@types/error';
import {
UserDeleteParams,
UserDeleteResponse
} from '../model/types';
interface UserDeleteMutationResponse {
status: boolean;
data?: UserDeleteResponse;
error?: {
root: string;
errKey: string;
code: string;
message: string;
timestamp: string;
details: Record<string, string>;
};
}
import {
useMutation,
UseMutationOptions
} from '@tanstack/react-query';
import { getHeaderUserAgent } from '@/shared/constants/url';
export const userDelete = async (params: UserDeleteParams): Promise<UserDeleteMutationResponse> => {
let headerOptions = {
menuId: 45,
apiType: 'DELETE'
};
let options = {
headers: {
'X-User-Agent': getHeaderUserAgent(headerOptions)
}
};
try {
const response = await axios.post<UserDeleteResponse>(API_URL_USER.deleteUser(), params, options);
return { status: true, data: response.data };
} catch (error: any) {
return {
status: false,
error: {
root: 'USER_DELETE',
errKey: error.response?.data?.errKey || 'UNKNOWN_ERROR',
code: error.response?.status?.toString() || '500',
message: error.response?.data?.message || error.message || 'Unknown error',
timestamp: new Date().toISOString(),
details: error.response?.data?.details || {}
}
};
}
};
export const useUserDeleteMutation = (options?: UseMutationOptions<UserDeleteMutationResponse, NiceAxiosError, UserDeleteParams>) => {
const mutation = useMutation<UserDeleteMutationResponse, NiceAxiosError, UserDeleteParams>({
...options,
mutationFn: (params: UserDeleteParams) => userDelete(params),
});
return {
...mutation,
};
};

View File

@@ -219,6 +219,16 @@ export interface UserCreateResponse {
user: UserData;
};
export interface UserDeleteParams {
mid: string;
usrid: string;
};
export interface UserDeleteResponse {
status: boolean;
error?: ErrorResponse;
};
export interface AuthMethodItem {
usrid: string;
systemAdminClassId: string;

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { PATHS } from '@/shared/constants/paths';
@@ -11,16 +11,41 @@ import {
useSetHeaderTitle,
useSetHeaderType,
useSetFooterMode,
useSetOnBack
useSetOnBack,
useSetOnRightClick
} from '@/widgets/sub-layout/use-sub-layout';
import { useStore } from '@/shared/model/store';
import { useUserDeleteMutation } from '@/entities/user/api/use-user-delete-mutation';
import { showConfirm } from '@/widgets/show-confirm';
import { snackBar } from '@/shared/lib';
export const UserLoginAuthInfoPage = () => {
const { t } = useTranslation();
const location = useLocation();
const { mid, usrid, idCL, status } = location.state || {};
const { navigate } = useNavigate();
const { mutateAsync: userDelete } = useUserDeleteMutation();
const currentUsrid = useStore.getState().UserStore.userInfo.usrid;
const [activeTab, ] = useState<AccountUserTabKeys>(AccountUserTabKeys.LoginAuthInfo);
const handleDeleteUser = async () => {
const confirmed = await showConfirm(t('account.deleteUserConfirm', '사용자를 삭제하시겠습니까?'));
if (!confirmed) return;
try {
const result = await userDelete({ mid, usrid });
if (result.status) {
snackBar(t('account.deleteUserSuccess', '사용자 삭제를 성공했습니다.'));
navigate(PATHS.account.user.manage, { state: { refresh: true } });
} else {
snackBar(result.error?.message || t('account.deleteUserFailed', '사용자 삭제를 실패했습니다.'));
}
} catch (error: any) {
snackBar(error.message || t('account.deleteUserFailed', '사용자 삭제를 실패했습니다.'));
}
};
useSetHeaderTitle(t('account.userSettings'));
useSetHeaderType(HeaderType.LeftArrow);
useSetFooterMode(false);
@@ -28,6 +53,13 @@ export const UserLoginAuthInfoPage = () => {
navigate(PATHS.account.user.manage);
});
// 현재 로그인한 사용자가 아닌 경우에만 삭제 버튼 표시
useEffect(() => {
if (usrid && currentUsrid && usrid !== currentUsrid) {
useSetOnRightClick(() => handleDeleteUser);
}
}, [usrid, currentUsrid]);
return (
<>
<main>

View File

@@ -12,10 +12,11 @@ import { AppAlarmUnreadCountParams, AppAlarmUnreadCountResponse, MERCHANT_ADMIN_
import { useAppAlarmUnreadCountMutation } from '@/entities/alarm/api/use-app-alarm-unread-count-mutation';
import { appBridge } from '@/utils/appBridge';
export const HeaderNavigation = ({
onBack,
headerTitle,
headerType,
export const HeaderNavigation = ({
onBack,
onRightClick,
headerTitle,
headerType,
loginSuccess,
mid,
setMid
@@ -156,11 +157,11 @@ export const HeaderNavigation = ({
</div>
}
{
(headerType === HeaderType.LeftArrow) &&
(headerType === HeaderType.LeftArrow) &&
<div className="header-content">
<div className="header-left">
<button className="header-btn">
<img
<img
src={ IMAGE_ROOT + '/ico_back.svg' }
alt="뒤로가기"
onClick={ () => handleBack() }
@@ -168,6 +169,17 @@ export const HeaderNavigation = ({
</button>
</div>
<div className="header-center">{ headerTitle }</div>
{ onRightClick &&
<div className="header-right">
<button className="header-btn">
<img
src={ IMAGE_ROOT + '/ico_delete.svg' }
alt="삭제"
onClick={ () => onRightClick() }
/>
</button>
</div>
}
</div>
}
{

View File

@@ -0,0 +1,38 @@
import { Dialog } from '@/shared/ui/dialogs/dialog';
import { overlay } from 'overlay-kit';
import { useTranslation } from 'react-i18next';
export const showConfirm = (msg: string): Promise<boolean> => {
return new Promise((resolve) => {
const { t } = useTranslation();
const onConfirmClick = () => {
resolve(true);
};
const onCancelClick = () => {
resolve(false);
};
overlay.open(({
isOpen,
close,
unmount
}) => {
return (
<Dialog
afterLeave={ unmount }
open={ isOpen }
onClose={ () => {
resolve(false);
close();
}}
message={ msg }
buttonLabel={ [t('common.cancel'), t('common.confirm')] }
onConfirmClick={ onConfirmClick }
onCancelClick={ onCancelClick }
/>
);
});
});
};

View File

@@ -39,6 +39,7 @@ import { AlarmRoutes } from '@/entities/alarm/ui/alarm-routes';
export interface ContextType {
setOnBack: (onBack: () => void) => void;
setOnRightClick: (onRightClick: () => void) => void;
setHeaderTitle: (title: string) => void;
setIsPullToRefreshEnabled: (enabled: boolean) => void;
setMenuOn: (menuOn: boolean) => void;
@@ -65,6 +66,7 @@ export const SubLayout = () => {
} = useUserInfo();
const [onBack, setOnBack] = useState(undefined);
const [onRightClick, setOnRightClick] = useState(undefined);
const [headerTitle, setHeaderTitle] = useState<string>('');
const [headerType, setHeaderType] = useState<HeaderType>(HeaderType.NoHeader);
const [footerMode, setFooterMode] = useState<boolean>(false);
@@ -405,9 +407,10 @@ export const SubLayout = () => {
return (
<div className={ wrapperClassName }>
<Fragment>
<HeaderNavigation
onBack={ onBack }
headerTitle={ headerTitle }
<HeaderNavigation
onBack={ onBack }
onRightClick={ onRightClick }
headerTitle={ headerTitle }
headerType={ headerType }
key={ headerNavigationKey }
loginSuccess={ loginSuccess }
@@ -418,6 +421,7 @@ export const SubLayout = () => {
<Outlet
context={{
setOnBack,
setOnRightClick,
setHeaderTitle,
setHeaderType,
setFooterMode,

View File

@@ -19,6 +19,17 @@ export const useSetOnBack = (fn: any) => {
return { setOnBack };
};
export const useSetOnRightClick = (fn: any) => {
const { setOnRightClick } = useSubLayoutContext();
useEffect(() => {
setOnRightClick(() => fn);
return () => {
setOnRightClick(() => undefined);
};
}, [setOnRightClick]);
return { setOnRightClick };
};
export const useSetHeaderTitle = (title: string) => {
const { setHeaderTitle } = useSubLayoutContext();
useEffect(() => {