224 lines
6.8 KiB
TypeScript
224 lines
6.8 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import React, { useState, useEffect } from "react";
|
||
|
|
import {
|
||
|
|
Dialog,
|
||
|
|
DialogContent,
|
||
|
|
DialogDescription,
|
||
|
|
DialogFooter,
|
||
|
|
DialogHeader,
|
||
|
|
DialogTitle,
|
||
|
|
} from "@/components/ui/dialog";
|
||
|
|
import { Button } from "@/components/ui/button";
|
||
|
|
import { Input } from "@/components/ui/input";
|
||
|
|
import { Search, Loader2 } from "lucide-react";
|
||
|
|
import { useEntitySearch } from "./useEntitySearch";
|
||
|
|
import { EntitySearchResult } from "./types";
|
||
|
|
|
||
|
|
interface EntitySearchModalProps {
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
tableName: string;
|
||
|
|
displayField: string;
|
||
|
|
valueField: string;
|
||
|
|
searchFields?: string[];
|
||
|
|
filterCondition?: Record<string, any>;
|
||
|
|
modalTitle?: string;
|
||
|
|
modalColumns?: string[];
|
||
|
|
onSelect: (value: any, fullData: EntitySearchResult) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function EntitySearchModal({
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
tableName,
|
||
|
|
displayField,
|
||
|
|
valueField,
|
||
|
|
searchFields = [displayField],
|
||
|
|
filterCondition = {},
|
||
|
|
modalTitle = "검색",
|
||
|
|
modalColumns = [],
|
||
|
|
onSelect,
|
||
|
|
}: EntitySearchModalProps) {
|
||
|
|
const [localSearchText, setLocalSearchText] = useState("");
|
||
|
|
const {
|
||
|
|
results,
|
||
|
|
loading,
|
||
|
|
error,
|
||
|
|
pagination,
|
||
|
|
search,
|
||
|
|
clearSearch,
|
||
|
|
loadMore,
|
||
|
|
} = useEntitySearch({
|
||
|
|
tableName,
|
||
|
|
searchFields,
|
||
|
|
filterCondition,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 모달 열릴 때 초기 검색
|
||
|
|
useEffect(() => {
|
||
|
|
if (open) {
|
||
|
|
search("", 1); // 빈 검색어로 전체 목록 조회
|
||
|
|
} else {
|
||
|
|
clearSearch();
|
||
|
|
setLocalSearchText("");
|
||
|
|
}
|
||
|
|
}, [open]);
|
||
|
|
|
||
|
|
const handleSearch = () => {
|
||
|
|
search(localSearchText, 1);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleSelect = (item: EntitySearchResult) => {
|
||
|
|
onSelect(item[valueField], item);
|
||
|
|
onOpenChange(false);
|
||
|
|
};
|
||
|
|
|
||
|
|
// 표시할 컬럼 결정
|
||
|
|
const displayColumns = modalColumns.length > 0 ? modalColumns : [displayField];
|
||
|
|
|
||
|
|
return (
|
||
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
|
|
<DialogContent className="max-w-[95vw] sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||
|
|
<DialogHeader>
|
||
|
|
<DialogTitle className="text-base sm:text-lg">{modalTitle}</DialogTitle>
|
||
|
|
<DialogDescription className="text-xs sm:text-sm">
|
||
|
|
항목을 검색하고 선택하세요
|
||
|
|
</DialogDescription>
|
||
|
|
</DialogHeader>
|
||
|
|
|
||
|
|
{/* 검색 입력 */}
|
||
|
|
<div className="flex gap-2">
|
||
|
|
<Input
|
||
|
|
placeholder="검색어를 입력하세요"
|
||
|
|
value={localSearchText}
|
||
|
|
onChange={(e) => setLocalSearchText(e.target.value)}
|
||
|
|
onKeyDown={(e) => {
|
||
|
|
if (e.key === "Enter") {
|
||
|
|
handleSearch();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
/>
|
||
|
|
<Button
|
||
|
|
onClick={handleSearch}
|
||
|
|
disabled={loading}
|
||
|
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||
|
|
>
|
||
|
|
{loading ? (
|
||
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||
|
|
) : (
|
||
|
|
<Search className="h-4 w-4" />
|
||
|
|
)}
|
||
|
|
<span className="ml-2">검색</span>
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 오류 메시지 */}
|
||
|
|
{error && (
|
||
|
|
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
||
|
|
{error}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 검색 결과 테이블 */}
|
||
|
|
<div className="border rounded-md overflow-hidden">
|
||
|
|
<div className="overflow-x-auto">
|
||
|
|
<table className="w-full text-xs sm:text-sm">
|
||
|
|
<thead className="bg-muted">
|
||
|
|
<tr>
|
||
|
|
{displayColumns.map((col) => (
|
||
|
|
<th
|
||
|
|
key={col}
|
||
|
|
className="px-4 py-2 text-left font-medium text-muted-foreground"
|
||
|
|
>
|
||
|
|
{col}
|
||
|
|
</th>
|
||
|
|
))}
|
||
|
|
<th className="px-4 py-2 text-left font-medium text-muted-foreground w-24">
|
||
|
|
선택
|
||
|
|
</th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{loading && results.length === 0 ? (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center">
|
||
|
|
<Loader2 className="h-6 w-6 animate-spin mx-auto" />
|
||
|
|
<p className="mt-2 text-muted-foreground">검색 중...</p>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
) : results.length === 0 ? (
|
||
|
|
<tr>
|
||
|
|
<td colSpan={displayColumns.length + 1} className="px-4 py-8 text-center text-muted-foreground">
|
||
|
|
검색 결과가 없습니다
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
) : (
|
||
|
|
results.map((item, index) => (
|
||
|
|
<tr
|
||
|
|
key={item[valueField] || index}
|
||
|
|
className="border-t hover:bg-accent cursor-pointer transition-colors"
|
||
|
|
onClick={() => handleSelect(item)}
|
||
|
|
>
|
||
|
|
{displayColumns.map((col, colIndex) => (
|
||
|
|
<td key={`${item[valueField] || index}-${col}-${colIndex}`} className="px-4 py-2">
|
||
|
|
{item[col] || "-"}
|
||
|
|
</td>
|
||
|
|
))}
|
||
|
|
<td className="px-4 py-2">
|
||
|
|
<Button
|
||
|
|
size="sm"
|
||
|
|
variant="outline"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleSelect(item);
|
||
|
|
}}
|
||
|
|
className="h-7 text-xs"
|
||
|
|
>
|
||
|
|
선택
|
||
|
|
</Button>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
))
|
||
|
|
)}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 페이지네이션 정보 */}
|
||
|
|
{results.length > 0 && (
|
||
|
|
<div className="flex justify-between items-center text-xs sm:text-sm text-muted-foreground">
|
||
|
|
<span>
|
||
|
|
전체 {pagination.total}개 중 {results.length}개 표시
|
||
|
|
</span>
|
||
|
|
{pagination.page * pagination.limit < pagination.total && (
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
size="sm"
|
||
|
|
onClick={loadMore}
|
||
|
|
disabled={loading}
|
||
|
|
className="h-7 text-xs"
|
||
|
|
>
|
||
|
|
더 보기
|
||
|
|
</Button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
<DialogFooter className="gap-2 sm:gap-0">
|
||
|
|
<Button
|
||
|
|
variant="outline"
|
||
|
|
onClick={() => onOpenChange(false)}
|
||
|
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||
|
|
>
|
||
|
|
취소
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|