fix(autocomplete-search-input): 필드 매핑 저장 문제 해결

- types.ts에 targetTable 필드 추가하여 config에 저장되도록 수정
- ConfigPanel에서 targetTable을 localConfig로 관리하여 설정 유지
- Renderer 단순화 (TextInput 패턴 적용)
- Component에서 직접 isInteractive 체크 및 필드 매핑 처리
- ComponentRendererProps 상속으로 필수 props 타입 안정성 확보

문제:
- ConfigPanel 설정이 초기화되는 문제
- 필드 매핑 데이터가 DB에 저장되지 않는 문제

해결:
- 정상 작동하는 TextInput 컴포넌트 패턴 분석 및 적용
- Renderer는 props만 전달, Component가 저장 로직 처리
This commit is contained in:
SeongHyun Kim
2025-11-20 17:47:56 +09:00
parent 3aee36515a
commit 95b5e3dc7a
4 changed files with 329 additions and 692 deletions

View File

@@ -7,18 +7,23 @@ 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";
import { AutocompleteSearchInputConfig } from "./types";
import { ComponentRendererProps } from "../../DynamicComponentRenderer";
interface AutocompleteSearchInputProps extends Partial<AutocompleteSearchInputConfig> {
export interface AutocompleteSearchInputProps extends ComponentRendererProps {
config?: AutocompleteSearchInputConfig;
tableName?: string;
displayField?: string;
valueField?: string;
searchFields?: string[];
filterCondition?: Record<string, any>;
disabled?: boolean;
value?: any;
onChange?: (value: any, fullData?: any) => void;
className?: string;
placeholder?: string;
showAdditionalInfo?: boolean;
additionalFields?: string[];
}
export function AutocompleteSearchInputComponent({
component,
config,
tableName: propTableName,
displayField: propDisplayField,
@@ -29,9 +34,10 @@ export function AutocompleteSearchInputComponent({
disabled = false,
value,
onChange,
showAdditionalInfo: propShowAdditionalInfo,
additionalFields: propAdditionalFields,
className,
isInteractive = false,
onFormDataChange,
formData,
}: AutocompleteSearchInputProps) {
// config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || "";
@@ -39,8 +45,7 @@ export function AutocompleteSearchInputComponent({
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);
@@ -52,15 +57,20 @@ export function AutocompleteSearchInputComponent({
filterCondition,
});
// formData에서 현재 값 가져오기 (isInteractive 모드)
const currentValue = isInteractive && formData && component?.columnName
? formData[component.columnName]
: value;
// value가 변경되면 표시값 업데이트
useEffect(() => {
if (value && selectedData) {
if (currentValue && selectedData) {
setInputValue(selectedData[displayField] || "");
} else if (!value) {
} else if (!currentValue) {
setInputValue("");
setSelectedData(null);
}
}, [value, displayField]);
}, [currentValue, displayField, selectedData]);
// 외부 클릭 감지
useEffect(() => {
@@ -81,45 +91,61 @@ export function AutocompleteSearchInputComponent({
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);
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);
};
@@ -149,9 +175,9 @@ export function AutocompleteSearchInputComponent({
onFocus={handleInputFocus}
placeholder={placeholder}
disabled={disabled}
className="h-8 text-xs sm:h-10 sm:text-sm pr-16"
className="h-8 pr-16 text-xs sm:h-10 sm:text-sm"
/>
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex items-center gap-1">
<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" />
)}
@@ -172,10 +198,10 @@ export function AutocompleteSearchInputComponent({
{/* 드롭다운 결과 */}
{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">
<div className="absolute z-50 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="h-4 w-4 animate-spin mx-auto mb-2" />
<Loader2 className="mx-auto mb-2 h-4 w-4 animate-spin" />
...
</div>
) : results.length === 0 ? (
@@ -189,37 +215,15 @@ export function AutocompleteSearchInputComponent({
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"
className="w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent sm:text-sm"
>
<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>
);
}