From 3027f2c817ad5822cbe8d53ec5aed38920118222 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 25 Aug 2025 18:26:38 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EC=8A=A4=ED=81=AC=EB=A6=BD=ED=8A=B8=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=EC=97=90=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EC=A0=95=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.frontend.mac.yml | 3 +-- start-all-separated.sh | 8 ++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docker-compose.frontend.mac.yml b/docker-compose.frontend.mac.yml index 8858cd77..280cf8a1 100644 --- a/docker-compose.frontend.mac.yml +++ b/docker-compose.frontend.mac.yml @@ -1,4 +1,4 @@ -version: '3.8' +version: "3.8" services: # Next.js 프론트엔드만 @@ -22,4 +22,3 @@ services: networks: pms-network: driver: bridge - external: true \ No newline at end of file diff --git a/start-all-separated.sh b/start-all-separated.sh index b01ef6fb..43ff66e5 100755 --- a/start-all-separated.sh +++ b/start-all-separated.sh @@ -8,6 +8,14 @@ echo "" echo "🚀 백엔드(Node.js)와 프론트엔드(Next.js)를 순차적으로 시작합니다..." echo "" +# 기존 컨테이너 강제 삭제 (이름 충돌 방지) +echo "============================================" +echo "0. 기존 컨테이너 정리 중..." +echo "============================================" +docker rm -f pms-backend-mac pms-frontend-mac 2>/dev/null || echo "기존 컨테이너가 없습니다." +docker network rm pms-network 2>/dev/null || echo "기존 네트워크가 없습니다." +echo "" + # 백엔드 먼저 시작 echo "============================================" echo "1. 백엔드 서비스 시작 중... (Node.js)" -- 2.43.0 From b43a88a045746b7bb92ff10f51a6b9da57f692b4 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Mon, 25 Aug 2025 18:30:07 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=EC=9D=B4=EB=A0=A5=20=EC=A1=B0=ED=9A=8C=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 | 152 ++++++++++++++++++ backend-node/src/routes/adminRoutes.ts | 2 + backend-node/src/types/auth.ts | 17 ++ .../components/admin/UserHistoryModal.tsx | 33 ++-- frontend/components/admin/UserTable.tsx | 8 +- frontend/types/userHistory.ts | 33 ++-- 6 files changed, 207 insertions(+), 38 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index bac13d71..a91e8a31 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -1832,6 +1832,158 @@ export const checkDuplicateUserId = async ( * 사용자 등록/수정 API * 기존 Java AdminController의 saveUserInfo 기능 포팅 */ +/** + * GET /api/admin/users/:userId/history + * 사용자 변경이력 조회 API + * 기존 Java AdminController.getUserHistory() 포팅 + */ +export const getUserHistory = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { userId } = req.params; + const { page = 1, countPerPage = 10 } = req.query; + + logger.info(`사용자 변경이력 조회 요청 - userId: ${userId}`, { + page, + countPerPage, + user: req.user, + }); + + if (!userId) { + res.status(400).json({ + success: false, + message: "사용자 ID가 필요합니다.", + error: { + code: "USER_ID_REQUIRED", + details: "userId parameter is required", + }, + }); + return; + } + + // PostgreSQL 클라이언트 생성 + const client = new Client({ + connectionString: + process.env.DATABASE_URL || + "postgresql://postgres:postgres@localhost:5432/ilshin", + }); + + await client.connect(); + + try { + // 페이징 계산 + const currentPage = Number(page); + const pageSize = Number(countPerPage); + const pageStart = (currentPage - 1) * pageSize + 1; + const pageEnd = currentPage * pageSize; + + // 전체 건수 조회 쿼리 (기존 backend와 동일한 로직) + const countQuery = ` + SELECT + CEIL(TOTAL_CNT::float / $1)::integer AS MAX_PAGE_SIZE, + TOTAL_CNT + FROM ( + SELECT + COUNT(1) AS TOTAL_CNT + FROM user_info_history + WHERE user_id = $2 + ) A + `; + + const countResult = await client.query(countQuery, [pageSize, userId]); + const countData = countResult.rows[0] || { + total_cnt: 0, + max_page_size: 1, + }; + + // 변경이력 목록 조회 쿼리 (기존 backend와 동일한 로직) + const historyQuery = ` + SELECT + A.* + FROM ( + SELECT + A.*, + ROW_NUMBER() OVER (ORDER BY RM DESC) AS RNUM + FROM ( + SELECT + T.*, + ROW_NUMBER() OVER (ORDER BY regdate) AS RM, + (SELECT user_name FROM user_info UI WHERE T.writer = UI.user_id) AS writer_name, + TO_CHAR(T.regdate, 'YYYY-MM-DD HH24:MI:SS') AS reg_date_title + FROM + user_info_history T + WHERE user_id = $1 + ) A + WHERE 1=1 + ) A + WHERE 1=1 + AND RNUM::integer <= $2 + AND RNUM::integer >= $3 + ORDER BY RM DESC + `; + + const historyResult = await client.query(historyQuery, [ + userId, + pageEnd, + pageStart, + ]); + + // 응답 데이터 가공 + const historyList = historyResult.rows.map((row) => ({ + sabun: row.sabun || "", + userId: row.user_id || "", + userName: row.user_name || "", + deptCode: row.dept_code || "", + deptName: row.dept_name || "", + userTypeName: row.user_type_name || "", + historyType: row.history_type || "", + writer: row.writer || "", + writerName: row.writer_name || "", + regDate: row.regdate, + regDateTitle: row.reg_date_title || "", + status: row.status || "", + rowNum: row.rnum, + })); + + logger.info( + `사용자 변경이력 조회 완료 - userId: ${userId}, 조회건수: ${historyList.length}, 전체: ${countData.total_cnt}` + ); + + const response: ApiResponse = { + success: true, + data: historyList, + total: Number(countData.total_cnt), + + pagination: { + page: currentPage, + limit: pageSize, + total: Number(countData.total_cnt), + totalPages: Number(countData.max_page_size), + }, + }; + + res.status(200).json(response); + } finally { + await client.end(); + } + } catch (error) { + logger.error("사용자 변경이력 조회 중 오류 발생", error); + + const response: ApiResponse = { + success: false, + message: "사용자 변경이력 조회 중 오류가 발생했습니다.", + error: { + code: "USER_HISTORY_FETCH_ERROR", + details: error instanceof Error ? error.message : "Unknown error", + }, + }; + + res.status(500).json(response); + } +}; + export const saveUser = async (req: AuthenticatedRequest, res: Response) => { try { const userData = req.body; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 1d40c7b9..c3e79cd6 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -9,6 +9,7 @@ import { deleteMenusBatch, // 메뉴 일괄 삭제 getUserList, getUserInfo, // 사용자 상세 조회 + getUserHistory, // 사용자 변경이력 조회 getDepartmentList, // 부서 목록 조회 checkDuplicateUserId, // 사용자 ID 중복 체크 saveUser, // 사용자 등록/수정 @@ -39,6 +40,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 // 사용자 관리 API router.get("/users", getUserList); router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 +router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 router.post("/users", saveUser); // 사용자 등록/수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 diff --git a/backend-node/src/types/auth.ts b/backend-node/src/types/auth.ts index 18ba619f..785157f9 100644 --- a/backend-node/src/types/auth.ts +++ b/backend-node/src/types/auth.ts @@ -92,3 +92,20 @@ export interface AuthStatusInfo { export interface AuthenticatedRequest extends Request { user?: PersonBean; } + +// 사용자 변경이력 타입 +export interface UserHistory { + sabun: string; + userId: string; + userName: string; + deptCode: string; + deptName: string; + userTypeName: string; + historyType: string; + writer: string; + writerName: string; + regDate: Date; + regDateTitle: string; + status: string; + rowNum: number; +} diff --git a/frontend/components/admin/UserHistoryModal.tsx b/frontend/components/admin/UserHistoryModal.tsx index fb351799..dd6614b0 100644 --- a/frontend/components/admin/UserHistoryModal.tsx +++ b/frontend/components/admin/UserHistoryModal.tsx @@ -33,9 +33,10 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist const [maxPageSize, setMaxPageSize] = useState(1); // 페이지네이션 정보 계산 + const totalPages = Math.ceil(totalItems / pageSize); const paginationInfo: PaginationInfo = { currentPage, - totalPages: maxPageSize, + totalPages: totalPages || 1, totalItems, itemsPerPage: pageSize, startItem: totalItems > 0 ? (currentPage - 1) * pageSize + 1 : 0, @@ -62,14 +63,16 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist console.log("📊 백엔드 응답:", response); if (response && response.success && Array.isArray(response.data)) { - // 원본 JSP처럼 No 컬럼을 RM 값으로 설정 - const mappedHistoryList = response.data.map((item) => ({ + const responseTotal = response.total || 0; + + // No 컬럼을 rowNum 값으로 설정 (페이징 고려) + const mappedHistoryList = response.data.map((item, index) => ({ ...item, - no: item.RM || 0, // 원본 JSP에서는 RM을 No로 사용 + no: item.rowNum || responseTotal - (pageToLoad - 1) * pageSize - index, // rowNum 우선, 없으면 계산 })); setHistoryList(mappedHistoryList); - setTotalItems(response.total || 0); + setTotalItems(responseTotal); setMaxPageSize(response.maxPageSize || 1); } else if (response && response.success && (!response.data || response.data.length === 0)) { // 데이터가 비어있는 경우 @@ -202,23 +205,23 @@ export function UserHistoryModal({ isOpen, onClose, userId, userName }: UserHist historyList.map((history, index) => ( {history.no} - {history.SABUN || "-"} - {history.USER_ID || "-"} - {history.USER_NAME || "-"} - {history.DEPT_NAME || "-"} + {history.sabun || "-"} + {history.userId || "-"} + {history.userName || "-"} + {history.deptName || "-"} - - {getStatusText(history.STATUS || "")} + + {getStatusText(history.status || "")} - - {history.HISTORY_TYPE || "-"} + + {history.historyType || "-"} - {history.WRITER_NAME || "-"} + {history.writerName || "-"} - {history.REG_DATE_TITLE || formatDate(history.REGDATE || "")} + {history.regDateTitle || formatDate(history.regDate || "")} )) diff --git a/frontend/components/admin/UserTable.tsx b/frontend/components/admin/UserTable.tsx index bc5714e9..a4b55731 100644 --- a/frontend/components/admin/UserTable.tsx +++ b/frontend/components/admin/UserTable.tsx @@ -81,8 +81,8 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on const handleOpenHistoryModal = (user: User) => { setHistoryModal({ isOpen: true, - userId: user.user_id, - userName: user.user_name || user.user_id, + userId: user.userId, + userName: user.userName || user.userId, }); }; @@ -178,7 +178,7 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on {users.map((user, index) => ( - + {getRowNumber(index)} {user.sabun || "-"} {user.companyCode || "-"} @@ -190,7 +190,7 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on {user.email || "-"} - {formatDate(user.regDate)} + {formatDate(user.regDate || "")}
Date: Tue, 26 Aug 2025 09:56:45 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=EB=B3=80=EA=B2=BD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 135 ++++++++++++++++++ backend-node/src/routes/adminRoutes.ts | 2 + frontend/components/admin/UserTable.tsx | 7 +- frontend/hooks/useUserManagement.ts | 6 +- frontend/lib/api/user.ts | 4 +- 5 files changed, 143 insertions(+), 11 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index a91e8a31..03b58c26 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -3,6 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { Client } from "pg"; +import config from "../config/environment"; import { AdminService } from "../services/adminService"; import { EncryptUtil } from "../utils/encryptUtil"; @@ -1984,6 +1985,140 @@ export const getUserHistory = async ( } }; +/** + * PATCH /api/admin/users/:userId/status + * 사용자 상태 변경 API (부분 수정) + * 기존 Java AdminController.changeUserStatus() 포팅 + */ +export const changeUserStatus = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { userId } = req.params; + const { status } = req.body; + + logger.info("사용자 상태 변경 요청", { userId, status, user: req.user }); + + // 필수 파라미터 검증 + if (!userId || !status) { + res.status(400).json({ + result: false, + msg: "사용자 ID와 상태는 필수입니다.", + }); + return; + } + + // 상태 값 검증 + if (!["active", "inactive"].includes(status)) { + res.status(400).json({ + result: false, + msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)", + }); + return; + } + + const client = new Client({ + connectionString: config.databaseUrl, + }); + + try { + await client.connect(); + + // 1. 사용자 존재 여부 확인 + const userCheckResult = await client.query( + "SELECT user_id, user_name, status FROM user_info WHERE user_id = $1", + [userId] + ); + + if (userCheckResult.rows.length === 0) { + res.status(404).json({ + result: false, + msg: "사용자를 찾을 수 없습니다.", + }); + return; + } + + const currentUser = userCheckResult.rows[0]; + + // 2. 상태 변경 쿼리 실행 + let updateQuery = ` + UPDATE user_info + SET status = $1 + `; + + const queryParams = [status]; + + // active/inactive에 따른 END_DATE 처리 + if (status === "inactive") { + updateQuery += `, end_date = NOW()`; + } else if (status === "active") { + updateQuery += `, end_date = NULL`; + } + + updateQuery += ` WHERE user_id = $2`; + queryParams.push(userId); + + const updateResult = await client.query(updateQuery, queryParams); + + if (updateResult.rowCount && updateResult.rowCount > 0) { + // 3. 사용자 이력 저장 (선택적) + try { + await client.query( + ` + INSERT INTO user_info_history + (user_id, user_name, dept_code, dept_name, user_type_name, history_type, writer, reg_date, status, sabun) + VALUES ($1, $2, '', '', '', '사용자 상태 변경', $3, NOW(), $4, '') + `, + [ + userId, + currentUser.user_name || userId, + req.user?.userId || "system", + status, + ] + ); + } catch (historyError) { + logger.warn("사용자 이력 저장 실패", { + error: historyError, + userId, + status, + }); + // 이력 저장 실패는 치명적이지 않으므로 계속 진행 + } + + logger.info("사용자 상태 변경 성공", { + userId, + oldStatus: currentUser.status, + newStatus: status, + updatedBy: req.user?.userId, + }); + + res.json({ + result: true, + msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, + }); + } else { + res.status(400).json({ + result: false, + msg: "사용자 상태 변경에 실패했습니다.", + }); + } + } finally { + await client.end(); + } + } catch (error) { + logger.error("사용자 상태 변경 중 오류 발생", { + error, + userId: req.params.userId, + status: req.body.status, + }); + res.status(500).json({ + result: false, + msg: "시스템 오류가 발생했습니다.", + }); + } +}; + export const saveUser = async (req: AuthenticatedRequest, res: Response) => { try { const userData = req.body; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index c3e79cd6..460e81d9 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -10,6 +10,7 @@ import { getUserList, getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 + changeUserStatus, // 사용자 상태 변경 getDepartmentList, // 부서 목록 조회 checkDuplicateUserId, // 사용자 ID 중복 체크 saveUser, // 사용자 등록/수정 @@ -41,6 +42,7 @@ router.delete("/menus/:menuId", deleteMenu); // 메뉴 삭제 router.get("/users", getUserList); router.get("/users/:userId", getUserInfo); // 사용자 상세 조회 router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 조회 +router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.post("/users", saveUser); // 사용자 등록/수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 diff --git a/frontend/components/admin/UserTable.tsx b/frontend/components/admin/UserTable.tsx index a4b55731..86b7f519 100644 --- a/frontend/components/admin/UserTable.tsx +++ b/frontend/components/admin/UserTable.tsx @@ -1,4 +1,4 @@ -import { Key, FileText, History } from "lucide-react"; +import { Key, History } from "lucide-react"; import { useState } from "react"; import { User } from "@/types/user"; import { USER_TABLE_COLUMNS } from "@/constants/user"; @@ -198,11 +198,6 @@ export function UserTable({ users, isLoading, paginationInfo, onStatusToggle, on onCheckedChange={(checked) => handleStatusToggle(user, checked)} aria-label={`${user.userName} 상태 토글`} /> - - {user.status === "active" ? "활성" : "비활성"} -
diff --git a/frontend/hooks/useUserManagement.ts b/frontend/hooks/useUserManagement.ts index ae53607e..3492f4fb 100644 --- a/frontend/hooks/useUserManagement.ts +++ b/frontend/hooks/useUserManagement.ts @@ -181,10 +181,10 @@ export const useUserManagement = () => { // 사용자 상태 토글 핸들러 const handleStatusToggle = useCallback(async (user: User, newStatus: string) => { try { - console.log(`🎛️ 상태 변경: ${user.user_name} (${user.user_id}) → ${newStatus}`); + console.log(`🎛️ 상태 변경: ${user.userName} (${user.userId}) → ${newStatus}`); // 백엔드 API 호출 - const response = await userAPI.updateStatus(user.user_id, newStatus); + const response = await userAPI.updateStatus(user.userId, newStatus); // 백엔드 응답 구조: { result: boolean, msg: string } if (response && typeof response === "object" && "result" in response) { @@ -194,7 +194,7 @@ export const useUserManagement = () => { console.log("✅ 상태 변경 성공:", apiResponse.msg); // 전체 목록 새로고침 대신 개별 사용자 상태만 업데이트 - setUsers((prevUsers) => prevUsers.map((u) => (u.user_id === user.user_id ? { ...u, status: newStatus } : u))); + setUsers((prevUsers) => prevUsers.map((u) => (u.userId === user.userId ? { ...u, status: newStatus } : u))); } else { console.error("❌ 상태 변경 실패:", apiResponse.msg); alert(apiResponse.msg || "상태 변경에 실패했습니다."); diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index d4f8efea..b34eb71c 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -78,10 +78,10 @@ export async function createUser(userData: any) { // 사용자 수정 기능 제거됨 /** - * 사용자 상태 변경 + * 사용자 상태 변경 (부분 수정) */ export async function updateUserStatus(userId: string, status: string) { - const response = await apiClient.put(`/admin/users/${userId}/status`, { status }); + const response = await apiClient.patch(`/admin/users/${userId}/status`, { status }); return response.data; } -- 2.43.0 From 65d648d30b5f2e97ce645c6f00e2ddb71bfbd0f3 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 26 Aug 2025 11:10:34 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/UserStatusConfirmDialog.tsx | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/frontend/components/admin/UserStatusConfirmDialog.tsx b/frontend/components/admin/UserStatusConfirmDialog.tsx index 88ecbac5..72ab1aa3 100644 --- a/frontend/components/admin/UserStatusConfirmDialog.tsx +++ b/frontend/components/admin/UserStatusConfirmDialog.tsx @@ -7,7 +7,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { User } from "@/types/user"; +import { User, USER_STATUS_LABELS } from "@/types/user"; interface UserStatusConfirmDialogProps { user: User | null; @@ -29,8 +29,12 @@ export function UserStatusConfirmDialog({ }: UserStatusConfirmDialogProps) { if (!user) return null; - const statusText = newStatus === "active" ? "활성" : "비활성"; - const statusColor = newStatus === "active" ? "text-blue-600" : "text-gray-600"; + // 현재 상태와 새로운 상태의 텍스트 및 색상 + const currentStatusText = USER_STATUS_LABELS[user.status as keyof typeof USER_STATUS_LABELS] || user.status; + const newStatusText = USER_STATUS_LABELS[newStatus as keyof typeof USER_STATUS_LABELS] || newStatus; + + const currentStatusColor = user.status === "active" ? "text-blue-600" : "text-gray-600"; + const newStatusColor = newStatus === "active" ? "text-blue-600" : "text-gray-600"; return ( !open && onCancel()}> @@ -41,16 +45,20 @@ export function UserStatusConfirmDialog({
-
+
- 사용자: + 사용자: - {user.user_name} ({user.user_id}) + {user.userName} ({user.userId})
- 변경할 상태: - {statusText} + 상태 변경: +
+ {currentStatusText} + + {newStatusText} +
-- 2.43.0 From 9ff797ba897cf637e3dd972588c2556b9586ad3a Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 26 Aug 2025 11:10:44 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20x=EB=B2=84=ED=8A=BC?= =?UTF-8?q?=EC=97=90=20pointer=20=EC=86=8D=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/ui/dialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/components/ui/dialog.tsx b/frontend/components/ui/dialog.tsx index 1535591a..c0ef81cc 100644 --- a/frontend/components/ui/dialog.tsx +++ b/frontend/components/ui/dialog.tsx @@ -44,7 +44,7 @@ const DialogContent = React.forwardRef< {...props} > {children} - + Close -- 2.43.0 From 6f68fa5639e5c32ba074df5fb3e46477f3265a5b Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 26 Aug 2025 13:42:57 +0900 Subject: [PATCH 6/7] =?UTF-8?q?=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 155 ++++++++++++++++++ backend-node/src/routes/adminRoutes.ts | 2 + frontend/components/admin/UserManagement.tsx | 2 +- .../admin/UserPasswordResetModal.tsx | 60 ++++++- frontend/components/common/AlertModal.tsx | 97 +++++++++++ 5 files changed, 306 insertions(+), 10 deletions(-) create mode 100644 frontend/components/common/AlertModal.tsx diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 03b58c26..1858aedb 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -2592,3 +2592,158 @@ export const deleteCompany = async ( }); } }; + +/** + * POST /api/admin/users/reset-password + * 사용자 비밀번호 초기화 API + * 기존 Java AdminController.resetUserPassword() 포팅 + */ +export const resetUserPassword = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { userId, newPassword } = req.body; + + logger.info("비밀번호 초기화 요청", { userId, user: req.user }); + + // 입력값 검증 + if (!userId || !userId.trim()) { + res.status(400).json({ + result: false, + msg: "사용자 ID가 필요합니다.", + }); + return; + } + + if (!newPassword || !newPassword.trim()) { + res.status(400).json({ + success: false, + result: false, + message: "새 비밀번호가 필요합니다.", + msg: "새 비밀번호가 필요합니다.", + }); + return; + } + + // 비밀번호 길이 검증 (최소 4자) + if (newPassword.length < 4) { + res.status(400).json({ + success: false, + result: false, + message: "비밀번호는 최소 4자 이상이어야 합니다.", + msg: "비밀번호는 최소 4자 이상이어야 합니다.", + }); + return; + } + + const client = new Client({ connectionString: config.databaseUrl }); + try { + await client.connect(); + + // 1. 사용자 존재 여부 확인 + const userCheckResult = await client.query( + "SELECT user_id, user_name FROM user_info WHERE user_id = $1", + [userId] + ); + + if (userCheckResult.rows.length === 0) { + res.status(404).json({ + success: false, + result: false, + message: "사용자를 찾을 수 없습니다.", + msg: "사용자를 찾을 수 없습니다.", + }); + return; + } + + const currentUser = userCheckResult.rows[0]; + + // 2. 비밀번호 암호화 (기존 Java 로직과 동일) + let encryptedPassword: string; + try { + // EncryptUtil과 동일한 암호화 사용 + const crypto = require("crypto"); + const keyName = "ILJIAESSECRETKEY"; + const algorithm = "aes-128-ecb"; + + // AES-128-ECB 암호화 + const cipher = crypto.createCipher(algorithm, keyName); + let encrypted = cipher.update(newPassword, "utf8", "hex"); + encrypted += cipher.final("hex"); + encryptedPassword = encrypted.toUpperCase(); + } catch (encryptError) { + logger.error("비밀번호 암호화 중 오류 발생", { + error: encryptError, + userId, + }); + res.status(500).json({ + success: false, + result: false, + message: "비밀번호 암호화 중 오류가 발생했습니다.", + msg: "비밀번호 암호화 중 오류가 발생했습니다.", + }); + return; + } + + // 3. 비밀번호 업데이트 실행 + const updateResult = await client.query( + "UPDATE user_info SET user_password = $1 WHERE user_id = $2", + [encryptedPassword, userId] + ); + + if (updateResult.rowCount && updateResult.rowCount > 0) { + // 4. 이력 저장 (선택적) + try { + const writer = req.user?.userId || "system"; + await client.query( + ` + INSERT INTO user_info_history + (sabun, user_id, user_name, dept_code, dept_name, user_type_name, history_type, writer, regdate, status) + VALUES ('', $1, $2, '', '', '', '비밀번호 초기화', $3, NOW(), '') + `, + [userId, currentUser.user_name || userId, writer] + ); + } catch (historyError) { + logger.warn("비밀번호 초기화 이력 저장 실패", { + error: historyError, + userId, + }); + // 이력 저장 실패해도 비밀번호 초기화는 성공으로 처리 + } + + logger.info("비밀번호 초기화 성공", { + userId, + updatedBy: req.user?.userId, + }); + + res.json({ + success: true, + result: true, + message: "비밀번호가 성공적으로 초기화되었습니다.", + msg: "비밀번호가 성공적으로 초기화되었습니다.", + }); + } else { + res.status(400).json({ + success: false, + result: false, + message: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.", + msg: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.", + }); + } + } finally { + await client.end(); + } + } catch (error) { + logger.error("비밀번호 초기화 중 오류 발생", { + error, + userId: req.body.userId, + }); + res.status(500).json({ + success: false, + result: false, + message: "비밀번호 초기화 중 시스템 오류가 발생했습니다.", + msg: "비밀번호 초기화 중 시스템 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/routes/adminRoutes.ts b/backend-node/src/routes/adminRoutes.ts index 460e81d9..afdfb284 100644 --- a/backend-node/src/routes/adminRoutes.ts +++ b/backend-node/src/routes/adminRoutes.ts @@ -11,6 +11,7 @@ import { getUserInfo, // 사용자 상세 조회 getUserHistory, // 사용자 변경이력 조회 changeUserStatus, // 사용자 상태 변경 + resetUserPassword, // 사용자 비밀번호 초기화 getDepartmentList, // 부서 목록 조회 checkDuplicateUserId, // 사용자 ID 중복 체크 saveUser, // 사용자 등록/수정 @@ -45,6 +46,7 @@ router.get("/users/:userId/history", getUserHistory); // 사용자 변경이력 router.patch("/users/:userId/status", changeUserStatus); // 사용자 상태 변경 router.post("/users", saveUser); // 사용자 등록/수정 router.post("/users/check-duplicate", checkDuplicateUserId); // 사용자 ID 중복 체크 +router.post("/users/reset-password", resetUserPassword); // 사용자 비밀번호 초기화 // 부서 관리 API router.get("/departments", getDepartmentList); // 부서 목록 조회 diff --git a/frontend/components/admin/UserManagement.tsx b/frontend/components/admin/UserManagement.tsx index 46f867f5..fab8a20f 100644 --- a/frontend/components/admin/UserManagement.tsx +++ b/frontend/components/admin/UserManagement.tsx @@ -84,7 +84,7 @@ export function UserManagement() { // 비밀번호 초기화 성공 핸들러 const handlePasswordResetSuccess = () => { - refreshData(); // 목록 새로고침 + // refreshData(); // 비밀번호 변경은 목록에 영향을 주지 않으므로 새로고침 불필요 handlePasswordResetClose(); }; diff --git a/frontend/components/admin/UserPasswordResetModal.tsx b/frontend/components/admin/UserPasswordResetModal.tsx index cda4dbbb..46928bd3 100644 --- a/frontend/components/admin/UserPasswordResetModal.tsx +++ b/frontend/components/admin/UserPasswordResetModal.tsx @@ -6,8 +6,8 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Eye, EyeOff } from "lucide-react"; -// import { toast } from "react-hot-toast"; // 라이브러리 미설치로 alert 사용 import { userAPI } from "@/lib/api/user"; +import { AlertModal, AlertType } from "@/components/common/AlertModal"; interface UserPasswordResetModalProps { isOpen: boolean; @@ -24,6 +24,40 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [isLoading, setIsLoading] = useState(false); + // 알림 모달 상태 + const [alertState, setAlertState] = useState<{ + isOpen: boolean; + type: AlertType; + title: string; + message: string; + }>({ + isOpen: false, + type: "info", + title: "", + message: "", + }); + + // 알림 모달 표시 헬퍼 함수 + const showAlert = (type: AlertType, title: string, message: string) => { + setAlertState({ + isOpen: true, + type, + title, + message, + }); + }; + + // 알림 모달 닫기 + const closeAlert = () => { + setAlertState((prev) => ({ ...prev, isOpen: false })); + + // 성공 알림이 닫힐 때 메인 모달도 닫기 + if (alertState.type === "success") { + handleClose(); + onSuccess?.(); + } + }; + // 비밀번호 유효성 검사 (영문, 숫자, 특수문자만 허용) const validatePassword = (password: string) => { const regex = /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?`~]*$/; @@ -37,17 +71,17 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu // 초기화 핸들러 const handleReset = useCallback(async () => { if (!userId || !newPassword.trim()) { - alert("새 비밀번호를 입력해주세요."); + showAlert("warning", "입력 필요", "새 비밀번호를 입력해주세요."); return; } if (!validatePassword(newPassword)) { - alert("비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다."); + showAlert("warning", "형식 오류", "비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다."); return; } if (newPassword !== confirmPassword) { - alert("비밀번호가 일치하지 않습니다."); + showAlert("warning", "비밀번호 불일치", "비밀번호가 일치하지 않습니다."); return; } @@ -60,15 +94,14 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu }); if (response.success) { - alert("비밀번호가 성공적으로 초기화되었습니다."); - handleClose(); - onSuccess?.(); + showAlert("success", "초기화 완료", "비밀번호가 성공적으로 초기화되었습니다."); + // 성공 알림은 사용자가 확인 버튼을 눌러서 닫도록 함 } else { - alert(response.message || "비밀번호 초기화에 실패했습니다."); + showAlert("error", "초기화 실패", response.message || "비밀번호 초기화에 실패했습니다."); } } catch (error) { console.error("비밀번호 초기화 오류:", error); - alert("비밀번호 초기화 중 오류가 발생했습니다."); + showAlert("error", "시스템 오류", "비밀번호 초기화 중 오류가 발생했습니다."); } finally { setIsLoading(false); } @@ -183,6 +216,15 @@ export function UserPasswordResetModal({ isOpen, onClose, userId, userName, onSu
+ + {/* 알림 모달 */} +
); } diff --git a/frontend/components/common/AlertModal.tsx b/frontend/components/common/AlertModal.tsx new file mode 100644 index 00000000..e1fb19f4 --- /dev/null +++ b/frontend/components/common/AlertModal.tsx @@ -0,0 +1,97 @@ +"use client"; + +import React from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { CheckCircle, XCircle, AlertTriangle, Info } from "lucide-react"; + +export type AlertType = "success" | "error" | "warning" | "info"; + +interface AlertModalProps { + isOpen: boolean; + onClose: () => void; + type: AlertType; + title: string; + message: string; + confirmText?: string; + onConfirm?: () => void; +} + +const alertConfig = { + success: { + icon: CheckCircle, + iconColor: "text-green-500", + titleColor: "text-green-700", + }, + error: { + icon: XCircle, + iconColor: "text-red-500", + titleColor: "text-red-700", + }, + warning: { + icon: AlertTriangle, + iconColor: "text-yellow-500", + titleColor: "text-yellow-700", + }, + info: { + icon: Info, + iconColor: "text-blue-500", + titleColor: "text-blue-700", + }, +}; + +export function AlertModal({ + isOpen, + onClose, + type, + title, + message, + confirmText = "확인", + onConfirm, +}: AlertModalProps) { + const config = alertConfig[type]; + const IconComponent = config.icon; + + const handleConfirm = () => { + if (onConfirm) { + onConfirm(); + } + onClose(); + }; + + return ( + !open && onClose()}> + + +
+ + {title} +
+ {message} +
+ + + + +
+
+ ); +} + +// 편의를 위한 래퍼 함수들 +export const SuccessModal = (props: Omit) => ; + +export const ErrorModal = (props: Omit) => ; + +export const WarningModal = (props: Omit) => ; + +export const InfoModal = (props: Omit) => ; -- 2.43.0 From 7267cc52ebba975334e78fd1b32590bf890655b2 Mon Sep 17 00:00:00 2001 From: dohyeons Date: Tue, 26 Aug 2025 14:23:22 +0900 Subject: [PATCH 7/7] =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/adminController.ts | 168 +++++++++--- frontend/components/admin/UserToolbar.tsx | 242 ++++++++++++++---- frontend/hooks/useUserManagement.ts | 193 +++++++++----- frontend/lib/api/user.ts | 6 + frontend/types/user.ts | 17 +- 5 files changed, 473 insertions(+), 153 deletions(-) diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index 1858aedb..2ebfcb28 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -174,13 +174,30 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { user: req.user, }); - const { page = 1, countPerPage = 20, search, deptCode, status } = req.query; + const { + page = 1, + countPerPage = 20, + // 통합 검색 (전체 필드 대상) + search, + // 고급 검색 (개별 필드별) + searchField, + searchValue, + search_sabun, + search_companyName, + search_deptName, + search_positionName, + search_userId, + search_userName, + search_tel, + search_email, + // 기존 필터 + deptCode, + status, + } = req.query; // PostgreSQL 클라이언트 생성 const client = new Client({ - connectionString: - process.env.DATABASE_URL || - "postgresql://postgres:postgres@localhost:5432/ilshin", + connectionString: config.databaseUrl, }); await client.connect(); @@ -214,27 +231,109 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { const queryParams: any[] = []; let paramIndex = 1; + let searchType = "none"; + + // 검색 조건 처리 + if (search && typeof search === "string" && search.trim()) { + // 통합 검색 (우선순위: 모든 주요 필드에서 검색) + searchType = "unified"; + const searchTerm = search.trim(); - // 검색 조건 추가 - if (search) { query += ` AND ( - u.user_name ILIKE $${paramIndex} OR - u.user_id ILIKE $${paramIndex} OR - u.sabun ILIKE $${paramIndex} OR - u.email ILIKE $${paramIndex} + UPPER(COALESCE(u.sabun, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.user_type_name, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.dept_name, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.position_name, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.user_id, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.user_name, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.tel, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.cell_phone, '')) LIKE UPPER($${paramIndex}) OR + UPPER(COALESCE(u.email, '')) LIKE UPPER($${paramIndex}) )`; - queryParams.push(`%${search}%`); + queryParams.push(`%${searchTerm}%`); paramIndex++; + + logger.info("통합 검색 실행", { searchTerm }); + } else if (searchField && searchValue) { + // 단일 필드 검색 + searchType = "single"; + const fieldMap: { [key: string]: string } = { + sabun: "u.sabun", + companyName: "u.user_type_name", + deptName: "u.dept_name", + positionName: "u.position_name", + userId: "u.user_id", + userName: "u.user_name", + tel: "u.tel", + cellPhone: "u.cell_phone", + email: "u.email", + }; + + if (fieldMap[searchField as string]) { + if (searchField === "tel") { + // 전화번호는 TEL과 CELL_PHONE 모두 검색 + query += ` AND (UPPER(u.tel) LIKE UPPER($${paramIndex}) OR UPPER(u.cell_phone) LIKE UPPER($${paramIndex}))`; + } else { + query += ` AND UPPER(${fieldMap[searchField as string]}) LIKE UPPER($${paramIndex})`; + } + queryParams.push(`%${searchValue}%`); + paramIndex++; + + logger.info("단일 필드 검색 실행", { searchField, searchValue }); + } + } else { + // 고급 검색 (개별 필드별 AND 조건) + const advancedSearchFields = [ + { param: search_sabun, field: "u.sabun" }, + { param: search_companyName, field: "u.user_type_name" }, + { param: search_deptName, field: "u.dept_name" }, + { param: search_positionName, field: "u.position_name" }, + { param: search_userId, field: "u.user_id" }, + { param: search_userName, field: "u.user_name" }, + { param: search_email, field: "u.email" }, + ]; + + let hasAdvancedSearch = false; + + for (const { param, field } of advancedSearchFields) { + if (param && typeof param === "string" && param.trim()) { + query += ` AND UPPER(${field}) LIKE UPPER($${paramIndex})`; + queryParams.push(`%${param.trim()}%`); + paramIndex++; + hasAdvancedSearch = true; + } + } + + // 전화번호 검색 (TEL 또는 CELL_PHONE) + if (search_tel && typeof search_tel === "string" && search_tel.trim()) { + query += ` AND (UPPER(u.tel) LIKE UPPER($${paramIndex}) OR UPPER(u.cell_phone) LIKE UPPER($${paramIndex}))`; + queryParams.push(`%${search_tel.trim()}%`); + paramIndex++; + hasAdvancedSearch = true; + } + + if (hasAdvancedSearch) { + searchType = "advanced"; + logger.info("고급 검색 실행", { + search_sabun, + search_companyName, + search_deptName, + search_positionName, + search_userId, + search_userName, + search_tel, + search_email, + }); + } } - // 부서 코드 필터 + // 기존 필터들 if (deptCode) { query += ` AND u.dept_code = $${paramIndex}`; queryParams.push(deptCode); paramIndex++; } - // 상태 필터 if (status) { query += ` AND u.status = $${paramIndex}`; queryParams.push(status); @@ -244,26 +343,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { // 정렬 query += ` ORDER BY u.regdate DESC, u.user_name`; - // 총 개수 조회 - const countQuery = ` - SELECT COUNT(*) as total - FROM user_info u - WHERE 1=1 - ${ - search - ? `AND ( - u.user_name ILIKE $1 OR - u.user_id ILIKE $1 OR - u.sabun ILIKE $1 OR - u.email ILIKE $1 - )` - : "" - } - ${deptCode ? `AND u.dept_code = $${search ? 2 : 1}` : ""} - ${status ? `AND u.status = $${search ? (deptCode ? 3 : 2) : deptCode ? 2 : 1}` : ""} - `; + // 페이징 파라미터 제외한 카운트용 파라미터 + const countParams = [...queryParams]; + + // 총 개수 조회를 위해 기존 쿼리를 COUNT로 변환 + const countQuery = query.replace( + /SELECT[\s\S]*?FROM/i, + "SELECT COUNT(*) as total FROM" + ).replace(/ORDER BY.*$/i, ""); - const countParams = queryParams.slice(0, -2); // 페이징 파라미터 제외 const countResult = await client.query(countQuery, countParams); const totalCount = parseInt(countResult.rows[0].total); @@ -360,14 +448,13 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { const response = { success: true, - data: { - users: processedUsers, - pagination: { - currentPage: Number(page), - countPerPage: Number(countPerPage), - totalCount: totalCount, - totalPages: Math.ceil(totalCount / Number(countPerPage)), - }, + data: processedUsers, + total: totalCount, + searchType, // 검색 타입 정보 (unified, single, advanced, none) + pagination: { + page: Number(page), + limit: Number(countPerPage), + totalPages: Math.ceil(totalCount / Number(countPerPage)), }, message: "사용자 목록 조회 성공", }; @@ -375,6 +462,7 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => { logger.info("사용자 목록 조회 성공", { totalCount, returnedCount: processedUsers.length, + searchType, currentPage: Number(page), countPerPage: Number(countPerPage), }); diff --git a/frontend/components/admin/UserToolbar.tsx b/frontend/components/admin/UserToolbar.tsx index 4e180a84..ff3e455d 100644 --- a/frontend/components/admin/UserToolbar.tsx +++ b/frontend/components/admin/UserToolbar.tsx @@ -1,9 +1,8 @@ -import { Search, Plus } from "lucide-react"; +import { Search, Plus, ChevronDown, ChevronUp } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { UserSearchFilter } from "@/types/user"; -import { SEARCH_OPTIONS } from "@/constants/user"; +import { useState } from "react"; interface UserToolbarProps { searchFilter: UserSearchFilter; @@ -15,7 +14,7 @@ interface UserToolbarProps { /** * 사용자 관리 툴바 컴포넌트 - * 검색, 필터링, 액션 버튼들을 포함 + * 통합 검색 + 고급 검색 옵션 지원 */ export function UserToolbar({ searchFilter, @@ -24,62 +23,197 @@ export function UserToolbar({ onSearchChange, onCreateClick, }: UserToolbarProps) { + const [showAdvancedSearch, setShowAdvancedSearch] = useState(false); + + // 통합 검색어 변경 + const handleUnifiedSearchChange = (value: string) => { + onSearchChange({ + searchValue: value, + // 통합 검색 시 고급 검색 필드들 클리어 + searchType: undefined, + search_sabun: undefined, + search_companyName: undefined, + search_deptName: undefined, + search_positionName: undefined, + search_userId: undefined, + search_userName: undefined, + search_tel: undefined, + search_email: undefined, + }); + }; + + // 고급 검색 필드 변경 + const handleAdvancedSearchChange = (field: string, value: string) => { + onSearchChange({ + [field]: value, + // 고급 검색 시 통합 검색어 클리어 + searchValue: undefined, + }); + }; + + // 고급 검색 모드인지 확인 + const isAdvancedSearchMode = !!( + searchFilter.search_sabun || + searchFilter.search_companyName || + searchFilter.search_deptName || + searchFilter.search_positionName || + searchFilter.search_userId || + searchFilter.search_userName || + searchFilter.search_tel || + searchFilter.search_email + ); + return (
- {/* 검색 필터 영역 */} -
- {/* 검색 대상 선택 */} -
- - handleUnifiedSearchChange(e.target.value)} + disabled={isAdvancedSearchMode} + className={`pl-10 ${isSearching ? "border-blue-300 ring-1 ring-blue-200" : ""} ${ + isAdvancedSearchMode ? "bg-muted text-muted-foreground cursor-not-allowed" : "" + }`} + /> +
+ {isSearching &&

검색 중...

} + {isAdvancedSearchMode && ( +

+ 고급 검색 모드가 활성화되어 있습니다. 통합 검색을 사용하려면 고급 검색 조건을 초기화하세요. +

+ )} +
+ + {/* 고급 검색 토글 버튼 */} +
- {/* 검색어 입력 */} -
- -
- - opt.value === (searchFilter.searchType || "all"))?.label || "전체"}을 입력하세요` - } - value={searchFilter.searchValue || ""} - onChange={(e) => onSearchChange({ searchValue: e.target.value })} - disabled={(searchFilter.searchType || "all") === "all"} - className={`w-[300px] pl-10 ${isSearching ? "border-blue-300 ring-1 ring-blue-200" : ""} ${ - (searchFilter.searchType || "all") === "all" ? "bg-muted text-muted-foreground cursor-not-allowed" : "" - }`} - /> + {/* 고급 검색 옵션 */} + {showAdvancedSearch && ( +
+
+

고급 검색 옵션

+ (각 필드별로 개별 검색 조건을 설정할 수 있습니다) +
+ + {/* 고급 검색 필드들 */} +
+
+ + handleAdvancedSearchChange("search_sabun", e.target.value)} + /> +
+ +
+ + handleAdvancedSearchChange("search_companyName", e.target.value)} + /> +
+ +
+ + handleAdvancedSearchChange("search_deptName", e.target.value)} + /> +
+ +
+ + handleAdvancedSearchChange("search_positionName", e.target.value)} + /> +
+ +
+ + handleAdvancedSearchChange("search_userId", e.target.value)} + /> +
+ +
+ + handleAdvancedSearchChange("search_userName", e.target.value)} + /> +
+ +
+ + handleAdvancedSearchChange("search_tel", e.target.value)} + /> +
+ +
+ + handleAdvancedSearchChange("search_email", e.target.value)} + /> +
+
+ + {/* 고급 검색 초기화 버튼 */} + {isAdvancedSearchMode && ( +
+ +
+ )}
-
+ )}
{/* 액션 버튼 영역 */} diff --git a/frontend/hooks/useUserManagement.ts b/frontend/hooks/useUserManagement.ts index 3492f4fb..bd4e191b 100644 --- a/frontend/hooks/useUserManagement.ts +++ b/frontend/hooks/useUserManagement.ts @@ -15,20 +15,85 @@ export const useUserManagement = () => { // 검색 필터 상태 const [searchFilter, setSearchFilter] = useState({}); - // 검색어만 디바운싱 (500ms 지연) - searchType은 즉시 반영 + // 통합 검색어 디바운싱 (500ms 지연) const debouncedSearchValue = useDebounce(searchFilter.searchValue || "", 500); + // 고급 검색 필드들 디바운싱 + const debouncedSabun = useDebounce(searchFilter.search_sabun || "", 500); + const debouncedCompanyName = useDebounce(searchFilter.search_companyName || "", 500); + const debouncedDeptName = useDebounce(searchFilter.search_deptName || "", 500); + const debouncedPositionName = useDebounce(searchFilter.search_positionName || "", 500); + const debouncedUserId = useDebounce(searchFilter.search_userId || "", 500); + const debouncedUserName = useDebounce(searchFilter.search_userName || "", 500); + const debouncedTel = useDebounce(searchFilter.search_tel || "", 500); + const debouncedEmail = useDebounce(searchFilter.search_email || "", 500); + // 디바운싱된 검색 필터 (useMemo로 최적화) const debouncedSearchFilter = useMemo( () => ({ + // 통합 검색 searchValue: debouncedSearchValue, + + // 고급 검색 + search_sabun: debouncedSabun, + search_companyName: debouncedCompanyName, + search_deptName: debouncedDeptName, + search_positionName: debouncedPositionName, + search_userId: debouncedUserId, + search_userName: debouncedUserName, + search_tel: debouncedTel, + search_email: debouncedEmail, + + // 하위 호환성 searchType: searchFilter.searchType || "all", }), - [debouncedSearchValue, searchFilter.searchType], + [ + debouncedSearchValue, + debouncedSabun, + debouncedCompanyName, + debouncedDeptName, + debouncedPositionName, + debouncedUserId, + debouncedUserName, + debouncedTel, + debouncedEmail, + searchFilter.searchType, + ], ); - // 검색 중인지 확인 (검색어만 체크) - const isSearching = (searchFilter.searchValue || "") !== debouncedSearchValue; + // 검색 중인지 확인 (모든 검색 필드를 고려) + const isSearching = useMemo(() => { + return ( + (searchFilter.searchValue || "") !== debouncedSearchValue || + (searchFilter.search_sabun || "") !== debouncedSabun || + (searchFilter.search_companyName || "") !== debouncedCompanyName || + (searchFilter.search_deptName || "") !== debouncedDeptName || + (searchFilter.search_positionName || "") !== debouncedPositionName || + (searchFilter.search_userId || "") !== debouncedUserId || + (searchFilter.search_userName || "") !== debouncedUserName || + (searchFilter.search_tel || "") !== debouncedTel || + (searchFilter.search_email || "") !== debouncedEmail + ); + }, [ + searchFilter.searchValue, + debouncedSearchValue, + searchFilter.search_sabun, + debouncedSabun, + searchFilter.search_companyName, + debouncedCompanyName, + searchFilter.search_deptName, + debouncedDeptName, + searchFilter.search_positionName, + debouncedPositionName, + searchFilter.search_userId, + debouncedUserId, + searchFilter.search_userName, + debouncedUserName, + searchFilter.search_tel, + debouncedTel, + searchFilter.search_email, + debouncedEmail, + ]); // 로딩 및 에러 상태 const [isLoading, setIsLoading] = useState(false); @@ -39,52 +104,55 @@ export const useUserManagement = () => { const [pageSize, setPageSize] = useState(20); const [totalItems, setTotalItems] = useState(0); - // 사용자 목록 로드 (특정 검색 조건으로 호출) + // 사용자 목록 로드 (새로운 통합 검색 방식) const loadUsers = useCallback( - async (searchValue?: string, searchType?: string) => { + async (filter?: UserSearchFilter) => { setIsLoading(true); setError(null); try { - // 백엔드 API 호출 - // 검색 파라미터 구성 (단순 검색 방식) + // 검색 파라미터 구성 const searchParams: Record = { page: currentPage, countPerPage: pageSize, }; - // 검색어가 있고 searchType이 'all'이 아닐 때만 검색 파라미터 추가 - if (searchValue && searchValue.trim() && searchType && searchType !== "all") { - const trimmedValue = searchValue.trim(); + // 검색 조건 추가 + if (filter) { + // 통합 검색 (우선순위 최고) + if (filter.searchValue && filter.searchValue.trim()) { + searchParams.search = filter.searchValue.trim(); + } - // 각 검색 타입별로 해당하는 백엔드 파라미터 매핑 (백엔드 MyBatis와 정확히 일치) - switch (searchType) { - case "sabun": - searchParams.search_sabun = trimmedValue; - break; - case "company_name": - searchParams.search_companyName = trimmedValue; // MyBatis: search_companyName - break; - case "dept_name": - searchParams.search_deptName = trimmedValue; // MyBatis: search_deptName - break; - case "position_name": - searchParams.search_positionName = trimmedValue; // MyBatis: search_positionName - break; - case "user_id": - searchParams.search_userId = trimmedValue; // MyBatis: search_userId - break; - case "user_name": - searchParams.search_userName = trimmedValue; // MyBatis: search_userName - break; - case "tel": - searchParams.search_tel = trimmedValue; // MyBatis: search_tel - break; - case "email": - searchParams.search_email = trimmedValue; // MyBatis: search_email - break; - default: - searchParams.search_userName = trimmedValue; // 기본값 + // 고급 검색 (개별 필드별) + if (filter.search_sabun && filter.search_sabun.trim()) { + searchParams.search_sabun = filter.search_sabun.trim(); + } + if (filter.search_companyName && filter.search_companyName.trim()) { + searchParams.search_companyName = filter.search_companyName.trim(); + } + if (filter.search_deptName && filter.search_deptName.trim()) { + searchParams.search_deptName = filter.search_deptName.trim(); + } + if (filter.search_positionName && filter.search_positionName.trim()) { + searchParams.search_positionName = filter.search_positionName.trim(); + } + if (filter.search_userId && filter.search_userId.trim()) { + searchParams.search_userId = filter.search_userId.trim(); + } + if (filter.search_userName && filter.search_userName.trim()) { + searchParams.search_userName = filter.search_userName.trim(); + } + if (filter.search_tel && filter.search_tel.trim()) { + searchParams.search_tel = filter.search_tel.trim(); + } + if (filter.search_email && filter.search_email.trim()) { + searchParams.search_email = filter.search_email.trim(); + } + + // 하위 호환성: 기존 searchType/searchValue 방식 지원 + if (!filter.searchValue && filter.searchType && filter.searchType !== "all" && searchParams.searchValue) { + // 기존 방식 변환은 일단 제거 (통합 검색과 고급 검색만 지원) } } @@ -125,30 +193,41 @@ export const useUserManagement = () => { loadUsers(); }, [loadUsers]); - // 검색어 변경 시에만 API 호출 (검색어가 있고 'all'이 아닐 때) + // 디바운싱된 검색 조건이 변경될 때마다 API 호출 useEffect(() => { - if ( - debouncedSearchFilter.searchValue && - debouncedSearchFilter.searchValue.trim() && - debouncedSearchFilter.searchType !== "all" - ) { - loadUsers(debouncedSearchFilter.searchValue, debouncedSearchFilter.searchType); - } - }, [debouncedSearchFilter.searchValue, loadUsers]); - - // '전체' 선택 시에만 즉시 반영 - useEffect(() => { - if (searchFilter.searchType === "all" && !searchFilter.searchValue) { - loadUsers(); // 전체 목록 로드 (검색 조건 없음) - } - }, [searchFilter.searchType, loadUsers]); + loadUsers(debouncedSearchFilter); + }, [ + debouncedSearchFilter.searchValue, + debouncedSearchFilter.search_sabun, + debouncedSearchFilter.search_companyName, + debouncedSearchFilter.search_deptName, + debouncedSearchFilter.search_positionName, + debouncedSearchFilter.search_userId, + debouncedSearchFilter.search_userName, + debouncedSearchFilter.search_tel, + debouncedSearchFilter.search_email, + loadUsers, + ]); // 검색 필터 업데이트 const updateSearchFilter = useCallback((newFilter: Partial) => { setSearchFilter((prev) => ({ ...prev, ...newFilter })); - // searchType이 변경되거나 searchValue가 변경될 때만 첫 페이지로 이동 - if (newFilter.searchType !== undefined || newFilter.searchValue !== undefined) { + // 검색 조건이 변경될 때마다 첫 페이지로 이동 + const hasSearchChange = !!( + newFilter.searchValue !== undefined || + newFilter.search_sabun !== undefined || + newFilter.search_companyName !== undefined || + newFilter.search_deptName !== undefined || + newFilter.search_positionName !== undefined || + newFilter.search_userId !== undefined || + newFilter.search_userName !== undefined || + newFilter.search_tel !== undefined || + newFilter.search_email !== undefined || + newFilter.searchType !== undefined + ); + + if (hasSearchChange) { setCurrentPage(1); } }, []); diff --git a/frontend/lib/api/user.ts b/frontend/lib/api/user.ts index b34eb71c..809fd9f7 100644 --- a/frontend/lib/api/user.ts +++ b/frontend/lib/api/user.ts @@ -10,6 +10,12 @@ interface ApiResponse { message?: string; errorCode?: string; total?: number; + searchType?: "unified" | "single" | "advanced" | "none"; // 검색 타입 정보 + pagination?: { + page: number; + limit: number; + totalPages: number; + }; // 백엔드 호환성을 위한 추가 필드 result?: boolean; msg?: string; diff --git a/frontend/types/user.ts b/frontend/types/user.ts index 2361583c..d9036ae8 100644 --- a/frontend/types/user.ts +++ b/frontend/types/user.ts @@ -30,8 +30,21 @@ export interface User { // 사용자 검색 필터 export interface UserSearchFilter { - searchType?: "all" | "sabun" | "companyCode" | "deptName" | "positionName" | "userId" | "userName" | "tel" | "email"; // 검색 대상 - searchValue?: string; // 검색어 + // 통합 검색 (우선순위: 가장 높음) + searchValue?: string; // 통합 검색어 (모든 필드 대상) + + // 단일 필드 검색 (중간 우선순위) + searchType?: "all" | "sabun" | "companyCode" | "deptName" | "positionName" | "userId" | "userName" | "tel" | "email"; // 검색 대상 (하위 호환성) + + // 고급 검색 (개별 필드별 AND 조건) + search_sabun?: string; // 사번 검색 + search_companyName?: string; // 회사명 검색 + search_deptName?: string; // 부서명 검색 + search_positionName?: string; // 직책 검색 + search_userId?: string; // 사용자 ID 검색 + search_userName?: string; // 사용자명 검색 + search_tel?: string; // 전화번호 검색 (TEL + CELL_PHONE) + search_email?: string; // 이메일 검색 } // 사용자 목록 테이블 컬럼 정의 -- 2.43.0