ERP-node/frontend/lib/registry/components/autocomplete-search-input/AutocompleteSearchInputComp...

268 lines
9.0 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 } from "./types";
import { ComponentRendererProps } from "../../DynamicComponentRenderer";
export interface AutocompleteSearchInputProps extends ComponentRendererProps {
config?: AutocompleteSearchInputConfig;
tableName?: string;
displayField?: string;
valueField?: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
placeholder?: string;
showAdditionalInfo?: boolean;
additionalFields?: string[];
}
export function AutocompleteSearchInputComponent({
component,
config,
tableName: propTableName,
displayField: propDisplayField,
valueField: propValueField,
searchFields: propSearchFields,
filterCondition = {},
placeholder: propPlaceholder,
disabled = false,
value,
onChange,
className,
isInteractive = false,
onFormDataChange,
formData,
}: AutocompleteSearchInputProps) {
// config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || "";
const displayField = config?.displayField || propDisplayField || "";
const displayFields = config?.displayFields || (displayField ? [displayField] : []); // 다중 표시 필드
const displaySeparator = config?.displaySeparator || " → "; // 구분자
const valueField = config?.valueField || propValueField || "";
const searchFields = config?.searchFields || propSearchFields || displayFields; // 검색 필드도 다중 표시 필드 사용
const placeholder = config?.placeholder || propPlaceholder || "검색...";
// 다중 필드 값을 조합하여 표시 문자열 생성
const getDisplayValue = (item: EntitySearchResult): string => {
if (displayFields.length > 1) {
// 여러 필드를 구분자로 조합
const values = displayFields
.map((field) => item[field])
.filter((v) => v !== null && v !== undefined && v !== "")
.map((v) => String(v));
return values.join(displaySeparator);
}
// 단일 필드
return item[displayField] || "";
};
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,
});
// 선택된 데이터를 ref로도 유지 (리렌더링 시 초기화 방지)
const selectedDataRef = useRef<EntitySearchResult | null>(null);
const inputValueRef = useRef<string>("");
// formData에서 현재 값 가져오기 (isInteractive 모드)
const currentValue = isInteractive && formData && component?.columnName
? formData[component.columnName]
: value;
// selectedData 변경 시 ref도 업데이트
useEffect(() => {
if (selectedData) {
selectedDataRef.current = selectedData;
inputValueRef.current = inputValue;
}
}, [selectedData, inputValue]);
// 리렌더링 시 ref에서 값 복원
useEffect(() => {
if (!selectedData && selectedDataRef.current) {
setSelectedData(selectedDataRef.current);
setInputValue(inputValueRef.current);
}
}, []);
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
useEffect(() => {
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
if (selectedData || selectedDataRef.current) {
return;
}
if (!currentValue) {
setInputValue("");
}
}, [currentValue, selectedData]);
// 외부 클릭 감지
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 handleSelect = (item: EntitySearchResult) => {
setSelectedData(item);
setInputValue(getDisplayValue(item));
console.log("🔍 AutocompleteSearchInput handleSelect:", {
item,
valueField,
value: item[valueField],
config,
isInteractive,
hasOnFormDataChange: !!onFormDataChange,
columnName: component?.columnName,
});
// isInteractive 모드에서만 저장
if (isInteractive && onFormDataChange) {
// 필드 매핑 처리
if (config?.fieldMappings && Array.isArray(config.fieldMappings)) {
console.log("📋 필드 매핑 처리 시작:", config.fieldMappings);
config.fieldMappings.forEach((mapping: any, index: number) => {
const targetField = mapping.targetField || mapping.targetColumn;
console.log(` 매핑 ${index + 1}:`, {
sourceField: mapping.sourceField,
targetField,
label: mapping.label,
});
if (mapping.sourceField && targetField) {
const sourceValue = item[mapping.sourceField];
console.log(` 값: ${mapping.sourceField} = ${sourceValue}`);
if (sourceValue !== undefined) {
console.log(` ✅ 저장: ${targetField} = ${sourceValue}`);
onFormDataChange(targetField, sourceValue);
} else {
console.warn(` ⚠️ sourceField "${mapping.sourceField}"의 값이 undefined입니다`);
}
} else {
console.warn(` ⚠️ 매핑 불완전: sourceField=${mapping.sourceField}, targetField=${targetField}`);
}
});
}
// 기본 필드 저장 (columnName이 설정된 경우)
if (component?.columnName) {
console.log(`💾 기본 필드 저장: ${component.columnName} = ${item[valueField]}`);
onFormDataChange(component.columnName, item[valueField]);
}
}
// onChange 콜백 호출 (호환성)
onChange?.(item[valueField], 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 pr-16 text-xs sm:h-10 sm:text-sm !bg-background"
/>
<div className="absolute right-1 top-1/2 flex -translate-y-1/2 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-[100] mt-1 max-h-[300px] w-full overflow-y-auto rounded-md border bg-background shadow-lg">
{loading && results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
<Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
...
</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 px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
>
<div className="font-medium">{getDisplayValue(item)}</div>
</button>
))}
</div>
)}
</div>
)}
</div>
);
}