ERP-node/frontend/components/dataflow/node-editor/panels/properties/UpdateActionProperties.tsx

1581 lines
71 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
/**
* UPDATE 액션 노드 속성 편집 (개선 버전)
*/
import { useEffect, useState } from "react";
import { Plus, Trash2, Check, ChevronsUpDown, ArrowRight, Database, Globe, Link2 } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
import { tableTypeApi } from "@/lib/api/screen";
import { getTestedExternalConnections, getExternalTables, getExternalColumns } from "@/lib/api/nodeExternalConnections";
import type { UpdateActionNodeData } from "@/types/node-editor";
import type { ExternalConnection, ExternalTable, ExternalColumn } from "@/lib/api/nodeExternalConnections";
interface UpdateActionPropertiesProps {
nodeId: string;
data: UpdateActionNodeData;
}
interface TableOption {
tableName: string;
displayName: string;
description: string;
label: string;
}
interface ColumnInfo {
columnName: string;
columnLabel?: string;
dataType: string;
isNullable: boolean;
columnDefault?: string | null;
}
const OPERATORS = [
{ value: "EQUALS", label: "=" },
{ value: "NOT_EQUALS", label: "≠" },
{ value: "GREATER_THAN", label: ">" },
{ value: "LESS_THAN", label: "<" },
{ value: "GREATER_THAN_OR_EQUAL", label: "≥" },
{ value: "LESS_THAN_OR_EQUAL", label: "≤" },
{ value: "LIKE", label: "LIKE" },
{ value: "NOT_LIKE", label: "NOT LIKE" },
{ value: "IN", label: "IN" },
{ value: "NOT_IN", label: "NOT IN" },
{ value: "IS_NULL", label: "IS NULL" },
{ value: "IS_NOT_NULL", label: "IS NOT NULL" },
] as const;
export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesProps) {
const { updateNode, nodes, edges, getExternalConnectionsCache } = useFlowEditorStore();
// 🔥 타겟 타입 상태
const [targetType, setTargetType] = useState<"internal" | "external" | "api">(data.targetType || "internal");
const [displayName, setDisplayName] = useState(data.displayName || data.targetTable);
const [targetTable, setTargetTable] = useState(data.targetTable);
const [fieldMappings, setFieldMappings] = useState(data.fieldMappings || []);
const [whereConditions, setWhereConditions] = useState(data.whereConditions || []);
// 내부 DB 테이블 관련 상태
const [tables, setTables] = useState<TableOption[]>([]);
const [tablesLoading, setTablesLoading] = useState(false);
const [tablesOpen, setTablesOpen] = useState(false);
// 컬럼 관련 상태
const [targetColumns, setTargetColumns] = useState<ColumnInfo[]>([]);
const [columnsLoading, setColumnsLoading] = useState(false);
// 소스 필드 목록 (연결된 입력 노드에서 가져오기)
const [sourceFields, setSourceFields] = useState<Array<{ name: string; label?: string }>>([]);
// REST API 소스 노드 연결 여부
const [hasRestAPISource, setHasRestAPISource] = useState(false);
// Combobox 열림 상태 관리 (WHERE 조건)
const [sourceFieldsOpenState, setSourceFieldsOpenState] = useState<boolean[]>([]);
const [targetFieldsOpenState, setTargetFieldsOpenState] = useState<boolean[]>([]);
// Combobox 열림 상태 관리 (필드 매핑)
const [mappingSourceFieldsOpenState, setMappingSourceFieldsOpenState] = useState<boolean[]>([]);
const [mappingTargetFieldsOpenState, setMappingTargetFieldsOpenState] = useState<boolean[]>([]);
// 🔥 외부 DB 관련 상태
const [externalConnections, setExternalConnections] = useState<ExternalConnection[]>([]);
const [externalConnectionsLoading, setExternalConnectionsLoading] = useState(false);
const [selectedExternalConnectionId, setSelectedExternalConnectionId] = useState<number | undefined>(
data.externalConnectionId,
);
const [externalTables, setExternalTables] = useState<ExternalTable[]>([]);
const [externalTablesLoading, setExternalTablesLoading] = useState(false);
const [externalTargetTable, setExternalTargetTable] = useState(data.externalTargetTable);
const [externalColumns, setExternalColumns] = useState<ExternalColumn[]>([]);
const [externalColumnsLoading, setExternalColumnsLoading] = useState(false);
// 🔥 REST API 관련 상태
const [apiEndpoint, setApiEndpoint] = useState(data.apiEndpoint || "");
const [apiMethod, setApiMethod] = useState<"PUT" | "PATCH">(data.apiMethod || "PUT");
const [apiAuthType, setApiAuthType] = useState<"none" | "basic" | "bearer" | "apikey">(data.apiAuthType || "none");
const [apiAuthConfig, setApiAuthConfig] = useState(data.apiAuthConfig || {});
const [apiHeaders, setApiHeaders] = useState<Record<string, string>>(data.apiHeaders || {});
const [apiBodyTemplate, setApiBodyTemplate] = useState(data.apiBodyTemplate || "");
// 데이터 변경 시 로컬 상태 업데이트
useEffect(() => {
setDisplayName(data.displayName || data.targetTable);
setTargetTable(data.targetTable);
setFieldMappings(data.fieldMappings || []);
setWhereConditions(data.whereConditions || []);
}, [data]);
// 내부 DB 테이블 목록 로딩
useEffect(() => {
if (targetType === "internal") {
loadTables();
}
}, [targetType]);
// 타겟 테이블 변경 시 컬럼 로딩 (내부 DB)
useEffect(() => {
if (targetType === "internal" && targetTable) {
loadColumns(targetTable);
}
}, [targetType, targetTable]);
// 🔥 외부 DB: 커넥션 목록 로딩
useEffect(() => {
if (targetType === "external") {
loadExternalConnections();
}
}, [targetType]);
// 🔥 외부 DB: 테이블 목록 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId) {
loadExternalTables(selectedExternalConnectionId);
}
}, [targetType, selectedExternalConnectionId]);
// 🔥 외부 DB: 컬럼 목록 로딩
useEffect(() => {
if (targetType === "external" && selectedExternalConnectionId && externalTargetTable) {
loadExternalColumns(selectedExternalConnectionId, externalTargetTable);
}
}, [targetType, selectedExternalConnectionId, externalTargetTable]);
// whereConditions 변경 시 Combobox 열림 상태 초기화
useEffect(() => {
setSourceFieldsOpenState(new Array(whereConditions.length).fill(false));
setTargetFieldsOpenState(new Array(whereConditions.length).fill(false));
}, [whereConditions.length]);
// fieldMappings 변경 시 Combobox 열림 상태 초기화
useEffect(() => {
setMappingSourceFieldsOpenState(new Array(fieldMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(fieldMappings.length).fill(false));
}, [fieldMappings.length]);
// 연결된 소스 노드에서 필드 가져오기 (재귀적으로 모든 상위 노드 탐색)
useEffect(() => {
const getAllSourceFields = (
targetNodeId: string,
visitedNodes = new Set<string>(),
): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => {
if (visitedNodes.has(targetNodeId)) {
return { fields: [], hasRestAPI: false };
}
visitedNodes.add(targetNodeId);
const inputEdges = edges.filter((edge) => edge.target === targetNodeId);
const sourceNodeIds = inputEdges.map((edge) => edge.source);
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
const fields: Array<{ name: string; label?: string }> = [];
let foundRestAPI = false;
sourceNodes.forEach((node) => {
// 1⃣ 데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
if (node.type === "dataTransform") {
const upperResult = getAllSourceFields(node.id, visitedNodes);
const upperFields = upperResult.fields;
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
const inPlaceFields = new Set<string>();
(node.data as any).transformations.forEach((transform: any) => {
const targetField = transform.targetField || transform.sourceField;
const isInPlace = !transform.targetField || transform.targetField === transform.sourceField;
if (isInPlace) {
inPlaceFields.add(transform.sourceField);
} else if (targetField) {
fields.push({
name: targetField,
label: transform.targetFieldLabel || targetField,
});
}
});
upperFields.forEach((field) => {
fields.push(field);
});
} else {
fields.push(...upperFields);
}
}
// 2⃣ 수식 변환(FormulaTransform) 노드: 상위 필드 + 변환 출력 필드
else if (node.type === "formulaTransform") {
// 상위 노드의 필드 가져오기
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
// 수식 변환 출력 필드 추가
const nodeData = node.data as any;
if (nodeData.transformations && Array.isArray(nodeData.transformations)) {
nodeData.transformations.forEach((trans: any) => {
if (trans.outputField) {
fields.push({
name: trans.outputField,
label: trans.outputFieldLabel || trans.outputField,
});
}
});
}
}
// 3⃣ 집계(Aggregate) 노드: 그룹 필드 + 집계 결과 필드
else if (node.type === "aggregate") {
const nodeData = node.data as any;
// 그룹 기준 필드 추가 (field 또는 fieldName 둘 다 지원)
if (nodeData.groupByFields && Array.isArray(nodeData.groupByFields)) {
nodeData.groupByFields.forEach((groupField: any) => {
const fieldName = groupField.field || groupField.fieldName;
if (fieldName) {
fields.push({
name: fieldName,
label: groupField.fieldLabel || fieldName,
});
}
});
}
// 집계 결과 필드 추가 (aggregations 또는 aggregateFunctions 둘 다 지원)
const aggregations = nodeData.aggregations || nodeData.aggregateFunctions || [];
if (Array.isArray(aggregations)) {
aggregations.forEach((aggFunc: any) => {
// outputField 또는 targetField 둘 다 지원
const outputFieldName = aggFunc.outputField || aggFunc.targetField;
// function 또는 aggregateType 둘 다 지원
const funcType = aggFunc.function || aggFunc.aggregateType;
if (outputFieldName) {
fields.push({
name: outputFieldName,
label:
aggFunc.outputFieldLabel ||
aggFunc.targetFieldLabel ||
`${funcType}(${aggFunc.sourceFieldLabel || aggFunc.sourceField})`,
});
}
});
}
// 집계 노드는 상위 노드의 필드를 그대로 통과시키지 않음 (집계된 결과만 전달)
}
// 4⃣ REST API 소스 노드
else if (node.type === "restAPISource") {
foundRestAPI = true;
const responseFields = (node.data as any).responseFields;
if (responseFields && Array.isArray(responseFields)) {
responseFields.forEach((field: any) => {
const fieldName = field.name || field.fieldName;
const fieldLabel = field.label || field.displayName;
if (fieldName) {
fields.push({
name: fieldName,
label: fieldLabel,
});
}
});
}
}
// 4⃣ 테이블/외부DB 소스 노드
else if (node.type === "tableSource" || node.type === "externalDBSource") {
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
if (nodeFields && Array.isArray(nodeFields)) {
nodeFields.forEach((field: any) => {
const fieldName = field.name || field.fieldName || field.column_name;
const fieldLabel = field.label || field.displayName || field.label_ko;
if (fieldName) {
fields.push({
name: fieldName,
label: fieldLabel,
});
}
});
} else {
// 필드가 없으면 상위 노드로 계속 탐색
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
}
// 5⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
else {
const upperResult = getAllSourceFields(node.id, visitedNodes);
fields.push(...upperResult.fields);
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
}
});
return { fields, hasRestAPI: foundRestAPI };
};
const result = getAllSourceFields(nodeId);
// 중복 제거
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values());
setSourceFields(uniqueFields);
setHasRestAPISource(result.hasRestAPI);
}, [nodeId, nodes, edges]);
const loadTables = async () => {
try {
setTablesLoading(true);
const tableList = await tableTypeApi.getTables();
console.log("🔍 UPDATE 노드 - 테이블 목록:", tableList);
const options: TableOption[] = tableList.map((table) => {
const label = (table as any).tableLabel || table.displayName || table.tableName || "알 수 없는 테이블";
return {
tableName: table.tableName,
displayName: table.displayName || table.tableName,
description: table.description || "",
label,
};
});
console.log("✅ UPDATE 노드 - 테이블 옵션:", options);
setTables(options);
} catch (error) {
console.error("❌ UPDATE 노드 - 테이블 목록 로딩 실패:", error);
} finally {
setTablesLoading(false);
}
};
// 🔥 외부 DB 커넥션 목록 로딩
const loadExternalConnections = async () => {
try {
setExternalConnectionsLoading(true);
// 캐시 확인
const cached = getExternalConnectionsCache();
if (cached) {
setExternalConnections(cached);
setExternalConnectionsLoading(false);
return;
}
const connections = await getTestedExternalConnections();
setExternalConnections(connections);
} catch (error) {
console.error("외부 커넥션 목록 로딩 실패:", error);
} finally {
setExternalConnectionsLoading(false);
}
};
// 🔥 외부 DB 테이블 목록 로딩
const loadExternalTables = async (connectionId: number) => {
try {
setExternalTablesLoading(true);
const tables = await getExternalTables(connectionId);
setExternalTables(tables);
} catch (error) {
console.error("외부 테이블 목록 로딩 실패:", error);
} finally {
setExternalTablesLoading(false);
}
};
// 🔥 외부 DB 컬럼 목록 로딩
const loadExternalColumns = async (connectionId: number, tableName: string) => {
try {
setExternalColumnsLoading(true);
const columns = await getExternalColumns(connectionId, tableName);
setExternalColumns(columns);
} catch (error) {
console.error("외부 컬럼 목록 로딩 실패:", error);
} finally {
setExternalColumnsLoading(false);
}
};
const loadColumns = async (tableName: string) => {
try {
setColumnsLoading(true);
console.log(`🔍 UPDATE 노드 - 컬럼 조회 중: ${tableName}`);
const columns = await tableTypeApi.getColumns(tableName);
const columnInfo: ColumnInfo[] = columns.map((col: any) => {
// is_nullable 파싱: "YES", true, 1 등을 true로, "NO", false, 0 등을 false로 변환
const isNullableValue = col.is_nullable ?? col.isNullable;
let isNullable = true; // 기본값: nullable
if (typeof isNullableValue === "boolean") {
isNullable = isNullableValue;
} else if (typeof isNullableValue === "string") {
isNullable = isNullableValue.toUpperCase() === "YES" || isNullableValue.toUpperCase() === "TRUE";
} else if (typeof isNullableValue === "number") {
isNullable = isNullableValue !== 0;
}
return {
columnName: col.column_name || col.columnName,
columnLabel: col.label_ko || col.columnLabel,
dataType: col.data_type || col.dataType || "unknown",
isNullable,
columnDefault: col.column_default ?? col.columnDefault ?? null,
};
});
setTargetColumns(columnInfo);
console.log(`✅ UPDATE 노드 - 컬럼 ${columnInfo.length}개 로딩 완료`);
} catch (error) {
console.error("❌ UPDATE 노드 - 컬럼 목록 로딩 실패:", error);
setTargetColumns([]);
} finally {
setColumnsLoading(false);
}
};
const handleTableSelect = async (newTableName: string) => {
const selectedTable = tables.find((t) => t.tableName === newTableName);
const newDisplayName = selectedTable?.label || selectedTable?.displayName || newTableName;
setTargetTable(newTableName);
setDisplayName(newDisplayName);
await loadColumns(newTableName);
// 즉시 반영
updateNode(nodeId, {
displayName: newDisplayName,
targetTable: newTableName,
fieldMappings,
whereConditions,
});
setTablesOpen(false);
};
const handleAddMapping = () => {
const newMappings = [
...fieldMappings,
{
sourceField: null,
targetField: "",
staticValue: undefined,
},
];
setFieldMappings(newMappings);
updateNode(nodeId, { fieldMappings: newMappings });
// Combobox 열림 상태 배열 초기화
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
};
const handleRemoveMapping = (index: number) => {
const newMappings = fieldMappings.filter((_, i) => i !== index);
setFieldMappings(newMappings);
updateNode(nodeId, { fieldMappings: newMappings });
// Combobox 열림 상태 배열도 업데이트
setMappingSourceFieldsOpenState(new Array(newMappings.length).fill(false));
setMappingTargetFieldsOpenState(new Array(newMappings.length).fill(false));
};
const handleMappingChange = (index: number, field: string, value: any) => {
const newMappings = [...fieldMappings];
// 필드 변경 시 라벨도 함께 저장
if (field === "sourceField") {
const sourceField = sourceFields.find((f) => f.name === value);
newMappings[index] = {
...newMappings[index],
sourceField: value,
sourceFieldLabel: sourceField?.label,
};
} else if (field === "targetField") {
const targetColumn = targetColumns.find((c) => c.columnName === value);
newMappings[index] = {
...newMappings[index],
targetField: value,
targetFieldLabel: targetColumn?.columnLabel,
};
} else {
newMappings[index] = { ...newMappings[index], [field]: value };
}
setFieldMappings(newMappings);
updateNode(nodeId, { fieldMappings: newMappings });
};
// 🔥 타겟 타입 변경 핸들러
const handleTargetTypeChange = (newType: "internal" | "external" | "api") => {
setTargetType(newType);
updateNode(nodeId, {
targetType: newType,
...(newType === "internal" && {
targetTable: data.targetTable,
targetConnection: data.targetConnection,
displayName: data.displayName,
}),
...(newType === "external" && {
externalConnectionId: data.externalConnectionId,
externalConnectionName: data.externalConnectionName,
externalDbType: data.externalDbType,
externalTargetTable: data.externalTargetTable,
externalTargetSchema: data.externalTargetSchema,
}),
...(newType === "api" && {
apiEndpoint: data.apiEndpoint,
apiMethod: data.apiMethod,
apiAuthType: data.apiAuthType,
apiAuthConfig: data.apiAuthConfig,
apiHeaders: data.apiHeaders,
apiBodyTemplate: data.apiBodyTemplate,
}),
});
};
const handleAddCondition = () => {
const newConditions = [
...whereConditions,
{
field: "",
operator: "EQUALS",
staticValue: "",
},
];
setWhereConditions(newConditions);
updateNode(nodeId, { whereConditions: newConditions });
// Combobox 열림 상태 배열 초기화
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
setTargetFieldsOpenState(new Array(newConditions.length).fill(false));
};
const handleRemoveCondition = (index: number) => {
const newConditions = whereConditions.filter((_, i) => i !== index);
setWhereConditions(newConditions);
updateNode(nodeId, { whereConditions: newConditions });
// Combobox 열림 상태 배열도 업데이트
setSourceFieldsOpenState(new Array(newConditions.length).fill(false));
setTargetFieldsOpenState(new Array(newConditions.length).fill(false));
};
const handleConditionChange = (index: number, field: string, value: any) => {
const newConditions = [...whereConditions];
// 필드 변경 시 라벨도 함께 저장
if (field === "field") {
const targetColumn = targetColumns.find((c) => c.columnName === value);
newConditions[index] = {
...newConditions[index],
field: value,
fieldLabel: targetColumn?.columnLabel,
};
} else if (field === "sourceField") {
const sourceField = sourceFields.find((f) => f.name === value);
newConditions[index] = {
...newConditions[index],
sourceField: value,
sourceFieldLabel: sourceField?.label,
};
} else {
newConditions[index] = { ...newConditions[index], [field]: value };
}
setWhereConditions(newConditions);
updateNode(nodeId, { whereConditions: newConditions });
};
// 즉시 반영 핸들러들
const handleDisplayNameChange = (newDisplayName: string) => {
setDisplayName(newDisplayName);
updateNode(nodeId, { displayName: newDisplayName });
};
const handleFieldMappingsChange = (newMappings: any[]) => {
setFieldMappings(newMappings);
updateNode(nodeId, { fieldMappings: newMappings });
};
const handleWhereConditionsChange = (newConditions: any[]) => {
setWhereConditions(newConditions);
updateNode(nodeId, { whereConditions: newConditions });
};
const selectedTableLabel = tables.find((t) => t.tableName === targetTable)?.label || targetTable;
return (
<div className="space-y-4 p-4 pb-8">
{/* 기본 정보 */}
<div>
<h3 className="mb-3 text-sm font-semibold"> </h3>
<div className="space-y-3">
<div>
<Label htmlFor="displayName" className="text-xs">
</Label>
<Input
id="displayName"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
className="mt-1"
placeholder="노드 표시 이름"
/>
</div>
{/* 🔥 타겟 타입 선택 */}
<div>
<Label className="mb-2 block text-xs font-medium"> </Label>
<div className="grid grid-cols-3 gap-2">
{/* 내부 데이터베이스 */}
<button
onClick={() => handleTargetTypeChange("internal")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "internal" ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Database className={cn("h-5 w-5", targetType === "internal" ? "text-blue-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "internal" ? "text-blue-700" : "text-gray-600")}
>
DB
</span>
{targetType === "internal" && <Check className="absolute top-2 right-2 h-4 w-4 text-blue-600" />}
</button>
{/* 외부 데이터베이스 */}
<button
onClick={() => handleTargetTypeChange("external")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "external" ? "border-green-500 bg-green-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Globe className={cn("h-5 w-5", targetType === "external" ? "text-green-600" : "text-gray-400")} />
<span
className={cn("text-xs font-medium", targetType === "external" ? "text-green-700" : "text-gray-600")}
>
DB
</span>
{targetType === "external" && <Check className="absolute top-2 right-2 h-4 w-4 text-green-600" />}
</button>
{/* REST API */}
<button
onClick={() => handleTargetTypeChange("api")}
className={cn(
"relative flex flex-col items-center gap-2 rounded-lg border-2 p-3 transition-all",
targetType === "api" ? "border-purple-500 bg-purple-50" : "border-gray-200 hover:border-gray-300",
)}
>
<Link2 className={cn("h-5 w-5", targetType === "api" ? "text-purple-600" : "text-gray-400")} />
<span className={cn("text-xs font-medium", targetType === "api" ? "text-purple-700" : "text-gray-600")}>
REST API
</span>
{targetType === "api" && <Check className="absolute top-2 right-2 h-4 w-4 text-purple-600" />}
</button>
</div>
</div>
{/* 내부 DB: 타겟 테이블 Combobox */}
{targetType === "internal" && (
<div>
<Label className="text-xs"> </Label>
<Popover open={tablesOpen} onOpenChange={setTablesOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={tablesOpen}
className="mt-1 w-full justify-between"
disabled={tablesLoading}
>
{tablesLoading ? (
<span className="text-muted-foreground"> ...</span>
) : targetTable ? (
<span className="truncate">{selectedTableLabel}</span>
) : (
<span className="text-muted-foreground"> </span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[320px] p-0" align="start">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandEmpty> .</CommandEmpty>
<CommandList>
<CommandGroup>
{tables.map((table) => (
<CommandItem
key={table.tableName}
value={`${table.label} ${table.displayName} ${table.tableName}`}
onSelect={() => handleTableSelect(table.tableName)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
targetTable === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.label || table.displayName}</span>
<span className="text-muted-foreground text-xs">{table.tableName}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{/* 🔥 외부 DB 설정 (INSERT 노드와 동일 패턴) */}
{targetType === "external" && (
<>
{/* 외부 커넥션 선택 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={selectedExternalConnectionId?.toString()}
onValueChange={(value) => {
const connectionId = parseInt(value);
const selectedConnection = externalConnections.find((c) => c.id === connectionId);
setSelectedExternalConnectionId(connectionId);
setExternalTargetTable("");
setExternalColumns([]);
updateNode(nodeId, {
externalConnectionId: connectionId,
externalConnectionName: selectedConnection?.name,
externalDbType: selectedConnection?.db_type,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="커넥션을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalConnectionsLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalConnections.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id!.toString()}>
<div className="flex items-center gap-2">
<span className="font-medium">{conn.db_type}</span>
<span className="text-gray-500">-</span>
<span>{conn.name}</span>
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
{/* 외부 테이블 선택 */}
{selectedExternalConnectionId && (
<div>
<Label className="mb-1.5 block text-xs font-medium"></Label>
<Select
value={externalTargetTable}
onValueChange={(value) => {
const selectedTable = externalTables.find((t) => t.table_name === value);
setExternalTargetTable(value);
updateNode(nodeId, {
externalTargetTable: value,
externalTargetSchema: selectedTable?.schema,
});
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="테이블을 선택하세요" />
</SelectTrigger>
<SelectContent>
{externalTablesLoading ? (
<div className="p-2 text-center text-xs text-gray-500"> ...</div>
) : externalTables.length === 0 ? (
<div className="p-2 text-center text-xs text-gray-500"> </div>
) : (
externalTables.map((table) => (
<SelectItem key={table.table_name} value={table.table_name}>
<div className="flex items-center gap-2">
<span className="font-medium">{table.table_name}</span>
{table.schema && <span className="text-xs text-gray-500">({table.schema})</span>}
</div>
</SelectItem>
))
)}
</SelectContent>
</Select>
</div>
)}
{/* 외부 컬럼 표시 */}
{externalTargetTable && externalColumns.length > 0 && (
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<div className="max-h-32 space-y-1 overflow-y-auto rounded border bg-gray-50 p-2">
{externalColumns.map((col) => (
<div key={col.column_name} className="flex items-center justify-between text-xs">
<span className="font-medium">{col.column_name}</span>
<span className="text-gray-500">{col.data_type}</span>
</div>
))}
</div>
</div>
)}
</>
)}
{/* 🔥 REST API 설정 */}
{targetType === "api" && (
<div className="space-y-4">
{/* API 엔드포인트 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">API </Label>
<Input
placeholder="https://api.example.com/v1/users/{id}"
value={apiEndpoint}
onChange={(e) => {
setApiEndpoint(e.target.value);
updateNode(nodeId, { apiEndpoint: e.target.value });
}}
className="h-8 text-xs"
/>
</div>
{/* HTTP 메서드 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">HTTP </Label>
<Select
value={apiMethod}
onValueChange={(value: "PUT" | "PATCH") => {
setApiMethod(value);
updateNode(nodeId, { apiMethod: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
</SelectContent>
</Select>
</div>
{/* 인증 타입 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> </Label>
<Select
value={apiAuthType}
onValueChange={(value: "none" | "basic" | "bearer" | "apikey") => {
setApiAuthType(value);
updateNode(nodeId, { apiAuthType: value });
}}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"> </SelectItem>
<SelectItem value="bearer">Bearer Token</SelectItem>
<SelectItem value="basic">Basic Auth</SelectItem>
<SelectItem value="apikey">API Key</SelectItem>
</SelectContent>
</Select>
</div>
{/* 인증 설정 */}
{apiAuthType !== "none" && (
<div className="space-y-2 rounded border bg-gray-50 p-3">
<Label className="block text-xs font-medium"> </Label>
{apiAuthType === "bearer" && (
<Input
placeholder="Bearer Token"
value={(apiAuthConfig as any)?.token || ""}
onChange={(e) => {
const newConfig = { token: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
)}
{apiAuthType === "basic" && (
<div className="space-y-2">
<Input
placeholder="사용자명"
value={(apiAuthConfig as any)?.username || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), username: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
type="password"
placeholder="비밀번호"
value={(apiAuthConfig as any)?.password || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), password: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
{apiAuthType === "apikey" && (
<div className="space-y-2">
<Input
placeholder="헤더 이름 (예: X-API-Key)"
value={(apiAuthConfig as any)?.headerName || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), headerName: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
<Input
placeholder="API Key"
value={(apiAuthConfig as any)?.apiKey || ""}
onChange={(e) => {
const newConfig = { ...(apiAuthConfig as any), apiKey: e.target.value };
setApiAuthConfig(newConfig);
updateNode(nodeId, { apiAuthConfig: newConfig });
}}
className="h-8 text-xs"
/>
</div>
)}
</div>
)}
{/* 커스텀 헤더 */}
<div>
<Label className="mb-1.5 block text-xs font-medium"> ()</Label>
<div className="space-y-2 rounded border bg-gray-50 p-3">
{Object.entries(apiHeaders).map(([key, value], index) => (
<div key={index} className="flex gap-2">
<Input
placeholder="헤더 이름"
value={key}
onChange={(e) => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
newHeaders[e.target.value] = value;
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Input
placeholder="헤더 값"
value={value}
onChange={(e) => {
const newHeaders = { ...apiHeaders, [key]: e.target.value };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 flex-1 text-xs"
/>
<Button
size="sm"
variant="ghost"
onClick={() => {
const newHeaders = { ...apiHeaders };
delete newHeaders[key];
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-7 p-0"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
))}
<Button
size="sm"
variant="outline"
onClick={() => {
const newHeaders = { ...apiHeaders, "": "" };
setApiHeaders(newHeaders);
updateNode(nodeId, { apiHeaders: newHeaders });
}}
className="h-7 w-full text-xs"
>
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
</div>
{/* 요청 바디 설정 */}
<div>
<Label className="mb-1.5 block text-xs font-medium">
릿
<span className="ml-1 text-gray-500">{"{{fieldName}}"} </span>
</Label>
<textarea
placeholder={'{\n "id": "{{id}}",\n "name": "{{name}}",\n "email": "{{email}}"\n}'}
value={apiBodyTemplate}
onChange={(e) => {
setApiBodyTemplate(e.target.value);
updateNode(nodeId, { apiBodyTemplate: e.target.value });
}}
className="w-full rounded border p-2 font-mono text-xs"
rows={8}
/>
<p className="mt-1 text-xs text-gray-500">
{"{{필드명}}"} .
</p>
</div>
</div>
)}
</div>
</div>
{/* WHERE 조건 */}
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold">WHERE </h3>
<Button size="sm" variant="outline" onClick={handleAddCondition} className="h-7 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{!targetTable && !columnsLoading && (
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
</div>
)}
{targetTable && whereConditions.length === 0 && (
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
WHERE
</div>
)}
{targetTable && whereConditions.length > 0 && targetColumns.length > 0 && (
<div className="space-y-3">
{whereConditions.map((condition, index) => (
<div key={index} className="rounded border bg-blue-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveCondition(index)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
{/* 타겟 필드 - Combobox */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Popover
open={targetFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...targetFieldsOpenState];
newState[index] = open;
setTargetFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={targetFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{condition.field
? (() => {
const col = targetColumns.find((c) => c.columnName === condition.field);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-mono">{col?.columnLabel || condition.field}</span>
<span className="text-muted-foreground text-xs">{col?.dataType}</span>
</div>
);
})()
: "필드 선택"}
<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>
{targetColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={(currentValue) => {
handleConditionChange(index, "field", currentValue);
const newState = [...targetFieldsOpenState];
newState[index] = false;
setTargetFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
condition.field === col.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-mono font-medium">{col.columnLabel || col.columnName}</span>
<span className="text-muted-foreground text-[10px]">{col.dataType}</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 연산자 */}
<div>
<Label className="text-xs text-gray-600"></Label>
<Select
value={condition.operator}
onValueChange={(value) => handleConditionChange(index, "operator", value)}
>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value} className="text-xs">
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* IS_NULL, IS_NOT_NULL이 아닐 때만 소스 필드와 정적 값 표시 */}
{condition.operator !== "IS_NULL" && condition.operator !== "IS_NOT_NULL" && (
<>
{/* 소스 필드 - Combobox */}
<div>
<Label className="text-xs text-gray-600"> ()</Label>
<Popover
open={sourceFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...sourceFieldsOpenState];
newState[index] = open;
setSourceFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={sourceFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{condition.sourceField
? (() => {
const field = sourceFields.find((f) => f.name === condition.sourceField);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-medium">
{field?.label || condition.sourceField}
</span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">{field.name}</span>
)}
</div>
);
})()
: "소스 필드 선택 (선택)"}
<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="_NONE_"
onSelect={() => {
handleConditionChange(index, "sourceField", undefined);
const newState = [...sourceFieldsOpenState];
newState[index] = false;
setSourceFieldsOpenState(newState);
}}
className="text-xs text-gray-400 sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
!condition.sourceField ? "opacity-100" : "opacity-0",
)}
/>
( )
</CommandItem>
{sourceFields.map((field) => (
<CommandItem
key={field.name}
value={field.name}
onSelect={(currentValue) => {
handleConditionChange(index, "sourceField", currentValue);
const newState = [...sourceFieldsOpenState];
newState[index] = false;
setSourceFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
condition.sourceField === field.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-[10px]">
{field.name}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 정적 값 */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Input
value={condition.staticValue || ""}
onChange={(e) => handleConditionChange(index, "staticValue", e.target.value || undefined)}
placeholder="비교할 고정 값"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</>
)}
{/* IS_NULL, IS_NOT_NULL일 때 안내 메시지 */}
{(condition.operator === "IS_NULL" || condition.operator === "IS_NOT_NULL") && (
<div className="rounded bg-blue-50 p-3 text-xs text-blue-700">
{condition.operator === "IS_NULL" ? "IS NULL" : "IS NOT NULL"}
.
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* 필드 매핑 (REST API 타입에서는 숨김) */}
{targetType !== "api" && (
<div>
<div className="mb-2 flex items-center justify-between">
<h3 className="text-sm font-semibold"> </h3>
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7 px-2 text-xs">
<Plus className="mr-1 h-3 w-3" />
</Button>
</div>
{!targetTable && !columnsLoading && (
<div className="rounded border border-dashed bg-yellow-50 p-3 text-center text-xs text-yellow-700">
</div>
)}
{targetTable && !columnsLoading && targetColumns.length === 0 && (
<div className="rounded border border-dashed bg-red-50 p-3 text-center text-xs text-red-700">
</div>
)}
{targetTable && targetColumns.length > 0 && (
<>
{fieldMappings.length > 0 ? (
<div className="space-y-3">
{fieldMappings.map((mapping, index) => (
<div key={index} className="rounded border bg-gray-50 p-3">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-medium text-gray-700"> #{index + 1}</span>
<Button
size="sm"
variant="ghost"
onClick={() => handleRemoveMapping(index)}
className="h-6 w-6 p-0 text-red-600 hover:text-red-700"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<div className="space-y-2">
{/* 소스 필드 - REST API인 경우 입력, 아니면 Combobox */}
<div>
<Label className="text-xs text-gray-600">
{hasRestAPISource && " (REST API - 직접 입력)"}
</Label>
{hasRestAPISource ? (
<Input
value={mapping.sourceField || ""}
onChange={(e) => handleMappingChange(index, "sourceField", e.target.value || null)}
placeholder="API 응답 JSON의 필드명을 입력하세요"
className="mt-1 h-8 text-xs"
/>
) : sourceFields.length === 0 ? (
<div className="mt-1 rounded border bg-gray-50 p-2 text-center text-xs text-gray-400">
</div>
) : (
<Popover
open={mappingSourceFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...mappingSourceFieldsOpenState];
newState[index] = open;
setMappingSourceFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mappingSourceFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{mapping.sourceField
? (() => {
const field = sourceFields.find((f) => f.name === mapping.sourceField);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-medium">
{field?.label || mapping.sourceField}
</span>
{field?.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-xs">
{field.name}
</span>
)}
</div>
);
})()
: "소스 필드 선택"}
<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>
{sourceFields.map((field) => (
<CommandItem
key={field.name}
value={field.name}
onSelect={(currentValue) => {
handleMappingChange(index, "sourceField", currentValue || null);
const newState = [...mappingSourceFieldsOpenState];
newState[index] = false;
setMappingSourceFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.sourceField === field.name ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{field.label || field.name}</span>
{field.label && field.label !== field.name && (
<span className="text-muted-foreground font-mono text-[10px]">
{field.name}
</span>
)}
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
)}
</div>
<div className="flex items-center justify-center py-1">
<ArrowRight className="h-4 w-4 text-blue-600" />
</div>
{/* 타겟 필드 - Combobox */}
<div>
<Label className="text-xs text-gray-600"> </Label>
<Popover
open={mappingTargetFieldsOpenState[index]}
onOpenChange={(open) => {
const newState = [...mappingTargetFieldsOpenState];
newState[index] = open;
setMappingTargetFieldsOpenState(newState);
}}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={mappingTargetFieldsOpenState[index]}
className="mt-1 h-8 w-full justify-between text-xs sm:h-10 sm:text-sm"
>
{mapping.targetField
? (() => {
const col = targetColumns.find((c) => c.columnName === mapping.targetField);
return (
<div className="flex items-center justify-between gap-2 overflow-hidden">
<span className="truncate font-mono">
{col?.columnLabel || mapping.targetField}
</span>
<span className="text-muted-foreground text-xs">
{col?.dataType}
{col && !col.isNullable && <span className="text-red-500">*</span>}
</span>
</div>
);
})()
: "타겟 필드 선택"}
<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>
{targetColumns.map((col) => (
<CommandItem
key={col.columnName}
value={col.columnName}
onSelect={(currentValue) => {
handleMappingChange(index, "targetField", currentValue);
const newState = [...mappingTargetFieldsOpenState];
newState[index] = false;
setMappingTargetFieldsOpenState(newState);
}}
className="text-xs sm:text-sm"
>
<Check
className={cn(
"mr-2 h-4 w-4",
mapping.targetField === col.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-mono font-medium">
{col.columnLabel || col.columnName}
</span>
<span className="text-muted-foreground text-[10px]">
{col.dataType}
{col.columnDefault ? (
<span className="text-blue-600"> 🔵</span>
) : (
!col.isNullable && <span className="text-red-500"> *</span>
)}
</span>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
{/* 정적 값 */}
<div>
<Label className="text-xs text-gray-600"> ()</Label>
<Input
value={mapping.staticValue || ""}
onChange={(e) => handleMappingChange(index, "staticValue", e.target.value || undefined)}
placeholder="소스 필드 대신 고정 값 사용"
className="mt-1 h-8 text-xs"
/>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
</div>
</div>
))}
</div>
) : (
<div className="rounded border border-dashed bg-gray-50 p-3 text-center text-xs text-gray-500">
</div>
)}
</>
)}
</div>
)}
</div>
);
}