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; // 이메일 검색 } // 사용자 목록 테이블 컬럼 정의