데이터 흐름 설정 개선: INSERT 액션에 대한 필드 매핑 검증 로직 추가 및 새로운 InsertFieldMappingPanel 컴포넌트 구현. #34
|
|
@ -553,6 +553,28 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
|
|||
return true; // DELETE는 필드 매핑 검증 생략
|
||||
}
|
||||
|
||||
// INSERT 액션의 경우 모든 TO 테이블 컬럼이 매핑되거나 기본값이 있어야 함
|
||||
if (action.actionType === "insert") {
|
||||
// TO 테이블의 모든 컬럼을 찾기
|
||||
const toTableName = action.fieldMappings[0]?.targetTable;
|
||||
if (!toTableName) return false;
|
||||
|
||||
const toTableColumns = tableColumnsCache[toTableName] || [];
|
||||
if (toTableColumns.length === 0) return false;
|
||||
|
||||
// 모든 TO 컬럼이 매핑되거나 기본값이 있는지 확인
|
||||
return toTableColumns.every((column) => {
|
||||
const mapping = action.fieldMappings.find((m) => m.targetField === column.columnName);
|
||||
if (!mapping) return false;
|
||||
|
||||
// 소스 매핑 또는 기본값 중 하나는 있어야 함
|
||||
const hasSource = mapping.sourceTable && mapping.sourceField;
|
||||
const hasDefault = mapping.defaultValue && mapping.defaultValue.trim();
|
||||
|
||||
return hasSource || hasDefault;
|
||||
});
|
||||
}
|
||||
|
||||
return action.fieldMappings.every((mapping) => {
|
||||
// 타겟은 항상 필요
|
||||
if (!mapping.targetTable || !mapping.targetField) return false;
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
|
|||
import { Plus, Trash2 } from "lucide-react";
|
||||
import { TableInfo, ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||
import { InsertFieldMappingPanel } from "./InsertFieldMappingPanel";
|
||||
|
||||
interface ActionFieldMappingsProps {
|
||||
action: DataSaveSettings["actions"][0];
|
||||
|
|
@ -16,6 +17,10 @@ interface ActionFieldMappingsProps {
|
|||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
availableTables: TableInfo[];
|
||||
tableColumnsCache: { [tableName: string]: ColumnInfo[] };
|
||||
fromTableColumns?: ColumnInfo[];
|
||||
toTableColumns?: ColumnInfo[];
|
||||
fromTableName?: string;
|
||||
toTableName?: string;
|
||||
}
|
||||
|
||||
export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
||||
|
|
@ -25,7 +30,26 @@ export const ActionFieldMappings: React.FC<ActionFieldMappingsProps> = ({
|
|||
onSettingsChange,
|
||||
availableTables,
|
||||
tableColumnsCache,
|
||||
fromTableColumns = [],
|
||||
toTableColumns = [],
|
||||
fromTableName,
|
||||
toTableName,
|
||||
}) => {
|
||||
// INSERT 액션일 때는 새로운 패널 사용
|
||||
if (action.actionType === "insert" && fromTableColumns.length > 0 && toTableColumns.length > 0) {
|
||||
return (
|
||||
<InsertFieldMappingPanel
|
||||
action={action}
|
||||
actionIndex={actionIndex}
|
||||
settings={settings}
|
||||
onSettingsChange={onSettingsChange}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const addFieldMapping = () => {
|
||||
const newActions = [...settings.actions];
|
||||
newActions[actionIndex].fieldMappings.push({
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
"use client";
|
||||
|
||||
import React, { useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { getInputTypeForDataType } from "@/utils/connectionUtils";
|
||||
|
||||
interface ColumnMapping {
|
||||
toColumnName: string;
|
||||
fromColumnName?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
interface ColumnTableSectionProps {
|
||||
type: "from" | "to";
|
||||
tableName: string;
|
||||
columns: ColumnInfo[];
|
||||
selectedColumn: string | null;
|
||||
onColumnClick: (columnName: string) => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (term: string) => void;
|
||||
dataTypeFilter: string;
|
||||
onDataTypeFilterChange: (filter: string) => void;
|
||||
showMappedOnly: boolean;
|
||||
onShowMappedOnlyChange: (show: boolean) => void;
|
||||
showUnmappedOnly: boolean;
|
||||
onShowUnmappedOnlyChange: (show: boolean) => void;
|
||||
columnMappings: ColumnMapping[];
|
||||
onDefaultValueChange?: (columnName: string, value: string) => void;
|
||||
onRemoveMapping?: (columnName: string) => void;
|
||||
isColumnClickable: (column: ColumnInfo) => boolean;
|
||||
oppositeSelectedColumn?: string | null;
|
||||
oppositeColumns?: ColumnInfo[];
|
||||
}
|
||||
|
||||
export const ColumnTableSection: React.FC<ColumnTableSectionProps> = ({
|
||||
type,
|
||||
tableName,
|
||||
columns,
|
||||
selectedColumn,
|
||||
onColumnClick,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
dataTypeFilter,
|
||||
onDataTypeFilterChange,
|
||||
showMappedOnly,
|
||||
onShowMappedOnlyChange,
|
||||
showUnmappedOnly,
|
||||
onShowUnmappedOnlyChange,
|
||||
columnMappings,
|
||||
onDefaultValueChange,
|
||||
onRemoveMapping,
|
||||
isColumnClickable,
|
||||
oppositeSelectedColumn,
|
||||
oppositeColumns,
|
||||
}) => {
|
||||
const isFromTable = type === "from";
|
||||
|
||||
// 데이터 타입 목록 추출
|
||||
const dataTypes = useMemo(() => {
|
||||
const types = new Set(columns.map((col) => col.dataType).filter((type): type is string => !!type));
|
||||
return Array.from(types).sort();
|
||||
}, [columns]);
|
||||
|
||||
// 필터링된 컬럼 목록
|
||||
const filteredColumns = useMemo(() => {
|
||||
return columns.filter((column) => {
|
||||
// 검색어 필터
|
||||
const matchesSearch = searchTerm === "" || column.columnName.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
// 데이터 타입 필터
|
||||
const matchesDataType = dataTypeFilter === "" || column.dataType === dataTypeFilter;
|
||||
|
||||
// 매핑 상태 필터
|
||||
const isMapped = isFromTable
|
||||
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
|
||||
: (() => {
|
||||
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
|
||||
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
||||
})();
|
||||
|
||||
const matchesMappingFilter =
|
||||
(!showMappedOnly && !showUnmappedOnly) || (showMappedOnly && isMapped) || (showUnmappedOnly && !isMapped);
|
||||
|
||||
return matchesSearch && matchesDataType && matchesMappingFilter;
|
||||
});
|
||||
}, [columns, searchTerm, dataTypeFilter, showMappedOnly, showUnmappedOnly, columnMappings, isFromTable]);
|
||||
|
||||
const mappedCount = columns.filter((column) =>
|
||||
isFromTable
|
||||
? columnMappings.some((mapping) => mapping.fromColumnName === column.columnName)
|
||||
: (() => {
|
||||
const mapping = columnMappings.find((mapping) => mapping.toColumnName === column.columnName);
|
||||
return !!mapping?.fromColumnName || !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
||||
})(),
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
{/* 헤더 */}
|
||||
<div className="rounded-t-lg bg-gray-600 px-4 py-3 text-white">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{isFromTable ? "From" : "To"}: {tableName} ({columns.length}/{columns.length})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* 검색 및 필터 */}
|
||||
<div className="border-r border-l border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex flex-col gap-3">
|
||||
<Input
|
||||
placeholder="컬럼명 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Select
|
||||
value={dataTypeFilter || "all"}
|
||||
onValueChange={(value) => onDataTypeFilterChange(value === "all" ? "" : value || "")}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-full text-xs" size="sm">
|
||||
<SelectValue placeholder="모든 타입" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">모든 타입</SelectItem>
|
||||
{dataTypes.map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex cursor-pointer gap-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
onShowMappedOnlyChange(!showMappedOnly);
|
||||
if (showUnmappedOnly) onShowUnmappedOnlyChange(false);
|
||||
}}
|
||||
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
|
||||
showMappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
설정됨
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onShowUnmappedOnlyChange(!showUnmappedOnly);
|
||||
if (showMappedOnly) onShowMappedOnlyChange(false);
|
||||
}}
|
||||
className={`rounded px-2 py-1 text-xs transition-colors hover:cursor-pointer ${
|
||||
showUnmappedOnly ? "bg-gray-600 text-white" : "bg-gray-200 text-gray-700 hover:bg-gray-300"
|
||||
}`}
|
||||
>
|
||||
미설정
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 리스트 */}
|
||||
<div className="h-96 overflow-y-auto border-r border-l border-gray-200">
|
||||
{filteredColumns.map((column) => {
|
||||
const isSelected = selectedColumn === column.columnName;
|
||||
const isClickable = isColumnClickable(column);
|
||||
|
||||
if (isFromTable) {
|
||||
// FROM 테이블 렌더링
|
||||
const isMapped = columnMappings.some((mapping) => mapping.fromColumnName === column.columnName);
|
||||
const mappedToColumn = columnMappings.find(
|
||||
(mapping) => mapping.fromColumnName === column.columnName,
|
||||
)?.toColumnName;
|
||||
|
||||
// 선택된 TO 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
|
||||
const isTypeCompatible =
|
||||
!oppositeSelectedColumn ||
|
||||
isMapped ||
|
||||
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
onClick={isClickable ? () => onColumnClick(column.columnName) : undefined}
|
||||
className={`border-b border-gray-200 px-3 py-2 text-xs transition-colors ${
|
||||
isSelected
|
||||
? "bg-gray-200 text-gray-800"
|
||||
: isMapped
|
||||
? "bg-gray-100 text-gray-700"
|
||||
: oppositeSelectedColumn && !isTypeCompatible
|
||||
? "cursor-not-allowed bg-red-50 text-red-400 opacity-60"
|
||||
: isClickable
|
||||
? "cursor-pointer hover:bg-gray-50"
|
||||
: "cursor-not-allowed bg-gray-100 text-gray-400"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{column.columnName}</span>
|
||||
{isSelected && <span className="flex-shrink-0 text-blue-500">●</span>}
|
||||
{isMapped && <span className="flex-shrink-0 text-green-500">✓</span>}
|
||||
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
|
||||
>
|
||||
{column.dataType}
|
||||
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||
<span className="ml-1 text-red-400">(호환 불가)</span>
|
||||
)}
|
||||
</div>
|
||||
{isMapped && mappedToColumn && (
|
||||
<div className="mt-1 truncate text-xs text-green-600">→ {mappedToColumn}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
// TO 테이블 렌더링
|
||||
const mapping = columnMappings.find((m) => m.toColumnName === column.columnName);
|
||||
const isMapped = !!mapping?.fromColumnName;
|
||||
const hasDefaultValue = !!(mapping?.defaultValue && mapping.defaultValue.trim());
|
||||
|
||||
// 선택된 FROM 컬럼과 타입 호환성 체크 (이미 매핑된 컬럼은 제외)
|
||||
const isTypeCompatible =
|
||||
!oppositeSelectedColumn ||
|
||||
isMapped ||
|
||||
oppositeColumns?.find((col) => col.columnName === oppositeSelectedColumn)?.dataType === column.dataType;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={column.columnName}
|
||||
className={`border-b border-gray-200 transition-colors ${
|
||||
isSelected
|
||||
? "bg-gray-200"
|
||||
: isMapped
|
||||
? "bg-gray-100"
|
||||
: hasDefaultValue
|
||||
? "bg-gray-100"
|
||||
: oppositeSelectedColumn && !isTypeCompatible
|
||||
? "bg-red-50 opacity-60"
|
||||
: "bg-white"
|
||||
}`}
|
||||
>
|
||||
{/* 컬럼 정보 행 */}
|
||||
<div
|
||||
onClick={isClickable && isTypeCompatible ? () => onColumnClick(column.columnName) : undefined}
|
||||
className={`px-3 py-2 text-xs ${
|
||||
isClickable && isTypeCompatible
|
||||
? "cursor-pointer hover:bg-gray-50"
|
||||
: oppositeSelectedColumn && !isTypeCompatible
|
||||
? "cursor-not-allowed"
|
||||
: hasDefaultValue
|
||||
? "cursor-not-allowed"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">{column.columnName}</span>
|
||||
{isSelected && <span className="flex-shrink-0 text-green-500">●</span>}
|
||||
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||
<span className="flex-shrink-0 text-red-500" title="데이터 타입이 호환되지 않음">
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`mt-1 text-xs ${oppositeSelectedColumn && !isTypeCompatible ? "text-red-400" : "text-gray-500"}`}
|
||||
>
|
||||
{column.dataType}
|
||||
{oppositeSelectedColumn && !isTypeCompatible && (
|
||||
<span className="ml-1 text-red-400">(호환 불가)</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMapped && (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="truncate text-xs text-blue-600">← {mapping.fromColumnName}</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemoveMapping?.(column.columnName);
|
||||
}}
|
||||
className="flex-shrink-0 text-red-500 hover:text-red-700"
|
||||
title="매핑 제거"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMapped && onDefaultValueChange && (
|
||||
<div className="mt-2">
|
||||
<Input
|
||||
type={getInputTypeForDataType(column.dataType?.toLowerCase() || "string")}
|
||||
placeholder="기본값 입력..."
|
||||
value={mapping?.defaultValue || ""}
|
||||
onChange={(e) => onDefaultValueChange(column.columnName, e.target.value)}
|
||||
className="h-6 border-gray-200 text-xs focus:border-green-400 focus:ring-0"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={isSelected || !!oppositeSelectedColumn}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 하단 통계 */}
|
||||
<div className="rounded-b-lg border border-gray-200 bg-gray-50 p-3 text-xs text-gray-600">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>
|
||||
{isFromTable ? "매핑됨" : "설정됨"}: {mappedCount}/{columns.length}
|
||||
</span>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
표시: {filteredColumns.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -156,6 +156,10 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
|
|||
onSettingsChange={onSettingsChange}
|
||||
availableTables={availableTables}
|
||||
tableColumnsCache={tableColumnsCache}
|
||||
fromTableColumns={fromTableColumns}
|
||||
toTableColumns={toTableColumns}
|
||||
fromTableName={fromTableName}
|
||||
toTableName={toTableName}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,395 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { ColumnInfo } from "@/lib/api/dataflow";
|
||||
import { DataSaveSettings } from "@/types/connectionTypes";
|
||||
import { ColumnTableSection } from "./ColumnTableSection";
|
||||
|
||||
interface InsertFieldMappingPanelProps {
|
||||
action: DataSaveSettings["actions"][0];
|
||||
actionIndex: number;
|
||||
settings: DataSaveSettings;
|
||||
onSettingsChange: (settings: DataSaveSettings) => void;
|
||||
fromTableColumns: ColumnInfo[];
|
||||
toTableColumns: ColumnInfo[];
|
||||
fromTableName?: string;
|
||||
toTableName?: string;
|
||||
}
|
||||
|
||||
interface ColumnMapping {
|
||||
toColumnName: string;
|
||||
fromColumnName?: string;
|
||||
defaultValue?: string;
|
||||
}
|
||||
|
||||
export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = ({
|
||||
action,
|
||||
actionIndex,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
fromTableColumns,
|
||||
toTableColumns,
|
||||
fromTableName,
|
||||
toTableName,
|
||||
}) => {
|
||||
const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null);
|
||||
const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null);
|
||||
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
|
||||
|
||||
// 검색 및 필터링 상태 (FROM과 TO 독립적)
|
||||
const [fromSearchTerm, setFromSearchTerm] = useState("");
|
||||
const [toSearchTerm, setToSearchTerm] = useState("");
|
||||
const [fromDataTypeFilter, setFromDataTypeFilter] = useState("");
|
||||
const [toDataTypeFilter, setToDataTypeFilter] = useState("");
|
||||
|
||||
// FROM 테이블 필터
|
||||
const [fromShowMappedOnly, setFromShowMappedOnly] = useState(false);
|
||||
const [fromShowUnmappedOnly, setFromShowUnmappedOnly] = useState(false);
|
||||
|
||||
// TO 테이블 필터
|
||||
const [toShowMappedOnly, setToShowMappedOnly] = useState(false);
|
||||
const [toShowUnmappedOnly, setToShowUnmappedOnly] = useState(false);
|
||||
|
||||
// 기존 매핑 데이터를 columnMappings로 변환
|
||||
useEffect(() => {
|
||||
const mappings: ColumnMapping[] = toTableColumns.map((toCol) => {
|
||||
const existingMapping = action.fieldMappings.find((mapping) => mapping.targetField === toCol.columnName);
|
||||
|
||||
return {
|
||||
toColumnName: toCol.columnName,
|
||||
fromColumnName: existingMapping?.sourceField || undefined,
|
||||
defaultValue: existingMapping?.defaultValue || "",
|
||||
};
|
||||
});
|
||||
|
||||
setColumnMappings(mappings);
|
||||
}, [action.fieldMappings, toTableColumns]);
|
||||
|
||||
// columnMappings 변경 시 settings 업데이트
|
||||
const updateSettings = (newMappings: ColumnMapping[]) => {
|
||||
const newActions = [...settings.actions];
|
||||
|
||||
// 새로운 fieldMappings 생성
|
||||
const fieldMappings = newMappings
|
||||
.filter((mapping) => mapping.fromColumnName || (mapping.defaultValue && mapping.defaultValue.trim()))
|
||||
.map((mapping) => ({
|
||||
sourceTable: mapping.fromColumnName ? fromTableName || "" : "",
|
||||
sourceField: mapping.fromColumnName || "",
|
||||
targetTable: toTableName || "",
|
||||
targetField: mapping.toColumnName,
|
||||
defaultValue: mapping.defaultValue || "",
|
||||
transformFunction: "",
|
||||
}));
|
||||
|
||||
newActions[actionIndex].fieldMappings = fieldMappings;
|
||||
onSettingsChange({ ...settings, actions: newActions });
|
||||
};
|
||||
|
||||
// FROM 컬럼 클릭 핸들러
|
||||
const handleFromColumnClick = (columnName: string) => {
|
||||
if (selectedFromColumn === columnName) {
|
||||
setSelectedFromColumn(null);
|
||||
} else {
|
||||
setSelectedFromColumn(columnName);
|
||||
|
||||
// TO 컬럼이 이미 선택되어 있으면 매핑 시도
|
||||
if (selectedToColumn) {
|
||||
const fromColumn = fromTableColumns.find((col) => col.columnName === columnName);
|
||||
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn);
|
||||
|
||||
if (fromColumn && toColumn) {
|
||||
// 데이터 타입 호환성 체크
|
||||
if (fromColumn.dataType !== toColumn.dataType) {
|
||||
alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 생성
|
||||
createMapping(columnName, selectedToColumn);
|
||||
setSelectedFromColumn(null);
|
||||
setSelectedToColumn(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 공통 매핑 생성 함수
|
||||
const createMapping = (fromColumnName: string, toColumnName: string) => {
|
||||
const newMappings = columnMappings.map((mapping) => {
|
||||
if (mapping.toColumnName === toColumnName) {
|
||||
return {
|
||||
...mapping,
|
||||
fromColumnName: fromColumnName,
|
||||
defaultValue: "", // 매핑이 설정되면 기본값 초기화
|
||||
};
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
|
||||
setColumnMappings(newMappings);
|
||||
updateSettings(newMappings);
|
||||
};
|
||||
|
||||
// TO 컬럼 클릭 핸들러
|
||||
const handleToColumnClick = (toColumnName: string) => {
|
||||
const currentMapping = columnMappings.find((m) => m.toColumnName === toColumnName);
|
||||
|
||||
// 이미 매핑된 컬럼인 경우 처리하지 않음
|
||||
if (currentMapping?.fromColumnName) return;
|
||||
|
||||
if (selectedToColumn === toColumnName) {
|
||||
setSelectedToColumn(null);
|
||||
} else {
|
||||
setSelectedToColumn(toColumnName);
|
||||
|
||||
// FROM 컬럼이 이미 선택되어 있으면 매핑 시도
|
||||
if (selectedFromColumn) {
|
||||
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn);
|
||||
const toColumn = toTableColumns.find((col) => col.columnName === toColumnName);
|
||||
|
||||
if (fromColumn && toColumn) {
|
||||
// 데이터 타입 호환성 체크
|
||||
if (fromColumn.dataType !== toColumn.dataType) {
|
||||
alert(`데이터 타입이 다릅니다. FROM: ${fromColumn.dataType}, TO: ${toColumn.dataType}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 매핑 생성
|
||||
createMapping(selectedFromColumn, toColumnName);
|
||||
setSelectedFromColumn(null);
|
||||
setSelectedToColumn(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 기본값 변경 핸들러
|
||||
const handleDefaultValueChange = (toColumnName: string, value: string) => {
|
||||
const newMappings = columnMappings.map((mapping) => {
|
||||
if (mapping.toColumnName === toColumnName) {
|
||||
return {
|
||||
...mapping,
|
||||
fromColumnName: value.trim() ? undefined : mapping.fromColumnName, // 기본값이 있으면 매핑 제거
|
||||
defaultValue: value,
|
||||
};
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
|
||||
setColumnMappings(newMappings);
|
||||
updateSettings(newMappings);
|
||||
};
|
||||
|
||||
// 매핑 제거 핸들러
|
||||
const handleRemoveMapping = (toColumnName: string) => {
|
||||
const newMappings = columnMappings.map((mapping) => {
|
||||
if (mapping.toColumnName === toColumnName) {
|
||||
return {
|
||||
...mapping,
|
||||
fromColumnName: undefined,
|
||||
defaultValue: "",
|
||||
};
|
||||
}
|
||||
return mapping;
|
||||
});
|
||||
|
||||
setColumnMappings(newMappings);
|
||||
updateSettings(newMappings);
|
||||
};
|
||||
|
||||
// FROM 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성 + 1대1 매핑 제약)
|
||||
const isFromColumnClickable = (fromColumn: ColumnInfo) => {
|
||||
// 이미 다른 TO 컬럼과 매핑된 FROM 컬럼은 클릭 불가
|
||||
const isAlreadyMapped = columnMappings.some((mapping) => mapping.fromColumnName === fromColumn.columnName);
|
||||
if (isAlreadyMapped) return false;
|
||||
|
||||
if (!selectedToColumn) return true; // TO가 선택되지 않았으면 모든 FROM 클릭 가능
|
||||
|
||||
const toColumn = toTableColumns.find((col) => col.columnName === selectedToColumn);
|
||||
if (!toColumn) return true;
|
||||
|
||||
return fromColumn.dataType === toColumn.dataType;
|
||||
};
|
||||
|
||||
// TO 컬럼이 클릭 가능한지 확인 (데이터 타입 호환성)
|
||||
const isToColumnClickable = (toColumn: ColumnInfo) => {
|
||||
const currentMapping = columnMappings.find((m) => m.toColumnName === toColumn.columnName);
|
||||
|
||||
// 이미 매핑된 컬럼은 클릭 불가
|
||||
if (currentMapping?.fromColumnName) return false;
|
||||
|
||||
// 기본값이 설정된 컬럼은 클릭 불가
|
||||
if (currentMapping?.defaultValue && currentMapping.defaultValue.trim()) return false;
|
||||
|
||||
if (!selectedFromColumn) return true; // FROM이 선택되지 않았으면 모든 TO 클릭 가능
|
||||
|
||||
const fromColumn = fromTableColumns.find((col) => col.columnName === selectedFromColumn);
|
||||
if (!fromColumn) return true;
|
||||
|
||||
return fromColumn.dataType === toColumn.dataType;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-4">
|
||||
{/* 헤더 섹션 */}
|
||||
<Card className="mb-6 from-blue-50 to-green-50 py-2">
|
||||
<CardContent className="pt-0">
|
||||
<p className="text-sm leading-relaxed text-gray-700">
|
||||
양쪽 테이블의 컬럼을 클릭하여 매핑하거나, 대상 컬럼에 기본값을 입력하세요. 같은 데이터 타입의 컬럼만 매핑
|
||||
가능합니다. 하나의 FROM 컬럼은 하나의 TO 컬럼에만 매핑 가능합니다.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<ColumnTableSection
|
||||
type="from"
|
||||
tableName={fromTableName || "소스 테이블"}
|
||||
columns={fromTableColumns}
|
||||
selectedColumn={selectedFromColumn}
|
||||
onColumnClick={handleFromColumnClick}
|
||||
searchTerm={fromSearchTerm}
|
||||
onSearchChange={setFromSearchTerm}
|
||||
dataTypeFilter={fromDataTypeFilter}
|
||||
onDataTypeFilterChange={setFromDataTypeFilter}
|
||||
showMappedOnly={fromShowMappedOnly}
|
||||
onShowMappedOnlyChange={setFromShowMappedOnly}
|
||||
showUnmappedOnly={fromShowUnmappedOnly}
|
||||
onShowUnmappedOnlyChange={setFromShowUnmappedOnly}
|
||||
columnMappings={columnMappings}
|
||||
isColumnClickable={isFromColumnClickable}
|
||||
oppositeSelectedColumn={selectedToColumn}
|
||||
oppositeColumns={toTableColumns}
|
||||
/>
|
||||
|
||||
<ColumnTableSection
|
||||
type="to"
|
||||
tableName={toTableName || "대상 테이블"}
|
||||
columns={toTableColumns}
|
||||
selectedColumn={selectedToColumn}
|
||||
onColumnClick={handleToColumnClick}
|
||||
searchTerm={toSearchTerm}
|
||||
onSearchChange={setToSearchTerm}
|
||||
dataTypeFilter={toDataTypeFilter}
|
||||
onDataTypeFilterChange={setToDataTypeFilter}
|
||||
showMappedOnly={toShowMappedOnly}
|
||||
onShowMappedOnlyChange={setToShowMappedOnly}
|
||||
showUnmappedOnly={toShowUnmappedOnly}
|
||||
onShowUnmappedOnlyChange={setToShowUnmappedOnly}
|
||||
columnMappings={columnMappings}
|
||||
onDefaultValueChange={handleDefaultValueChange}
|
||||
onRemoveMapping={handleRemoveMapping}
|
||||
isColumnClickable={isToColumnClickable}
|
||||
oppositeSelectedColumn={selectedFromColumn}
|
||||
oppositeColumns={fromTableColumns}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 빠른 필터 액션 */}
|
||||
<Card className="mt-6 py-2">
|
||||
<CardContent className="flex flex-wrap gap-2 p-3">
|
||||
<span className="self-center text-sm font-medium text-gray-700">빠른 필터:</span>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFromSearchTerm("");
|
||||
setToSearchTerm("");
|
||||
setFromDataTypeFilter("");
|
||||
setToDataTypeFilter("");
|
||||
setFromShowMappedOnly(false);
|
||||
setFromShowUnmappedOnly(false);
|
||||
setToShowMappedOnly(false);
|
||||
setToShowUnmappedOnly(false);
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
전체 보기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// FROM: 미매핑만, TO: 미설정만
|
||||
setFromShowMappedOnly(false);
|
||||
setFromShowUnmappedOnly(true);
|
||||
setToShowMappedOnly(false);
|
||||
setToShowUnmappedOnly(true);
|
||||
}}
|
||||
className="h-7 bg-orange-100 text-xs text-orange-700 hover:bg-orange-200"
|
||||
>
|
||||
미매핑만 보기
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// FROM: 매핑됨만, TO: 설정됨만
|
||||
setFromShowMappedOnly(true);
|
||||
setFromShowUnmappedOnly(false);
|
||||
setToShowMappedOnly(true);
|
||||
setToShowUnmappedOnly(false);
|
||||
}}
|
||||
className="h-7 bg-green-100 text-xs text-green-700 hover:bg-green-200"
|
||||
>
|
||||
매핑됨만 보기
|
||||
</Button>
|
||||
<div className="ml-auto flex gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
FROM: {fromTableColumns.length}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
TO: {toTableColumns.length}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 매핑 통계 */}
|
||||
<Card className="mt-4 bg-gradient-to-r from-gray-50 to-gray-100 py-2">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-800">매핑 진행 상황</div>
|
||||
<div className="text-sm text-gray-600">
|
||||
총 {toTableColumns.length}개 컬럼 중{" "}
|
||||
<span className="font-bold text-blue-600">
|
||||
{columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length}
|
||||
개
|
||||
</span>{" "}
|
||||
완료
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-2xl font-bold text-gray-800">
|
||||
{Math.round(
|
||||
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
||||
toTableColumns.length) *
|
||||
100,
|
||||
)}
|
||||
%
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">완료율</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<Progress
|
||||
value={
|
||||
(columnMappings.filter((m) => m.fromColumnName || (m.defaultValue && m.defaultValue.trim())).length /
|
||||
toTableColumns.length) *
|
||||
100
|
||||
}
|
||||
className="h-2"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -358,7 +358,7 @@ export class DataFlowAPI {
|
|||
}
|
||||
|
||||
/**
|
||||
* 테이블 컬럼 정보 조회
|
||||
* 테이블 컬럼 정보 조회 (모든 컬럼)
|
||||
*/
|
||||
static async getTableColumns(tableName: string): Promise<ColumnInfo[]> {
|
||||
try {
|
||||
|
|
@ -369,7 +369,7 @@ export class DataFlowAPI {
|
|||
total: number;
|
||||
totalPages: number;
|
||||
}>
|
||||
>(`/table-management/tables/${tableName}/columns`);
|
||||
>(`/table-management/tables/${tableName}/columns?size=1000`);
|
||||
|
||||
if (!response.data.success) {
|
||||
throw new Error(response.data.message || "컬럼 정보 조회에 실패했습니다.");
|
||||
|
|
|
|||
|
|
@ -220,9 +220,9 @@ export const tableTypeApi = {
|
|||
}
|
||||
},
|
||||
|
||||
// 테이블 컬럼 정보 조회
|
||||
// 테이블 컬럼 정보 조회 (모든 컬럼)
|
||||
getColumns: async (tableName: string): Promise<any[]> => {
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`);
|
||||
const response = await apiClient.get(`/table-management/tables/${tableName}/columns?size=1000`);
|
||||
// 새로운 API 응답 구조에 맞게 수정: { columns, total, page, size, totalPages }
|
||||
const data = response.data.data || response.data;
|
||||
return data.columns || data || [];
|
||||
|
|
|
|||
Loading…
Reference in New Issue