405 lines
14 KiB
TypeScript
405 lines
14 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 || " → "; // 구분자
|
|
|
|
// valueField 결정: fieldMappings 기반으로 추론 (config.valueField가 fieldMappings에 없으면 무시)
|
|
const getValueField = () => {
|
|
// fieldMappings가 있으면 그 안에서 추론 (가장 신뢰할 수 있는 소스)
|
|
if (config?.fieldMappings && config.fieldMappings.length > 0) {
|
|
// config.valueField가 fieldMappings의 sourceField에 있으면 사용
|
|
if (config?.valueField) {
|
|
const hasValueFieldInMappings = config.fieldMappings.some(
|
|
(m: any) => m.sourceField === config.valueField
|
|
);
|
|
if (hasValueFieldInMappings) {
|
|
return config.valueField;
|
|
}
|
|
// fieldMappings에 없으면 무시하고 추론
|
|
}
|
|
|
|
// _code 또는 _id로 끝나는 필드 우선 (보통 PK나 코드 필드)
|
|
const codeMapping = config.fieldMappings.find(
|
|
(m: any) => m.sourceField?.endsWith("_code") || m.sourceField?.endsWith("_id")
|
|
);
|
|
if (codeMapping) {
|
|
return codeMapping.sourceField;
|
|
}
|
|
|
|
// 없으면 첫 번째 매핑 사용
|
|
return config.fieldMappings[0].sourceField || "";
|
|
}
|
|
|
|
// fieldMappings가 없으면 기존 방식
|
|
if (config?.valueField) return config.valueField;
|
|
if (propValueField) return propValueField;
|
|
|
|
return "";
|
|
};
|
|
const valueField = getValueField();
|
|
|
|
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>("");
|
|
const initialValueLoadedRef = useRef<string | null>(null); // 초기값 로드 추적
|
|
|
|
// formData에서 현재 값 가져오기 (isInteractive 모드)
|
|
// 우선순위: 1) component.columnName, 2) fieldMappings에서 valueField에 매핑된 targetField
|
|
const getCurrentValue = () => {
|
|
if (!isInteractive || !formData) {
|
|
return value;
|
|
}
|
|
|
|
// 1. component.columnName으로 직접 바인딩된 경우
|
|
if (component?.columnName && formData[component.columnName] !== undefined) {
|
|
return formData[component.columnName];
|
|
}
|
|
|
|
// 2. fieldMappings에서 valueField와 매핑된 targetField에서 값 가져오기
|
|
if (config?.fieldMappings && Array.isArray(config.fieldMappings)) {
|
|
const valueFieldMapping = config.fieldMappings.find(
|
|
(mapping: any) => mapping.sourceField === valueField
|
|
);
|
|
|
|
if (valueFieldMapping) {
|
|
const targetField = valueFieldMapping.targetField || valueFieldMapping.targetColumn;
|
|
|
|
if (targetField && formData[targetField] !== undefined) {
|
|
return formData[targetField];
|
|
}
|
|
}
|
|
}
|
|
|
|
return value;
|
|
};
|
|
|
|
const currentValue = getCurrentValue();
|
|
|
|
// 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);
|
|
}
|
|
}, []);
|
|
|
|
// 초기값이 있을 때 해당 값의 표시 텍스트를 조회하여 설정
|
|
useEffect(() => {
|
|
const loadInitialDisplayValue = async () => {
|
|
// 이미 로드된 값이거나, 값이 없거나, 이미 선택된 데이터가 있으면 스킵
|
|
if (!currentValue || selectedData || selectedDataRef.current) {
|
|
return;
|
|
}
|
|
|
|
// 이미 같은 값을 로드한 적이 있으면 스킵
|
|
if (initialValueLoadedRef.current === currentValue) {
|
|
return;
|
|
}
|
|
|
|
// 테이블명과 필드 정보가 없으면 스킵
|
|
if (!tableName || !valueField) {
|
|
return;
|
|
}
|
|
|
|
console.log("🔄 AutocompleteSearchInput 초기값 로드:", {
|
|
currentValue,
|
|
tableName,
|
|
valueField,
|
|
displayFields,
|
|
});
|
|
|
|
try {
|
|
// API를 통해 해당 값의 표시 텍스트 조회
|
|
const { apiClient } = await import("@/lib/api/client");
|
|
const filterConditionWithValue = {
|
|
...filterCondition,
|
|
[valueField]: currentValue,
|
|
};
|
|
|
|
const params = new URLSearchParams({
|
|
searchText: "",
|
|
searchFields: searchFields.join(","),
|
|
filterCondition: JSON.stringify(filterConditionWithValue),
|
|
page: "1",
|
|
limit: "10",
|
|
});
|
|
|
|
const response = await apiClient.get<{ success: boolean; data: EntitySearchResult[] }>(
|
|
`/entity-search/${tableName}?${params.toString()}`
|
|
);
|
|
|
|
if (response.data.success && response.data.data && response.data.data.length > 0) {
|
|
const matchedItem = response.data.data.find((item: EntitySearchResult) =>
|
|
String(item[valueField]) === String(currentValue)
|
|
);
|
|
|
|
if (matchedItem) {
|
|
const displayText = getDisplayValue(matchedItem);
|
|
console.log("✅ 초기값 표시 텍스트 로드 성공:", {
|
|
currentValue,
|
|
displayText,
|
|
matchedItem,
|
|
});
|
|
|
|
setSelectedData(matchedItem);
|
|
setInputValue(displayText);
|
|
selectedDataRef.current = matchedItem;
|
|
inputValueRef.current = displayText;
|
|
initialValueLoadedRef.current = currentValue;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("❌ 초기값 표시 텍스트 로드 실패:", error);
|
|
}
|
|
};
|
|
|
|
loadInitialDisplayValue();
|
|
}, [currentValue, tableName, valueField, displayFields, filterCondition, searchFields, selectedData]);
|
|
|
|
// value가 변경되면 표시값 업데이트 - 단, selectedData가 있으면 유지
|
|
useEffect(() => {
|
|
// selectedData가 있으면 표시값 유지 (사용자가 방금 선택한 경우)
|
|
if (selectedData || selectedDataRef.current) {
|
|
return;
|
|
}
|
|
|
|
if (!currentValue) {
|
|
setInputValue("");
|
|
initialValueLoadedRef.current = null; // 값이 없어지면 초기화
|
|
}
|
|
}, [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>
|
|
);
|
|
}
|