데이터 흐름 설정 개선: INSERT 액션에 대한 필드 매핑 검증 로직 추가 및 새로운 InsertFieldMappingPanel 컴포넌트 구현. #34

Merged
hyeonsu merged 2 commits from dataflowMng into dev 2025-09-18 17:20:49 +09:00
7 changed files with 783 additions and 4 deletions

View File

@ -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;

View File

@ -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({

View File

@ -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>
);
};

View File

@ -156,6 +156,10 @@ export const DataSaveSettings: React.FC<DataSaveSettingsProps> = ({
onSettingsChange={onSettingsChange}
availableTables={availableTables}
tableColumnsCache={tableColumnsCache}
fromTableColumns={fromTableColumns}
toTableColumns={toTableColumns}
fromTableName={fromTableName}
toTableName={toTableName}
/>
)}

View File

@ -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>
);
};

View File

@ -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 || "컬럼 정보 조회에 실패했습니다.");

View File

@ -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 || [];