From 00ce90a9f0ecb4feab9fd2c614c834a426c8c80d Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Wed, 27 Aug 2025 17:32:41 +0900 Subject: [PATCH 01/11] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=88=98=EC=A0=95=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 106 +++++++++++++++++- backend-node/src/routes/adminRoutes.ts | 2 + frontend/hooks/useProfile.ts | 60 +++++----- 3 files changed, 132 insertions(+), 36 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 2ebfcb28..667637c8 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3,10 +3,13 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { Client } from "pg"; +import { PrismaClient } from "@prisma/client"; import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; +const prisma = new PrismaClient(); + /** * 관리자 메뉴 목록 조회 */ @@ -347,10 +350,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { const countParams = [...queryParams]; // 총 개수 조회를 위해 기존 쿼리를 COUNT로 변환 - const countQuery = query.replace( - /SELECT[\s\S]*?FROM/i, - "SELECT COUNT(*) as total FROM" - ).replace(/ORDER BY.*$/i, ""); + const countQuery = query + .replace(/SELECT[\s\S]*?FROM/i, "SELECT COUNT(*) as total FROM") + .replace(/ORDER BY.*$/i, ""); const countResult = await client.query(countQuery, countParams); const totalCount = parseInt(countResult.rows[0].total); @@ -2686,6 +2688,102 @@ export const deleteCompany = async ( * 사용자 비밀번호 초기화 API * 기존 Java AdminController.resetUserPassword() 포팅 */ +export const updateProfile = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const userId = req.user?.userId; + if (!userId) { + res.status(401).json({ + result: false, + error: { + code: "TOKEN_MISSING", + details: "인증 토큰이 필요합니다.", + }, + }); + return; + } + + const { + userName, + userNameEng, + userNameCn, + email, + tel, + cellPhone, + photo, + locale, + } = req.body; + + // 사용자 정보 업데이트 + const updateData: any = {}; + if (userName !== undefined) updateData.user_name = userName; + if (userNameEng !== undefined) updateData.user_name_eng = userNameEng; + if (userNameCn !== undefined) updateData.user_name_cn = userNameCn; + if (email !== undefined) updateData.email = email; + if (tel !== undefined) updateData.tel = tel; + if (cellPhone !== undefined) updateData.cell_phone = cellPhone; + if (photo !== undefined) updateData.photo = photo; + if (locale !== undefined) updateData.locale = locale; + + // 업데이트할 데이터가 없으면 에러 + if (Object.keys(updateData).length === 0) { + res.status(400).json({ + result: false, + error: { + code: "NO_DATA", + details: "업데이트할 데이터가 없습니다.", + }, + }); + return; + } + + // 데이터베이스 업데이트 + await prisma.user_info.update({ + where: { user_id: userId }, + data: updateData, + }); + + // 업데이트된 사용자 정보 조회 + const updatedUser = await prisma.user_info.findUnique({ + where: { user_id: userId }, + select: { + user_id: true, + user_name: true, + user_name_eng: true, + user_name_cn: true, + dept_code: true, + dept_name: true, + position_code: true, + position_name: true, + email: true, + tel: true, + cell_phone: true, + user_type: true, + user_type_name: true, + photo: true, + locale: true, + }, + }); + + res.json({ + result: true, + message: "프로필이 성공적으로 업데이트되었습니다.", + data: updatedUser, + }); + } catch (error) { + console.error("프로필 업데이트 오류:", error); + res.status(500).json({ + result: false, + error: { + code: "UPDATE_FAILED", + details: "프로필 업데이트 중 오류가 발생했습니다.", + }, + }); + } +}; + export const resetUserPassword = async ( req: AuthenticatedRequest, res: Response diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index afdfb284..895b96e9 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -12,6 +12,7 @@ import { getUserHistory, // 사용자 변경이력 조회 changeUserStatus, // 사용자 상태 변경 resetUserPassword, // 사용자 비밀번호 초기화 + updateProfile, // 프로필 수정 getDepartmentList, // 부서 목록 조회 checkDuplicateUserId, // 사용자 ID 중복 체크 saveUser, // 사용자 등록/수정 @@ -45,6 +46,7 @@ router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.post("/users", saveUser); // 사용자 등록/수정 +router.put("/profile", updateProfile); // 프로필 수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 diff --git a/frontend/hooks/useProfile.ts b/frontend/hooks/useProfile.ts index ed9f0528..adfe8d26 100644 --- a/frontend/hooks/useProfile.ts +++ b/frontend/hooks/useProfile.ts @@ -3,6 +3,7 @@ import { useState, useCallback } from "react"; import { ProfileFormData, ProfileModalState } from "@/types/profile"; import { LAYOUT_CONFIG, MESSAGES } from "@/constants/layout"; +import { apiCall } from "@/lib/api/client"; // 알림 모달 상태 타입 interface AlertModalState { @@ -181,48 +182,43 @@ export const useProfile = (user: any, refreshUserData: () => Promise) => { photoData = modalState.selectedImage; } - // 사용자 정보 저장 - const userSaveData = { - userId: user.userId, + // 사용자 정보 저장 데이터 준비 + const updateData = { userName: modalState.formData.userName, email: modalState.formData.email, - deptName: modalState.formData.deptName, - positionName: modalState.formData.positionName, locale: modalState.formData.locale, - photo: photoData, + photo: photoData !== user.photo ? photoData : undefined, // 변경된 경우만 전송 }; - console.log("사용자 정보 저장 요청:", userSaveData); + console.log("프로필 업데이트 요청:", updateData); - const userResponse = await fetch(`${LAYOUT_CONFIG.API_BASE_URL}${LAYOUT_CONFIG.ENDPOINTS.USER_SAVE}`, { - method: "POST", - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(userSaveData), - }); + // API 호출 (JWT 토큰 자동 포함) + const response = await apiCall("PUT", "/admin/profile", updateData); - if (userResponse.ok) { - const userResult = await userResponse.json(); - console.log("사용자 정보 저장 응답:", userResult); + console.log("프로필 업데이트 응답:", response); - if (userResult.result) { - // 성공: 세션 정보 새로고침 - await refreshUserData(); - setModalState((prev) => ({ - ...prev, - selectedFile: null, - isOpen: false, - })); - showAlert("저장 완료", MESSAGES.PROFILE_SAVE_SUCCESS, "success"); - } else { - throw new Error(userResult.msg || "사용자 정보 저장 실패"); + if (response.result) { + // locale이 변경된 경우 전역 변수와 localStorage 업데이트 + if (modalState.formData.locale && modalState.formData.locale !== user.locale) { + if (typeof window !== "undefined") { + // 전역 변수 업데이트 + (window as any).__GLOBAL_USER_LANG = modalState.formData.locale; + // localStorage 업데이트 + localStorage.setItem("userLocale", modalState.formData.locale); + console.log("🌍 사용자 locale 업데이트:", modalState.formData.locale); + } } + + // 성공: 사용자 정보 새로고침 + await refreshUserData(); + setModalState((prev) => ({ + ...prev, + selectedFile: null, + isOpen: false, + })); + showAlert("저장 완료", "프로필이 성공적으로 업데이트되었습니다.", "success"); } else { - const errorText = await userResponse.text(); - console.error("API 응답 오류:", errorText); - throw new Error(`사용자 정보 저장 실패: ${userResponse.status} ${userResponse.statusText}`); + throw new Error(response.message || "프로필 업데이트 실패"); } } catch (error) { console.error("프로필 저장 실패:", error); -- 2.43.0 From 49f812f4449c0898ac6ed4c736982329f4f2f161 Mon Sep 17 00:00:00 2001 From: hyeonsu Date: Thu, 28 Aug 2025 10:05:06 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=ED=94=84=EB=A1=9C=ED=95=84=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 33 ++++++++++++++++- .../src/controllers/authController.ts | 32 ++++++++++++---- backend-node/src/services/authService.ts | 4 ++ backend-node/src/types/auth.ts | 5 +++ frontend/components/layout/AppLayout.tsx | 2 + frontend/components/layout/ProfileModal.tsx | 33 +++++++++++++---- frontend/hooks/useAuth.ts | 1 + frontend/hooks/useProfile.ts | 37 +++++++++++++++++-- 8 files changed, 128 insertions(+), 19 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 667637c8..0ec055ba 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1739,6 +1739,7 @@ export const getUserInfo = async (req: AuthenticatedRequest, res: Response) => { u.fax_no, u.partner_objid, u.rank, + u.photo, u.locale, u.company_code, u.data_type, @@ -1791,6 +1792,9 @@ export const getUserInfo = async (req: AuthenticatedRequest, res: Response) => { faxNo: user.fax_no, partnerObjid: user.partner_objid, rank: user.rank, + photo: user.photo + ? `data:image/jpeg;base64,${user.photo.toString("base64")}` + : null, locale: user.locale, companyCode: user.company_code, dataType: user.data_type, @@ -2724,7 +2728,24 @@ export const updateProfile = async ( if (email !== undefined) updateData.email = email; if (tel !== undefined) updateData.tel = tel; if (cellPhone !== undefined) updateData.cell_phone = cellPhone; - if (photo !== undefined) updateData.photo = photo; + + // photo 데이터 처리 (Base64를 Buffer로 변환하여 저장) + if (photo !== undefined) { + if (photo && typeof photo === "string") { + try { + // Base64 헤더 제거 (data:image/jpeg;base64, 등) + const base64Data = photo.replace(/^data:image\/[a-z]+;base64,/, ""); + // Base64를 Buffer로 변환 + updateData.photo = Buffer.from(base64Data, "base64"); + } catch (error) { + console.error("Base64 이미지 처리 오류:", error); + updateData.photo = null; + } + } else { + updateData.photo = null; // 빈 값이면 null로 설정 + } + } + if (locale !== undefined) updateData.locale = locale; // 업데이트할 데이터가 없으면 에러 @@ -2767,10 +2788,18 @@ export const updateProfile = async ( }, }); + // photo가 Buffer 타입인 경우 Base64로 변환 + const responseData = { + ...updatedUser, + photo: updatedUser?.photo + ? `data:image/jpeg;base64,${updatedUser.photo.toString("base64")}` + : null, + }; + res.json({ result: true, message: "프로필이 성공적으로 업데이트되었습니다.", - data: updatedUser, + data: responseData, }); } catch (error) { console.error("프로필 업데이트 오류:", error); diff --git a/backend-node/src/controllers/authController.ts b/backend-node/src/controllers/authController.ts index c9e5b6ce..43a82f2e 100644 --- a/backend-node/src/controllers/authController.ts +++ b/backend-node/src/controllers/authController.ts @@ -166,15 +166,33 @@ export class AuthController { const userInfo = JwtUtils.verifyToken(token); + // DB에서 최신 사용자 정보 조회 (locale 포함) + const dbUserInfo = await AuthService.getUserInfo(userInfo.userId); + + if (!dbUserInfo) { + res.status(401).json({ + success: false, + message: "사용자 정보를 찾을 수 없습니다.", + error: { + code: "USER_NOT_FOUND", + details: "사용자 정보가 삭제되었거나 존재하지 않습니다.", + }, + }); + return; + } + const userInfoResponse: UserInfo = { - userId: userInfo.userId, - userName: userInfo.userName || "", - deptName: userInfo.deptName || "", - companyCode: userInfo.companyCode || "ILSHIN", - userType: userInfo.userType || "USER", - userTypeName: userInfo.userTypeName || "일반사용자", + userId: dbUserInfo.userId, + userName: dbUserInfo.userName || "", + deptName: dbUserInfo.deptName || "", + companyCode: dbUserInfo.companyCode || "ILSHIN", + userType: dbUserInfo.userType || "USER", + userTypeName: dbUserInfo.userTypeName || "일반사용자", + email: dbUserInfo.email || "", + photo: dbUserInfo.photo, + locale: dbUserInfo.locale || "KR", // locale 정보 추가 isAdmin: - userInfo.userType === "ADMIN" || userInfo.userId === "plm_admin", + dbUserInfo.userType === "ADMIN" || dbUserInfo.userId === "plm_admin", }; res.status(200).json({ diff --git a/backend-node/src/services/authService.ts b/backend-node/src/services/authService.ts index 5aafc132..a7e32d5c 100644 --- a/backend-node/src/services/authService.ts +++ b/backend-node/src/services/authService.ts @@ -146,6 +146,8 @@ export class AuthService { user_type_name: true, partner_objid: true, company_code: true, + locale: true, + photo: true, }, }); @@ -189,6 +191,8 @@ export class AuthService { partnerObjid: userInfo.partner_objid || undefined, authName: authInfo.length > 0 ? authInfo[0].auth_name : undefined, companyCode: userInfo.company_code || "ILSHIN", + photo: userInfo.photo ? `data:image/jpeg;base64,${userInfo.photo.toString('base64')}` : undefined, + locale: userInfo.locale || "KR", }; logger.info(`사용자 정보 조회 완료: ${userId}`); diff --git a/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index 785157f9..c1384b51 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -15,6 +15,9 @@ export interface UserInfo { companyCode: string; userType?: string; userTypeName?: string; + email?: string; + photo?: string; + locale?: string; isAdmin?: boolean; } @@ -47,6 +50,8 @@ export interface PersonBean { partnerObjid?: string; authName?: string; companyCode?: string; + photo?: string; + locale?: string; } // 로그인 결과 타입 (기존 LoginService.loginPwdCheck 반환값) diff --git a/frontend/components/layout/AppLayout.tsx b/frontend/components/layout/AppLayout.tsx index 35597eb3..9a4d6207 100644 --- a/frontend/components/layout/AppLayout.tsx +++ b/frontend/components/layout/AppLayout.tsx @@ -204,6 +204,7 @@ export function AppLayout({ children }: AppLayoutProps) { formData, selectedImage, isSaving, + departments, alertModal, closeAlert, openProfileModal, @@ -394,6 +395,7 @@ export function AppLayout({ children }: AppLayoutProps) { formData={formData} selectedImage={selectedImage} isSaving={isSaving} + departments={departments} alertModal={alertModal} onClose={closeProfileModal} onFormChange={updateFormData} diff --git a/frontend/components/layout/ProfileModal.tsx b/frontend/components/layout/ProfileModal.tsx index a99fd23d..d2467bac 100644 --- a/frontend/components/layout/ProfileModal.tsx +++ b/frontend/components/layout/ProfileModal.tsx @@ -53,6 +53,10 @@ interface ProfileModalProps { formData: ProfileFormData; selectedImage: string; isSaving: boolean; + departments: Array<{ + deptCode: string; + deptName: string; + }>; alertModal: { isOpen: boolean; title: string; @@ -76,6 +80,7 @@ export function ProfileModal({ formData, selectedImage, isSaving, + departments, alertModal, onClose, onFormChange, @@ -99,12 +104,14 @@ export function ProfileModal({ {selectedImage ? ( + ) : user?.photo ? ( + ) : ( {formData.userName?.substring(0, 1) || "U"} )} - {selectedImage && ( + {(selectedImage || user?.photo) && (