From b5d051814f417d38b39c803b99365afaa6ddbb5a Mon Sep 17 00:00:00 2001 From: jaemin Date: Wed, 6 May 2026 19:50:54 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(admin):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EA=B3=84=EC=A0=95=20=EA=B4=80=EB=A6=AC=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/apis/controller/user/deleteUser.ts | 7 + .../src/apis/controller/user/getUserById.ts | 13 + .../src/apis/controller/user/getUserList.ts | 7 + apps/admin/src/apis/controller/user/index.ts | 6 +- .../admin/src/apis/controller/user/putUser.ts | 7 + .../routes/_GNBLayout/admin-user/index.tsx | 728 ++++++++++++++++++ apps/admin/src/types/api/schema.d.ts | 128 ++- 7 files changed, 894 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/apis/controller/user/deleteUser.ts create mode 100644 apps/admin/src/apis/controller/user/getUserById.ts create mode 100644 apps/admin/src/apis/controller/user/getUserList.ts create mode 100644 apps/admin/src/apis/controller/user/putUser.ts create mode 100644 apps/admin/src/routes/_GNBLayout/admin-user/index.tsx diff --git a/apps/admin/src/apis/controller/user/deleteUser.ts b/apps/admin/src/apis/controller/user/deleteUser.ts new file mode 100644 index 000000000..127167a7d --- /dev/null +++ b/apps/admin/src/apis/controller/user/deleteUser.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const deleteUser = () => { + return $api.useMutation('delete', '/api/admin/user/{id}'); +}; + +export default deleteUser; diff --git a/apps/admin/src/apis/controller/user/getUserById.ts b/apps/admin/src/apis/controller/user/getUserById.ts new file mode 100644 index 000000000..d7f7f4ba3 --- /dev/null +++ b/apps/admin/src/apis/controller/user/getUserById.ts @@ -0,0 +1,13 @@ +import { $api } from '@apis'; + +const getUserById = (id: number) => { + return $api.useQuery('get', '/api/admin/user/{id}', { + params: { + path: { + id, + }, + }, + }); +}; + +export default getUserById; diff --git a/apps/admin/src/apis/controller/user/getUserList.ts b/apps/admin/src/apis/controller/user/getUserList.ts new file mode 100644 index 000000000..8ada831a6 --- /dev/null +++ b/apps/admin/src/apis/controller/user/getUserList.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const getUserList = () => { + return $api.useQuery('get', '/api/admin/user'); +}; + +export default getUserList; diff --git a/apps/admin/src/apis/controller/user/index.ts b/apps/admin/src/apis/controller/user/index.ts index 6d3f4aedc..fdd09104a 100644 --- a/apps/admin/src/apis/controller/user/index.ts +++ b/apps/admin/src/apis/controller/user/index.ts @@ -1,3 +1,7 @@ +import deleteUser from './deleteUser'; +import getUserById from './getUserById'; +import getUserList from './getUserList'; import postUser from './postUser'; +import putUser from './putUser'; -export { postUser }; +export { deleteUser, getUserById, getUserList, postUser, putUser }; diff --git a/apps/admin/src/apis/controller/user/putUser.ts b/apps/admin/src/apis/controller/user/putUser.ts new file mode 100644 index 000000000..ff8475f10 --- /dev/null +++ b/apps/admin/src/apis/controller/user/putUser.ts @@ -0,0 +1,7 @@ +import { $api } from '@apis'; + +const putUser = () => { + return $api.useMutation('put', '/api/admin/user/{id}'); +}; + +export default putUser; diff --git a/apps/admin/src/routes/_GNBLayout/admin-user/index.tsx b/apps/admin/src/routes/_GNBLayout/admin-user/index.tsx new file mode 100644 index 000000000..7dd27d277 --- /dev/null +++ b/apps/admin/src/routes/_GNBLayout/admin-user/index.tsx @@ -0,0 +1,728 @@ +import { useEffect, useState } from 'react'; +import { createFileRoute } from '@tanstack/react-router'; +import { useForm } from 'react-hook-form'; +import { useQueryClient } from '@tanstack/react-query'; +import { Header, Input, Modal, OneButtonModalTemplate } from '@components'; +import { $api, deleteUser, getUserById, getUserList, postUser, putUser } from '@apis'; +import { useModal } from '@hooks'; +import { components } from '@schema'; +import { + AlertCircle, + Eye, + EyeOff, + Mail, + Pencil, + Plus, + ShieldCheck, + Trash2, + UserCircle, +} from 'lucide-react'; + +export const Route = createFileRoute('/_GNBLayout/admin-user/')({ + component: RouteComponent, +}); + +type AdminResp = components['schemas']['AdminResp']; + +interface UserFormValues { + name: string; + email: string; + password: string; +} + +interface ApiErrorShape { + message?: string; + code?: string; + error?: { + message?: string; + code?: string; + }; + data?: { + message?: string; + code?: string; + }; +} + +const getErrorMeta = (error: unknown) => { + const fallback = { + code: '', + message: '요청 처리 중 오류가 발생했습니다.', + }; + + if (!error || typeof error !== 'object') { + return fallback; + } + + const typedError = error as ApiErrorShape; + + return { + code: typedError.code ?? typedError.error?.code ?? typedError.data?.code ?? '', + message: + typedError.message ?? + typedError.error?.message ?? + typedError.data?.message ?? + fallback.message, + }; +}; + +const getCreateOrUpdateErrorMessage = (error: unknown) => { + const { code, message } = getErrorMeta(error); + + if (code === 'DUPLICATED_EMAIL') { + return '이미 사용 중인 이메일입니다. 다른 이메일을 입력해주세요.'; + } + + return message; +}; + +const getDeleteErrorMessage = (error: unknown) => { + const { code, message } = getErrorMeta(error); + + if (code === 'ADMIN_002') { + return '본인 계정은 삭제할 수 없습니다.'; + } + + return message; +}; + +const getAdminTypeLabel = (adminType: AdminResp['adminType']) => { + if (adminType === 'SUPER') { + return '슈퍼 관리자'; + } + + return '역할 기반'; +}; + +const UserFormModal = ({ + mode, + userId, + initialUser, + isLoading, + loadErrorMessage, + isOpen, + onClose, + onSuccess, +}: { + mode: 'create' | 'edit'; + userId: number | null; + initialUser: AdminResp | null; + isLoading: boolean; + loadErrorMessage: string; + isOpen: boolean; + onClose: () => void; + onSuccess: (message: string) => void; +}) => { + const [submitError, setSubmitError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const isEditMode = mode === 'edit' && userId !== null; + const { mutate: createUser, isPending: isCreatePending } = postUser(); + const { mutate: updateUser, isPending: isUpdatePending } = putUser(); + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { + name: '', + email: '', + password: '', + }, + }); + + useEffect(() => { + if (!isOpen) { + reset({ + name: '', + email: '', + password: '', + }); + setSubmitError(''); + setShowPassword(false); + return; + } + + if (isEditMode && initialUser) { + reset({ + name: initialUser.name, + email: initialUser.email, + password: '', + }); + setSubmitError(''); + return; + } + + if (mode === 'create') { + reset({ + name: '', + email: '', + password: '', + }); + setSubmitError(''); + } + }, [initialUser, isEditMode, isOpen, mode, reset]); + + const isPending = isCreatePending || isUpdatePending; + + const onSubmit = handleSubmit((values) => { + setSubmitError(''); + + if (mode === 'create') { + createUser( + { + body: { + name: values.name, + email: values.email, + password: values.password, + }, + }, + { + onSuccess: () => { + onSuccess('관리자 계정 등록이 완료되었습니다.'); + onClose(); + }, + onError: (error) => { + setSubmitError(getCreateOrUpdateErrorMessage(error)); + }, + } + ); + return; + } + + if (!userId) { + return; + } + + const body: components['schemas']['AdminUpdateRequest'] = { + name: values.name.trim() || undefined, + email: values.email, + }; + + if (values.password.trim()) { + body.password = values.password; + } + + updateUser( + { + params: { + path: { + id: userId, + }, + }, + body, + }, + { + onSuccess: () => { + onSuccess('관리자 계정 수정이 완료되었습니다.'); + onClose(); + }, + onError: (error) => { + setSubmitError(getCreateOrUpdateErrorMessage(error)); + }, + } + ); + }); + + return ( + +
+
+
+ {mode === 'create' ? ( + + ) : ( + + )} +
+
+

+ {mode === 'create' ? '관리자 등록' : '관리자 수정'} +

+

+ {mode === 'create' + ? '이메일과 비밀번호를 입력해 관리자 계정을 생성합니다.' + : '관리자 계정 정보를 수정합니다.'} +

+
+
+ + {isEditMode && isLoading ? ( +
+ 관리자 정보를 불러오는 중입니다. +
+ ) : loadErrorMessage ? ( +
+ {loadErrorMessage} +
+ ) : ( +
+ {submitError && ( +
+ + {submitError} +
+ )} + +
+ + + {errors.name && ( +

{errors.name.message}

+ )} +
+ +
+ + + {errors.email && ( +

{errors.email.message}

+ )} +
+ +
+ +
+ + +
+ {mode === 'edit' && ( +

+ 비밀번호를 비워두면 기존 비밀번호를 유지합니다. +

+ )} + {errors.password && ( +

{errors.password.message}

+ )} +
+ +
+ + +
+
+ )} +
+
+ ); +}; + +const AdminUserModal = ({ + mode, + userId, + isOpen, + onClose, + onSuccess, +}: { + mode: 'create' | 'edit'; + userId: number | null; + isOpen: boolean; + onClose: () => void; + onSuccess: (message: string) => void; +}) => { + if (mode === 'edit' && userId !== null) { + return ( + + ); + } + + return ( + + ); +}; + +const EditAdminUserModal = ({ + userId, + isOpen, + onClose, + onSuccess, +}: { + userId: number; + isOpen: boolean; + onClose: () => void; + onSuccess: (message: string) => void; +}) => { + const { data, isLoading, error } = getUserById(userId); + + return ( + + ); +}; + +const DeleteUserModal = ({ + user, + isOpen, + onClose, + onConfirm, + isPending, + errorMessage, +}: { + user: AdminResp | null; + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + isPending: boolean; + errorMessage: string; +}) => { + return ( + +
+
+
+ +
+
+

관리자 삭제

+

삭제 후에는 계정을 복구할 수 없습니다.

+
+
+ +
+

{user?.name}

+

{user?.email}

+

이 관리자 계정을 삭제하시겠습니까?

+
+ + {errorMessage && ( +
+ + {errorMessage} +
+ )} + +
+ + +
+
+
+ ); +}; + +function RouteComponent() { + const queryClient = useQueryClient(); + const [modalMode, setModalMode] = useState<'create' | 'edit'>('create'); + const [selectedUserId, setSelectedUserId] = useState(null); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deleteErrorMessage, setDeleteErrorMessage] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + const { + isOpen: isUserModalOpen, + openModal: openUserModal, + closeModal: closeUserModal, + } = useModal(); + const { + isOpen: isDeleteModalOpen, + openModal: openDeleteModal, + closeModal: closeDeleteModal, + } = useModal(); + const { + isOpen: isSuccessModalOpen, + openModal: openSuccessModal, + closeModal: closeSuccessModal, + } = useModal(); + const { data: userListResponse, isLoading } = getUserList(); + const { mutate: removeUser, isPending: isDeletePending } = deleteUser(); + + const userList = userListResponse ?? []; + const userListQueryKey = $api.queryOptions('get', '/api/admin/user').queryKey; + + const refreshUserList = () => { + queryClient.invalidateQueries({ + queryKey: userListQueryKey, + }); + }; + + const handleSuccess = (message: string) => { + refreshUserList(); + setSuccessMessage(message); + openSuccessModal(); + }; + + const handleOpenCreateModal = () => { + setModalMode('create'); + setSelectedUserId(null); + openUserModal(); + }; + + const handleOpenEditModal = (userId: number) => { + setModalMode('edit'); + setSelectedUserId(userId); + openUserModal(); + }; + + const handleCloseUserModal = () => { + setSelectedUserId(null); + closeUserModal(); + }; + + const handleOpenDeleteModal = (user: AdminResp) => { + setDeleteTarget(user); + setDeleteErrorMessage(''); + openDeleteModal(); + }; + + const handleCloseDeleteModal = () => { + setDeleteTarget(null); + setDeleteErrorMessage(''); + closeDeleteModal(); + }; + + const handleConfirmDelete = () => { + if (!deleteTarget) { + return; + } + + setDeleteErrorMessage(''); + + removeUser( + { + params: { + path: { + id: deleteTarget.id, + }, + }, + }, + { + onSuccess: () => { + queryClient.setQueryData(userListQueryKey, (oldData) => { + if (!oldData) { + return oldData; + } + + return oldData.filter((user) => user.id !== deleteTarget.id); + }); + handleCloseDeleteModal(); + handleSuccess('관리자 계정 삭제가 완료되었습니다.'); + }, + onError: (error) => { + setDeleteErrorMessage(getDeleteErrorMessage(error)); + }, + } + ); + }; + + useEffect(() => { + if (!isDeleteModalOpen) { + setDeleteErrorMessage(''); + } + }, [isDeleteModalOpen]); + + return ( +
+
+ + 관리자 등록 + +
+ +
+
+
+

계정 목록

+

+ 전체 관리자 계정을 조회하고 생성, 수정, 삭제할 수 있습니다. +

+
+
+ 총 {userList.length}명 +
+
+ +
+
+ + + + + + + + + + + {isLoading ? ( + + + + ) : userList.length === 0 ? ( + + + + ) : ( + userList.map((user) => { + return ( + + + + + + + ); + }) + )} + +
이메일 + 관리자 유형 + 역할관리
+ 관리자 목록을 불러오는 중입니다. +
+ 등록된 관리자 계정이 없습니다. +
+

{user.email}

+

{user.name}

+
+ + {getAdminTypeLabel(user.adminType)} + + + {user.adminType === 'SUPER' ? '-' : user.roleName || '-'} + +
+ + +
+
+
+
+
+ + + + + + { + setSuccessMessage(''); + closeSuccessModal(); + }}> + { + setSuccessMessage(''); + closeSuccessModal(); + }} + /> + +
+ ); +} diff --git a/apps/admin/src/types/api/schema.d.ts b/apps/admin/src/types/api/schema.d.ts index 673e15b12..238af5095 100644 --- a/apps/admin/src/types/api/schema.d.ts +++ b/apps/admin/src/types/api/schema.d.ts @@ -1357,7 +1357,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** 관리자 계정 목록 조회 */ + get: operations['getUserList']; put?: never; /** 관리자 계정 생성 */ post: operations['create_2']; @@ -1367,6 +1368,26 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/user/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 관리자 계정 단건 조회 */ + get: operations['getUserById']; + put?: never; + post?: never; + /** 관리자 계정 삭제 */ + delete: operations['deleteUser']; + options?: never; + head?: never; + /** 관리자 계정 수정 */ + patch?: never; + trace?: never; + put: operations['putUser']; + }; '/api/admin/teacher': { parameters: { query?: never; @@ -3954,9 +3975,25 @@ export interface components { clientEventId?: string; }; AdminCreateRequest: { + name: string; email: string; password: string; }; + AdminUpdateRequest: { + name?: string; + email?: string; + password?: string; + }; + AdminResp: { + /** Format: int64 */ + id: number; + name: string; + email: string; + adminType: 'SUPER' | 'ROLE_BASED'; + /** Format: int64 */ + roleId?: number | null; + roleName?: string | null; + }; TeacherCreateRequest: { name: string; email: string; @@ -7460,6 +7497,95 @@ export interface operations { }; }; }; + getUserList: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['AdminResp'][]; + }; + }; + }; + }; + getUserById: { + parameters: { + query?: never; + header?: never; + path: { + /** Format: int64 */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['AdminResp']; + }; + }; + }; + }; + putUser: { + parameters: { + query?: never; + header?: never; + path: { + /** Format: int64 */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + deleteUser: { + parameters: { + query?: never; + header?: never; + path: { + /** Format: int64 */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; search: { parameters: { query?: { From 38bb2eb628499500c8cffcc354ea2ec144c460b6 Mon Sep 17 00:00:00 2001 From: jaemin Date: Wed, 6 May 2026 19:51:22 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(admin):=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EB=A9=94=EB=89=B4=20=EC=98=81=EC=97=AD=20=EC=8A=A4?= =?UTF-8?q?=ED=81=AC=EB=A1=A4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/admin/src/components/common/GNB.tsx | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/apps/admin/src/components/common/GNB.tsx b/apps/admin/src/components/common/GNB.tsx index ed2c4ed08..ec5eea692 100644 --- a/apps/admin/src/components/common/GNB.tsx +++ b/apps/admin/src/components/common/GNB.tsx @@ -10,6 +10,7 @@ import { Package, ChartNoAxesCombined, Users, + ShieldCheck, Megaphone, Tags, MessageCircle, @@ -98,7 +99,7 @@ const GNB = () => { return (
-
+
{/* Header */}
@@ -123,8 +124,8 @@ const GNB = () => {
{/* Navigation */} -