226 lines
7.5 KiB
TypeScript
226 lines
7.5 KiB
TypeScript
"use client";
|
|
|
|
import React, { useState, useEffect, useRef } from "react";
|
|
import { Input } from "@/components/ui/input";
|
|
import { X, Loader2, ChevronDown } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { useEntitySearch } from "../entity-search-input/useEntitySearch";
|
|
import { EntitySearchResult } from "../entity-search-input/types";
|
|
import { cn } from "@/lib/utils";
|
|
import { AutocompleteSearchInputConfig, FieldMapping } from "./types";
|
|
|
|
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
|
|
config?: AutocompleteSearchInputConfig;
|
|
filterCondition?: Record<string, any>;
|
|
disabled?: boolean;
|
|
value?: any;
|
|
onChange?: (value: any, fullData?: any) => void;
|
|
className?: string;
|
|
}
|
|
|
|
export function AutocompleteSearchInputComponent({
|
|
config,
|
|
tableName: propTableName,
|
|
displayField: propDisplayField,
|
|
valueField: propValueField,
|
|
searchFields: propSearchFields,
|
|
filterCondition = {},
|
|
placeholder: propPlaceholder,
|
|
disabled = false,
|
|
value,
|
|
onChange,
|
|
showAdditionalInfo: propShowAdditionalInfo,
|
|
additionalFields: propAdditionalFields,
|
|
className,
|
|
}: AutocompleteSearchInputProps) {
|
|
// config prop 우선, 없으면 개별 prop 사용
|
|
const tableName = config?.tableName || propTableName || "";
|
|
const displayField = config?.displayField || propDisplayField || "";
|
|
const valueField = config?.valueField || propValueField || "";
|
|
const searchFields = config?.searchFields || propSearchFields || [displayField];
|
|
const placeholder = config?.placeholder || propPlaceholder || "검색...";
|
|
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
|
|
const additionalFields = config?.additionalFields || propAdditionalFields || [];
|
|
const [inputValue, setInputValue] = useState("");
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const { searchText, setSearchText, results, loading, clearSearch } = useEntitySearch({
|
|
tableName,
|
|
searchFields,
|
|
filterCondition,
|
|
});
|
|
|
|
// value가 변경되면 표시값 업데이트
|
|
useEffect(() => {
|
|
if (value && selectedData) {
|
|
setInputValue(selectedData[displayField] || "");
|
|
} else if (!value) {
|
|
setInputValue("");
|
|
setSelectedData(null);
|
|
}
|
|
}, [value, displayField]);
|
|
|
|
// 외부 클릭 감지
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setIsOpen(false);
|
|
}
|
|
};
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
}, []);
|
|
|
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const newValue = e.target.value;
|
|
setInputValue(newValue);
|
|
setSearchText(newValue);
|
|
setIsOpen(true);
|
|
};
|
|
|
|
// 필드 자동 매핑 처리
|
|
const applyFieldMappings = (item: EntitySearchResult) => {
|
|
if (!config?.enableFieldMapping || !config?.fieldMappings) {
|
|
return;
|
|
}
|
|
|
|
config.fieldMappings.forEach((mapping: FieldMapping) => {
|
|
if (!mapping.sourceField || !mapping.targetField) {
|
|
return;
|
|
}
|
|
|
|
const value = item[mapping.sourceField];
|
|
|
|
// DOM에서 타겟 필드 찾기 (id로 검색)
|
|
const targetElement = document.getElementById(mapping.targetField);
|
|
|
|
if (targetElement) {
|
|
// input, textarea 등의 값 설정
|
|
if (
|
|
targetElement instanceof HTMLInputElement ||
|
|
targetElement instanceof HTMLTextAreaElement
|
|
) {
|
|
targetElement.value = value?.toString() || "";
|
|
|
|
// React의 change 이벤트 트리거
|
|
const event = new Event("input", { bubbles: true });
|
|
targetElement.dispatchEvent(event);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
const handleSelect = (item: EntitySearchResult) => {
|
|
setSelectedData(item);
|
|
setInputValue(item[displayField] || "");
|
|
onChange?.(item[valueField], item);
|
|
|
|
// 필드 자동 매핑 실행
|
|
applyFieldMappings(item);
|
|
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleClear = () => {
|
|
setInputValue("");
|
|
setSelectedData(null);
|
|
onChange?.(null, null);
|
|
setIsOpen(false);
|
|
};
|
|
|
|
const handleInputFocus = () => {
|
|
// 포커스 시 항상 검색 실행 (빈 값이면 전체 목록)
|
|
if (!selectedData) {
|
|
setSearchText(inputValue || "");
|
|
setIsOpen(true);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className={cn("relative", className)} ref={containerRef}>
|
|
{/* 입력 필드 */}
|
|
<div className="relative">
|
|
<Input
|
|
value={inputValue}
|
|
onChange={handleInputChange}
|
|
onFocus={handleInputFocus}
|
|
placeholder={placeholder}
|
|
disabled={disabled}
|
|
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
|
|
/>
|
|
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
|
{loading && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
{inputValue && !disabled && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={handleClear}
|
|
className="h-6 w-6 p-0"
|
|
>
|
|
<X className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
|
</div>
|
|
</div>
|
|
|
|
{/* 드롭다운 결과 */}
|
|
{isOpen && (results.length > 0 || loading) && (
|
|
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-[300px] overflow-y-auto">
|
|
{loading && results.length === 0 ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin mx-auto mb-2" />
|
|
검색 중...
|
|
</div>
|
|
) : results.length === 0 ? (
|
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
|
검색 결과가 없습니다
|
|
</div>
|
|
) : (
|
|
<div className="py-1">
|
|
{results.map((item, index) => (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
onClick={() => handleSelect(item)}
|
|
className="w-full text-left px-3 py-2 hover:bg-accent text-xs sm:text-sm transition-colors"
|
|
>
|
|
<div className="font-medium">{item[displayField]}</div>
|
|
{additionalFields.length > 0 && (
|
|
<div className="text-xs text-muted-foreground mt-1 space-y-0.5">
|
|
{additionalFields.map((field) => (
|
|
<div key={field}>
|
|
{field}: {item[field] || "-"}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 추가 정보 표시 */}
|
|
{showAdditionalInfo && selectedData && additionalFields.length > 0 && (
|
|
<div className="mt-2 text-xs text-muted-foreground space-y-1 px-2">
|
|
{additionalFields.map((field) => (
|
|
<div key={field} className="flex gap-2">
|
|
<span className="font-medium">{field}:</span>
|
|
<span>{selectedData[field] || "-"}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|