396 lines
14 KiB
TypeScript
396 lines
14 KiB
TypeScript
|
|
"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>
|
||
|
|
);
|
||
|
|
};
|