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

@ -337,12 +337,22 @@ export const SaveModal: React.FC<SaveModalProps> = ({
formData={formData} formData={formData}
originalData={originalData} originalData={originalData}
onFormDataChange={(fieldName, value) => { onFormDataChange={(fieldName, value) => {
setFormData((prev) => ({ console.log("📝 SaveModal - formData 변경:", {
...prev, fieldName,
[fieldName]: value, value,
})); componentType: component.type,
componentId: component.id,
});
setFormData((prev) => {
const newData = {
...prev,
[fieldName]: value,
};
console.log("📦 새 formData:", newData);
return newData;
});
}} }}
mode={initialData ? "edit" : "create"} mode="edit"
isInModal={true} isInModal={true}
isInteractive={true} isInteractive={true}
/> />

View File

@ -7,18 +7,23 @@ import { Button } from "@/components/ui/button";
import { useEntitySearch } from "../entity-search-input/useEntitySearch"; import { useEntitySearch } from "../entity-search-input/useEntitySearch";
import { EntitySearchResult } from "../entity-search-input/types"; import { EntitySearchResult } from "../entity-search-input/types";
import { cn } from "@/lib/utils"; 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; config?: AutocompleteSearchInputConfig;
tableName?: string;
displayField?: string;
valueField?: string;
searchFields?: string[];
filterCondition?: Record<string, any>; filterCondition?: Record<string, any>;
disabled?: boolean; placeholder?: string;
value?: any; showAdditionalInfo?: boolean;
onChange?: (value: any, fullData?: any) => void; additionalFields?: string[];
className?: string;
} }
export function AutocompleteSearchInputComponent({ export function AutocompleteSearchInputComponent({
component,
config, config,
tableName: propTableName, tableName: propTableName,
displayField: propDisplayField, displayField: propDisplayField,
@ -29,9 +34,10 @@ export function AutocompleteSearchInputComponent({
disabled = false, disabled = false,
value, value,
onChange, onChange,
showAdditionalInfo: propShowAdditionalInfo,
additionalFields: propAdditionalFields,
className, className,
isInteractive = false,
onFormDataChange,
formData,
}: AutocompleteSearchInputProps) { }: AutocompleteSearchInputProps) {
// config prop 우선, 없으면 개별 prop 사용 // config prop 우선, 없으면 개별 prop 사용
const tableName = config?.tableName || propTableName || ""; const tableName = config?.tableName || propTableName || "";
@ -39,8 +45,7 @@ export function AutocompleteSearchInputComponent({
const valueField = config?.valueField || propValueField || ""; const valueField = config?.valueField || propValueField || "";
const searchFields = config?.searchFields || propSearchFields || [displayField]; const searchFields = config?.searchFields || propSearchFields || [displayField];
const placeholder = config?.placeholder || propPlaceholder || "검색..."; const placeholder = config?.placeholder || propPlaceholder || "검색...";
const showAdditionalInfo = config?.showAdditionalInfo ?? propShowAdditionalInfo ?? false;
const additionalFields = config?.additionalFields || propAdditionalFields || [];
const [inputValue, setInputValue] = useState(""); const [inputValue, setInputValue] = useState("");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null); const [selectedData, setSelectedData] = useState<EntitySearchResult | null>(null);
@ -52,15 +57,20 @@ export function AutocompleteSearchInputComponent({
filterCondition, filterCondition,
}); });
// formData에서 현재 값 가져오기 (isInteractive 모드)
const currentValue = isInteractive && formData && component?.columnName
? formData[component.columnName]
: value;
// value가 변경되면 표시값 업데이트 // value가 변경되면 표시값 업데이트
useEffect(() => { useEffect(() => {
if (value && selectedData) { if (currentValue && selectedData) {
setInputValue(selectedData[displayField] || ""); setInputValue(selectedData[displayField] || "");
} else if (!value) { } else if (!currentValue) {
setInputValue(""); setInputValue("");
setSelectedData(null); setSelectedData(null);
} }
}, [value, displayField]); }, [currentValue, displayField, selectedData]);
// 외부 클릭 감지 // 외부 클릭 감지
useEffect(() => { useEffect(() => {
@ -81,45 +91,61 @@ export function AutocompleteSearchInputComponent({
setIsOpen(true); 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) => { const handleSelect = (item: EntitySearchResult) => {
setSelectedData(item); setSelectedData(item);
setInputValue(item[displayField] || ""); setInputValue(item[displayField] || "");
onChange?.(item[valueField], item);
// 필드 자동 매핑 실행 console.log("🔍 AutocompleteSearchInput handleSelect:", {
applyFieldMappings(item); 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); setIsOpen(false);
}; };
@ -149,9 +175,9 @@ export function AutocompleteSearchInputComponent({
onFocus={handleInputFocus} onFocus={handleInputFocus}
placeholder={placeholder} placeholder={placeholder}
disabled={disabled} 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 && ( {loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
)} )}
@ -172,10 +198,10 @@ export function AutocompleteSearchInputComponent({
{/* 드롭다운 결과 */} {/* 드롭다운 결과 */}
{isOpen && (results.length > 0 || loading) && ( {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 ? ( {loading && results.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground"> <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> </div>
) : results.length === 0 ? ( ) : results.length === 0 ? (
@ -189,37 +215,15 @@ export function AutocompleteSearchInputComponent({
key={index} key={index}
type="button" type="button"
onClick={() => handleSelect(item)} 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> <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> </button>
))} ))}
</div> </div>
)} )}
</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> </div>
); );
} }

View File

@ -6,10 +6,9 @@ import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Plus, X, Check, ChevronsUpDown } from "lucide-react"; import { Plus, X, Check, ChevronsUpDown } from "lucide-react";
import { AutocompleteSearchInputConfig, FieldMapping, ValueFieldStorage } from "./types"; import { AutocompleteSearchInputConfig } from "./types";
import { tableManagementApi } from "@/lib/api/tableManagement"; import { tableManagementApi } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@ -24,83 +23,14 @@ export function AutocompleteSearchInputConfigPanel({
}: AutocompleteSearchInputConfigPanelProps) { }: AutocompleteSearchInputConfigPanelProps) {
const [localConfig, setLocalConfig] = useState(config); const [localConfig, setLocalConfig] = useState(config);
const [allTables, setAllTables] = useState<any[]>([]); const [allTables, setAllTables] = useState<any[]>([]);
const [tableColumns, setTableColumns] = useState<any[]>([]); const [sourceTableColumns, setSourceTableColumns] = useState<any[]>([]);
const [targetTableColumns, setTargetTableColumns] = useState<any[]>([]);
const [isLoadingTables, setIsLoadingTables] = useState(false); const [isLoadingTables, setIsLoadingTables] = useState(false);
const [isLoadingColumns, setIsLoadingColumns] = useState(false); const [isLoadingSourceColumns, setIsLoadingSourceColumns] = useState(false);
const [openTableCombo, setOpenTableCombo] = useState(false); const [isLoadingTargetColumns, setIsLoadingTargetColumns] = useState(false);
const [openSourceTableCombo, setOpenSourceTableCombo] = useState(false);
const [openTargetTableCombo, setOpenTargetTableCombo] = useState(false);
const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false); const [openDisplayFieldCombo, setOpenDisplayFieldCombo] = useState(false);
const [openValueFieldCombo, setOpenValueFieldCombo] = useState(false);
const [openStorageTableCombo, setOpenStorageTableCombo] = useState(false);
const [openStorageColumnCombo, setOpenStorageColumnCombo] = useState(false);
const [storageTableColumns, setStorageTableColumns] = useState<any[]>([]);
const [isLoadingStorageColumns, setIsLoadingStorageColumns] = useState(false);
// 전체 테이블 목록 로드
useEffect(() => {
const loadTables = async () => {
setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
console.error("테이블 목록 로드 실패:", error);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
// 선택된 테이블의 컬럼 목록 로드
useEffect(() => {
const loadColumns = async () => {
if (!localConfig.tableName) {
setTableColumns([]);
return;
}
setIsLoadingColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.tableName);
if (response.success && response.data) {
setTableColumns(response.data.columns);
}
} catch (error) {
console.error("컬럼 목록 로드 실패:", error);
setTableColumns([]);
} finally {
setIsLoadingColumns(false);
}
};
loadColumns();
}, [localConfig.tableName]);
// 저장 대상 테이블의 컬럼 목록 로드
useEffect(() => {
const loadStorageColumns = async () => {
const storageTable = localConfig.valueFieldStorage?.targetTable;
if (!storageTable) {
setStorageTableColumns([]);
return;
}
setIsLoadingStorageColumns(true);
try {
const response = await tableManagementApi.getColumnList(storageTable);
if (response.success && response.data) {
setStorageTableColumns(response.data.columns);
}
} catch (error) {
console.error("저장 테이블 컬럼 로드 실패:", error);
setStorageTableColumns([]);
} finally {
setIsLoadingStorageColumns(false);
}
};
loadStorageColumns();
}, [localConfig.valueFieldStorage?.targetTable]);
useEffect(() => { useEffect(() => {
setLocalConfig(config); setLocalConfig(config);
@ -112,52 +42,76 @@ export function AutocompleteSearchInputConfigPanel({
onConfigChange(newConfig); onConfigChange(newConfig);
}; };
const addSearchField = () => { // 테이블 목록 로드
const fields = localConfig.searchFields || []; useEffect(() => {
updateConfig({ searchFields: [...fields, ""] }); const loadTables = async () => {
}; setIsLoadingTables(true);
try {
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setAllTables(response.data);
}
} catch (error) {
setAllTables([]);
} finally {
setIsLoadingTables(false);
}
};
loadTables();
}, []);
const updateSearchField = (index: number, value: string) => { // 외부 테이블 컬럼 로드
const fields = [...(localConfig.searchFields || [])]; useEffect(() => {
fields[index] = value; const loadColumns = async () => {
updateConfig({ searchFields: fields }); if (!localConfig.tableName) {
}; setSourceTableColumns([]);
return;
}
setIsLoadingSourceColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.tableName);
if (response.success && response.data) {
setSourceTableColumns(response.data.columns);
}
} catch (error) {
setSourceTableColumns([]);
} finally {
setIsLoadingSourceColumns(false);
}
};
loadColumns();
}, [localConfig.tableName]);
const removeSearchField = (index: number) => { // 저장 테이블 컬럼 로드
const fields = [...(localConfig.searchFields || [])]; useEffect(() => {
fields.splice(index, 1); const loadTargetColumns = async () => {
updateConfig({ searchFields: fields }); if (!localConfig.targetTable) {
}; setTargetTableColumns([]);
return;
}
setIsLoadingTargetColumns(true);
try {
const response = await tableManagementApi.getColumnList(localConfig.targetTable);
if (response.success && response.data) {
setTargetTableColumns(response.data.columns);
}
} catch (error) {
setTargetTableColumns([]);
} finally {
setIsLoadingTargetColumns(false);
}
};
loadTargetColumns();
}, [localConfig.targetTable]);
const addAdditionalField = () => {
const fields = localConfig.additionalFields || [];
updateConfig({ additionalFields: [...fields, ""] });
};
const updateAdditionalField = (index: number, value: string) => {
const fields = [...(localConfig.additionalFields || [])];
fields[index] = value;
updateConfig({ additionalFields: fields });
};
const removeAdditionalField = (index: number) => {
const fields = [...(localConfig.additionalFields || [])];
fields.splice(index, 1);
updateConfig({ additionalFields: fields });
};
// 필드 매핑 관리 함수
const addFieldMapping = () => { const addFieldMapping = () => {
const mappings = localConfig.fieldMappings || []; const mappings = localConfig.fieldMappings || [];
updateConfig({ updateConfig({
fieldMappings: [ fieldMappings: [...mappings, { sourceField: "", targetField: "", label: "" }],
...mappings,
{ sourceField: "", targetField: "", label: "" },
],
}); });
}; };
const updateFieldMapping = (index: number, updates: Partial<FieldMapping>) => { const updateFieldMapping = (index: number, updates: any) => {
const mappings = [...(localConfig.fieldMappings || [])]; const mappings = [...(localConfig.fieldMappings || [])];
mappings[index] = { ...mappings[index], ...updates }; mappings[index] = { ...mappings[index], ...updates };
updateConfig({ fieldMappings: mappings }); updateConfig({ fieldMappings: mappings });
@ -170,21 +124,22 @@ export function AutocompleteSearchInputConfigPanel({
}; };
return ( return (
<div className="space-y-4 p-4"> <div className="space-y-6 p-4">
{/* 1. 외부 테이블 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label> <Label className="text-xs font-semibold sm:text-sm">1. *</Label>
<Popover open={openTableCombo} onOpenChange={setOpenTableCombo}> <Popover open={openSourceTableCombo} onOpenChange={setOpenSourceTableCombo}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={openTableCombo} aria-expanded={openSourceTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm" className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables} disabled={isLoadingTables}
> >
{localConfig.tableName {localConfig.tableName
? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName ? allTables.find((t) => t.tableName === localConfig.tableName)?.displayName || localConfig.tableName
: isLoadingTables ? "로딩 중..." : "테이블 선택"} : isLoadingTables ? "로딩 중..." : "데이터를 가져올 테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -200,7 +155,7 @@ export function AutocompleteSearchInputConfigPanel({
value={table.tableName} value={table.tableName}
onSelect={() => { onSelect={() => {
updateConfig({ tableName: table.tableName }); updateConfig({ tableName: table.tableName });
setOpenTableCombo(false); setOpenSourceTableCombo(false);
}} }}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
@ -216,13 +171,11 @@ export function AutocompleteSearchInputConfigPanel({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<p className="text-[10px] text-muted-foreground">
</p>
</div> </div>
{/* 2. 표시 필드 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label> <Label className="text-xs font-semibold sm:text-sm">2. *</Label>
<Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}> <Popover open={openDisplayFieldCombo} onOpenChange={setOpenDisplayFieldCombo}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
@ -230,11 +183,11 @@ export function AutocompleteSearchInputConfigPanel({
role="combobox" role="combobox"
aria-expanded={openDisplayFieldCombo} aria-expanded={openDisplayFieldCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm" className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns} disabled={!localConfig.tableName || isLoadingSourceColumns}
> >
{localConfig.displayField {localConfig.displayField
? tableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField ? sourceTableColumns.find((c) => c.columnName === localConfig.displayField)?.displayName || localConfig.displayField
: isLoadingColumns ? "로딩 중..." : "필드 선택"} : isLoadingSourceColumns ? "로딩 중..." : "사용자에게 보여줄 필드"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
@ -244,7 +197,7 @@ export function AutocompleteSearchInputConfigPanel({
<CommandList> <CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty> <CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{tableColumns.map((column) => ( {sourceTableColumns.map((column) => (
<CommandItem <CommandItem
key={column.columnName} key={column.columnName}
value={column.columnName} value={column.columnName}
@ -266,48 +219,46 @@ export function AutocompleteSearchInputConfigPanel({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<p className="text-[10px] text-muted-foreground">
(: 거래처명)
</p>
</div> </div>
{/* 3. 저장 대상 테이블 선택 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"> *</Label> <Label className="text-xs font-semibold sm:text-sm">3. *</Label>
<Popover open={openValueFieldCombo} onOpenChange={setOpenValueFieldCombo}> <Popover open={openTargetTableCombo} onOpenChange={setOpenTargetTableCombo}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
variant="outline" variant="outline"
role="combobox" role="combobox"
aria-expanded={openValueFieldCombo} aria-expanded={openTargetTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm" className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={!localConfig.tableName || isLoadingColumns} disabled={isLoadingTables}
> >
{localConfig.valueField {localConfig.targetTable
? tableColumns.find((c) => c.columnName === localConfig.valueField)?.displayName || localConfig.valueField ? allTables.find((t) => t.tableName === localConfig.targetTable)?.displayName || localConfig.targetTable
: isLoadingColumns ? "로딩 중..." : "필드 선택"} : "데이터를 저장할 테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start"> <PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command> <Command>
<CommandInput placeholder="필드 검색..." className="text-xs sm:text-sm" /> <CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList> <CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty> <CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup> <CommandGroup>
{tableColumns.map((column) => ( {allTables.map((table) => (
<CommandItem <CommandItem
key={column.columnName} key={table.tableName}
value={column.columnName} value={table.tableName}
onSelect={() => { onSelect={() => {
updateConfig({ valueField: column.columnName }); updateConfig({ targetTable: table.tableName });
setOpenValueFieldCombo(false); setOpenTargetTableCombo(false);
}} }}
className="text-xs sm:text-sm" className="text-xs sm:text-sm"
> >
<Check className={cn("mr-2 h-4 w-4", localConfig.valueField === column.columnName ? "opacity-100" : "opacity-0")} /> <Check className={cn("mr-2 h-4 w-4", localConfig.targetTable === table.tableName ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span> <span className="font-medium">{table.displayName || table.tableName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>} {table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div> </div>
</CommandItem> </CommandItem>
))} ))}
@ -316,11 +267,124 @@ export function AutocompleteSearchInputConfigPanel({
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
<p className="text-[10px] text-muted-foreground">
(: customer_code)
</p>
</div> </div>
{/* 4. 필드 매핑 */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label className="text-xs font-semibold sm:text-sm">4. *</Label>
<Button
size="sm"
variant="outline"
onClick={addFieldMapping}
className="h-7 text-xs"
disabled={!localConfig.tableName || !localConfig.targetTable || isLoadingSourceColumns || isLoadingTargetColumns}
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{(localConfig.fieldMappings || []).length === 0 && (
<div className="rounded-lg border border-dashed p-6 text-center">
<p className="text-sm text-muted-foreground">
</p>
</div>
)}
<div className="space-y-3">
{(localConfig.fieldMappings || []).map((mapping, index) => (
<div key={index} className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
#{index + 1}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeFieldMapping(index)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={mapping.label || ""}
onChange={(e) =>
updateFieldMapping(index, { label: e.target.value })
}
placeholder="예: 거래처 코드"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Select
value={mapping.sourceField}
onValueChange={(value) =>
updateFieldMapping(index, { sourceField: value })
}
disabled={!localConfig.tableName || isLoadingSourceColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="가져올 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{sourceTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs"> *</Label>
<Select
value={mapping.targetField}
onValueChange={(value) =>
updateFieldMapping(index, { targetField: value })
}
disabled={!localConfig.targetTable || isLoadingTargetColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="저장할 컬럼 선택" />
</SelectTrigger>
<SelectContent>
{targetTableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{mapping.sourceField && mapping.targetField && (
<div className="rounded bg-blue-50 p-2 dark:bg-blue-950">
<p className="text-[10px] text-blue-700 dark:text-blue-300">
<code className="rounded bg-blue-100 px-1 font-mono dark:bg-blue-900">
{localConfig.tableName}.{mapping.sourceField}
</code>
{" → "}
<code className="rounded bg-blue-100 px-1 font-mono dark:bg-blue-900">
{localConfig.targetTable}.{mapping.targetField}
</code>
</p>
</div>
)}
</div>
))}
</div>
</div>
{/* 플레이스홀더 */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-xs sm:text-sm"></Label> <Label className="text-xs sm:text-sm"></Label>
<Input <Input
@ -331,471 +395,28 @@ export function AutocompleteSearchInputConfigPanel({
/> />
</div> </div>
{/* 값 필드 저장 위치 설정 */} {/* 설정 요약 */}
<div className="space-y-4 border rounded-lg p-4 bg-card"> {localConfig.tableName && localConfig.targetTable && (localConfig.fieldMappings || []).length > 0 && (
<div> <div className="rounded-lg border bg-green-50 p-4 dark:bg-green-950">
<h3 className="text-sm font-semibold mb-1"> ()</h3> <h3 className="mb-2 text-sm font-semibold text-green-800 dark:text-green-200">
<p className="text-xs text-muted-foreground">
"값 필드" / . </h3>
<br /> <div className="space-y-1 text-xs text-green-700 dark:text-green-300">
. <p>
</p> <strong> :</strong> {localConfig.tableName}
</div> </p>
<p>
{/* 저장 테이블 선택 */} <strong> :</strong> {localConfig.displayField}
<div className="space-y-2"> </p>
<Label className="text-xs sm:text-sm"> </Label> <p>
<Popover open={openStorageTableCombo} onOpenChange={setOpenStorageTableCombo}> <strong> :</strong> {localConfig.targetTable}
<PopoverTrigger asChild> </p>
<Button <p>
variant="outline" <strong> :</strong> {(localConfig.fieldMappings || []).length}
role="combobox"
aria-expanded={openStorageTableCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingTables}
>
{localConfig.valueFieldStorage?.targetTable
? allTables.find((t) => t.tableName === localConfig.valueFieldStorage?.targetTable)?.displayName ||
localConfig.valueFieldStorage.targetTable
: "기본값 (화면 연결 테이블)"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{/* 기본값 옵션 */}
<CommandItem
value=""
onSelect={() => {
updateConfig({
valueFieldStorage: {
...localConfig.valueFieldStorage,
targetTable: undefined,
targetColumn: undefined,
},
});
setOpenStorageTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check className={cn("mr-2 h-4 w-4", !localConfig.valueFieldStorage?.targetTable ? "opacity-100" : "opacity-0")} />
<div className="flex flex-col">
<span className="font-medium"></span>
<span className="text-[10px] text-gray-500"> </span>
</div>
</CommandItem>
{allTables.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={() => {
updateConfig({
valueFieldStorage: {
...localConfig.valueFieldStorage,
targetTable: table.tableName,
targetColumn: undefined, // 테이블 변경 시 컬럼 초기화
},
});
setOpenStorageTableCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.valueFieldStorage?.targetTable === table.tableName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.displayName && <span className="text-[10px] text-gray-500">{table.tableName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground">
(기본값: 화면 )
</p>
</div>
{/* 저장 컬럼 선택 */}
{localConfig.valueFieldStorage?.targetTable && (
<div className="space-y-2">
<Label className="text-xs sm:text-sm"> </Label>
<Popover open={openStorageColumnCombo} onOpenChange={setOpenStorageColumnCombo}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openStorageColumnCombo}
className="h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
disabled={isLoadingStorageColumns}
>
{localConfig.valueFieldStorage?.targetColumn
? storageTableColumns.find((c) => c.columnName === localConfig.valueFieldStorage?.targetColumn)
?.displayName || localConfig.valueFieldStorage.targetColumn
: isLoadingStorageColumns
? "로딩 중..."
: "컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="컬럼 검색..." className="text-xs sm:text-sm" />
<CommandList>
<CommandEmpty className="text-xs sm:text-sm"> .</CommandEmpty>
<CommandGroup>
{storageTableColumns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
updateConfig({
valueFieldStorage: {
...localConfig.valueFieldStorage,
targetColumn: column.columnName,
},
});
setOpenStorageColumnCombo(false);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
localConfig.valueFieldStorage?.targetColumn === column.columnName ? "opacity-100" : "opacity-0"
)}
/>
<div className="flex flex-col">
<span className="font-medium">{column.displayName || column.columnName}</span>
{column.displayName && <span className="text-[10px] text-gray-500">{column.columnName}</span>}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="text-[10px] text-muted-foreground">
</p> </p>
</div>
)}
{/* 설명 박스 */}
<div className="p-3 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
<p className="text-xs font-medium mb-2 text-blue-800 dark:text-blue-200">
</p>
<div className="text-[10px] text-blue-700 dark:text-blue-300 space-y-1">
{localConfig.valueFieldStorage?.targetTable ? (
<>
<p>
(<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">{localConfig.valueField}</code>)
</p>
<p>
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{localConfig.valueFieldStorage.targetTable}
</code>{" "}
{" "}
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{localConfig.valueFieldStorage.targetColumn || "(컬럼 미지정)"}
</code>{" "}
.
</p>
</>
) : (
<p>기본값: 화면의 .</p>
)}
</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addSearchField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.searchFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateSearchField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeSearchField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.showAdditionalInfo || false}
onCheckedChange={(checked) =>
updateConfig({ showAdditionalInfo: checked })
}
/>
</div>
</div>
{localConfig.showAdditionalInfo && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addAdditionalField}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-2">
{(localConfig.additionalFields || []).map((field, index) => (
<div key={index} className="flex items-center gap-2">
<Select
value={field}
onValueChange={(value) => updateAdditionalField(index, value)}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm flex-1">
<SelectValue placeholder="필드 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
{col.displayName || col.columnName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
size="sm"
variant="ghost"
onClick={() => removeAdditionalField(index)}
className="h-8 w-8 p-0"
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div> </div>
</div> </div>
)} )}
{/* 필드 자동 매핑 설정 */}
<div className="space-y-4 border rounded-lg p-4 bg-card">
<div>
<h3 className="text-sm font-semibold mb-1"> </h3>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Switch
checked={localConfig.enableFieldMapping || false}
onCheckedChange={(checked) =>
updateConfig({ enableFieldMapping: checked })
}
/>
</div>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{localConfig.enableFieldMapping && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs sm:text-sm"> </Label>
<Button
size="sm"
variant="outline"
onClick={addFieldMapping}
className="h-7 text-xs"
disabled={!localConfig.tableName || isLoadingColumns}
>
<Plus className="h-3 w-3 mr-1" />
</Button>
</div>
<div className="space-y-3">
{(localConfig.fieldMappings || []).map((mapping, index) => (
<div key={index} className="border rounded-lg p-3 space-y-3 bg-background">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
#{index + 1}
</span>
<Button
size="sm"
variant="ghost"
onClick={() => removeFieldMapping(index)}
className="h-6 w-6 p-0"
>
<X className="h-3 w-3" />
</Button>
</div>
{/* 표시명 */}
<div className="space-y-1.5">
<Label className="text-xs"></Label>
<Input
value={mapping.label || ""}
onChange={(e) =>
updateFieldMapping(index, { label: e.target.value })
}
placeholder="예: 거래처명"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] text-muted-foreground">
()
</p>
</div>
{/* 소스 필드 (테이블의 컬럼) */}
<div className="space-y-1.5">
<Label className="text-xs">
( ) *
</Label>
<Select
value={mapping.sourceField}
onValueChange={(value) =>
updateFieldMapping(index, { sourceField: value })
}
disabled={!localConfig.tableName || isLoadingColumns}
>
<SelectTrigger className="h-8 text-xs sm:h-10 sm:text-sm">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{tableColumns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex flex-col">
<span className="font-medium">
{col.displayName || col.columnName}
</span>
{col.displayName && (
<span className="text-[10px] text-gray-500">
{col.columnName}
</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[10px] text-muted-foreground">
</p>
</div>
{/* 타겟 필드 (화면의 input ID) */}
<div className="space-y-1.5">
<Label className="text-xs">
( ID) *
</Label>
<Input
value={mapping.targetField}
onChange={(e) =>
updateFieldMapping(index, { targetField: e.target.value })
}
placeholder="예: customer_name_input"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-[10px] text-muted-foreground">
ID (: input의 id )
</p>
</div>
{/* 예시 설명 */}
<div className="p-2 bg-blue-50 dark:bg-blue-950 rounded border border-blue-200 dark:border-blue-800">
<p className="text-[10px] text-blue-700 dark:text-blue-300">
{mapping.sourceField && mapping.targetField ? (
<>
<span className="font-semibold">{mapping.label || "이 필드"}</span>: {" "}
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{mapping.sourceField}
</code>{" "}
{" "}
<code className="font-mono bg-blue-100 dark:bg-blue-900 px-1 rounded">
{mapping.targetField}
</code>{" "}
</>
) : (
"소스 필드와 타겟 필드를 모두 선택하세요"
)}
</p>
</div>
</div>
))}
</div>
{/* 사용 안내 */}
{localConfig.fieldMappings && localConfig.fieldMappings.length > 0 && (
<div className="p-3 bg-amber-50 dark:bg-amber-950 rounded border border-amber-200 dark:border-amber-800">
<p className="text-xs font-medium mb-2 text-amber-800 dark:text-amber-200">
</p>
<ul className="text-[10px] text-amber-700 dark:text-amber-300 space-y-1 list-disc list-inside">
<li> </li>
<li> </li>
<li> ID는 ID와 </li>
</ul>
</div>
)}
</div>
)}
</div>
</div> </div>
); );
} }

View File

@ -27,5 +27,7 @@ export interface AutocompleteSearchInputConfig {
// 필드 자동 매핑 설정 // 필드 자동 매핑 설정
enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부 enableFieldMapping?: boolean; // 필드 자동 매핑 활성화 여부
fieldMappings?: FieldMapping[]; // 매핑할 필드 목록 fieldMappings?: FieldMapping[]; // 매핑할 필드 목록
// 저장 대상 테이블 (간소화 버전)
targetTable?: string;
} }