사용자 검색 기능 구현

This commit is contained in:
dohyeons
2025-08-26 14:23:22 +09:00
parent 6f68fa5639
commit 7267cc52eb
5 changed files with 473 additions and 153 deletions

View File

@@ -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 (
<div className="space-y-4">
{/* 검색 필터 영역 */}
<div className="bg-muted/30 flex flex-wrap gap-4 rounded-lg p-4">
{/* 검색 대상 선택 */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium"> </label>
<Select
value={searchFilter.searchType || "all"}
onValueChange={(value) =>
onSearchChange({
searchType: value as (typeof SEARCH_OPTIONS)[number]["value"],
searchValue: "", // 옵션 변경 시 항상 검색어 초기화
})
}
{/* 메인 검색 영역 */}
<div className="bg-muted/30 rounded-lg p-4">
{/* 통합 검색 */}
<div className="mb-4 flex items-center gap-4">
<div className="flex-1">
<div className="relative">
<Search
className={`absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform ${
isSearching ? "animate-pulse text-blue-500" : "text-muted-foreground"
}`}
/>
<Input
placeholder="통합 검색..."
value={searchFilter.searchValue || ""}
onChange={(e) => 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" : ""
}`}
/>
</div>
{isSearching && <p className="mt-1 text-xs text-blue-500"> ...</p>}
{isAdvancedSearchMode && (
<p className="mt-1 text-xs text-amber-600">
. .
</p>
)}
</div>
{/* 고급 검색 토글 버튼 */}
<Button
variant="outline"
size="sm"
onClick={() => setShowAdvancedSearch(!showAdvancedSearch)}
className="gap-2"
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{SEARCH_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
🔍
{showAdvancedSearch ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button>
</div>
{/* 검색어 입력 */}
<div className="flex flex-col gap-2">
<label className="text-sm font-medium">
{isSearching && <span className="ml-1 text-xs text-blue-500">( ...)</span>}
</label>
<div className="relative">
<Search
className={`absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 transform ${
isSearching ? "animate-pulse text-blue-500" : "text-muted-foreground"
}`}
/>
<Input
placeholder={
(searchFilter.searchType || "all") === "all"
? "전체 목록을 조회합니다"
: `${SEARCH_OPTIONS.find((opt) => 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 && (
<div className="border-t pt-4">
<div className="mb-3">
<h4 className="text-sm font-medium"> </h4>
<span className="text-muted-foreground text-xs">( )</span>
</div>
{/* 고급 검색 필드들 */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="사번 검색"
value={searchFilter.search_sabun || ""}
onChange={(e) => handleAdvancedSearchChange("search_sabun", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="회사명 검색"
value={searchFilter.search_companyName || ""}
onChange={(e) => handleAdvancedSearchChange("search_companyName", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="부서명 검색"
value={searchFilter.search_deptName || ""}
onChange={(e) => handleAdvancedSearchChange("search_deptName", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="직책 검색"
value={searchFilter.search_positionName || ""}
onChange={(e) => handleAdvancedSearchChange("search_positionName", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"> ID</label>
<Input
placeholder="사용자 ID 검색"
value={searchFilter.search_userId || ""}
onChange={(e) => handleAdvancedSearchChange("search_userId", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="사용자명 검색"
value={searchFilter.search_userName || ""}
onChange={(e) => handleAdvancedSearchChange("search_userName", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="전화번호/휴대폰 검색"
value={searchFilter.search_tel || ""}
onChange={(e) => handleAdvancedSearchChange("search_tel", e.target.value)}
/>
</div>
<div>
<label className="text-muted-foreground mb-1 block text-xs font-medium"></label>
<Input
placeholder="이메일 검색"
value={searchFilter.search_email || ""}
onChange={(e) => handleAdvancedSearchChange("search_email", e.target.value)}
/>
</div>
</div>
{/* 고급 검색 초기화 버튼 */}
{isAdvancedSearchMode && (
<div className="mt-4 border-t pt-2">
<Button
variant="ghost"
onClick={() =>
onSearchChange({
search_sabun: undefined,
search_companyName: undefined,
search_deptName: undefined,
search_positionName: undefined,
search_userId: undefined,
search_userName: undefined,
search_tel: undefined,
search_email: undefined,
})
}
className="text-muted-foreground hover:text-foreground"
>
</Button>
</div>
)}
</div>
</div>
)}
</div>
{/* 액션 버튼 영역 */}

View File

@@ -15,20 +15,85 @@ export const useUserManagement = () => {
// 검색 필터 상태
const [searchFilter, setSearchFilter] = useState<UserSearchFilter>({});
// 검색어 디바운싱 (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<string, string | number | undefined> = {
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<UserSearchFilter>) => {
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);
}
}, []);

View File

@@ -10,6 +10,12 @@ interface ApiResponse<T> {
message?: string;
errorCode?: string;
total?: number;
searchType?: "unified" | "single" | "advanced" | "none"; // 검색 타입 정보
pagination?: {
page: number;
limit: number;
totalPages: number;
};
// 백엔드 호환성을 위한 추가 필드
result?: boolean;
msg?: string;

View File

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