ERP-node/frontend/components/dataflow/connection/InsertFieldMappingPanel.tsx

486 lines
18 KiB
TypeScript
Raw Normal View History

"use client";
import React, { useState, useEffect, useMemo } 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";
import {
getColumnsFromConnection,
getTablesFromConnection,
ColumnInfo as MultiColumnInfo,
} from "@/lib/api/multiConnection";
interface InsertFieldMappingPanelProps {
action: DataSaveSettings["actions"][0];
actionIndex: number;
settings: DataSaveSettings;
onSettingsChange: (settings: DataSaveSettings) => void;
fromTableColumns?: ColumnInfo[];
toTableColumns?: ColumnInfo[];
fromTableName?: string;
toTableName?: string;
// 다중 커넥션 지원
fromConnectionId?: number;
toConnectionId?: number;
}
interface ColumnMapping {
toColumnName: string;
fromColumnName?: string;
defaultValue?: string;
}
export const InsertFieldMappingPanel: React.FC<InsertFieldMappingPanelProps> = ({
action,
actionIndex,
settings,
onSettingsChange,
fromTableColumns = [],
toTableColumns = [],
fromTableName,
toTableName,
fromConnectionId,
toConnectionId,
}) => {
const [selectedFromColumn, setSelectedFromColumn] = useState<string | null>(null);
const [selectedToColumn, setSelectedToColumn] = useState<string | null>(null);
const [columnMappings, setColumnMappings] = useState<ColumnMapping[]>([]);
// 다중 커넥션에서 로드한 컬럼 정보
const [multiFromColumns, setMultiFromColumns] = useState<MultiColumnInfo[]>([]);
const [multiToColumns, setMultiToColumns] = useState<MultiColumnInfo[]>([]);
const [isLoadingColumns, setIsLoadingColumns] = useState(false);
// 테이블 라벨명 정보
const [fromTableDisplayName, setFromTableDisplayName] = useState<string>("");
const [toTableDisplayName, setToTableDisplayName] = useState<string>("");
// 검색 및 필터링 상태 (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);
// 다중 커넥션에서 컬럼 정보 및 테이블 라벨명 로드
useEffect(() => {
const loadColumnsAndTableInfo = async () => {
if (fromConnectionId !== undefined && toConnectionId !== undefined && fromTableName && toTableName) {
setIsLoadingColumns(true);
try {
const [fromCols, toCols, fromTables, toTables] = await Promise.all([
getColumnsFromConnection(fromConnectionId, fromTableName),
getColumnsFromConnection(toConnectionId, toTableName),
getTablesFromConnection(fromConnectionId),
getTablesFromConnection(toConnectionId),
]);
setMultiFromColumns(fromCols);
setMultiToColumns(toCols);
// 테이블 라벨명 설정
const fromTable = fromTables.find((t) => t.tableName === fromTableName);
const toTable = toTables.find((t) => t.tableName === toTableName);
setFromTableDisplayName(
fromTable?.displayName && fromTable.displayName !== fromTable.tableName
? fromTable.displayName
: fromTableName,
);
setToTableDisplayName(
toTable?.displayName && toTable.displayName !== toTable.tableName ? toTable.displayName : toTableName,
);
} catch (error) {
console.error("컬럼 정보 및 테이블 정보 로드 실패:", error);
} finally {
setIsLoadingColumns(false);
}
}
};
loadColumnsAndTableInfo();
}, [fromConnectionId, toConnectionId, fromTableName, toTableName]);
// 사용할 컬럼 데이터 결정 (다중 커넥션 > 기존)
const actualFromColumns = useMemo(() => {
if (multiFromColumns.length > 0) {
return multiFromColumns.map((col) => ({
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength,
description: col.description,
}));
}
return fromTableColumns || [];
}, [multiFromColumns.length, fromTableColumns?.length]);
const actualToColumns = useMemo(() => {
if (multiToColumns.length > 0) {
return multiToColumns.map((col) => ({
columnName: col.columnName,
displayName: col.displayName,
dataType: col.dataType,
isNullable: col.isNullable,
isPrimaryKey: col.isPrimaryKey,
defaultValue: col.defaultValue,
maxLength: col.maxLength,
description: col.description,
}));
}
return toTableColumns || [];
}, [multiToColumns.length, toTableColumns?.length]);
// 기존 매핑 데이터를 columnMappings로 변환
useEffect(() => {
const columnsToUse = multiToColumns.length > 0 ? multiToColumns : toTableColumns || [];
const mappings: ColumnMapping[] = columnsToUse.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, multiToColumns.length, toTableColumns?.length]);
// 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 = actualToColumns.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 = actualFromColumns.find((col) => col.columnName === selectedFromColumn);
if (!fromColumn) return true;
return fromColumn.dataType === toColumn.dataType;
};
return (
<div className="mt-4">
{/* 헤더 섹션 */}
2025-09-19 09:36:36 +09:00
<p className="mb-4 text-sm leading-relaxed text-gray-700">
, .
. FROM TO .
</p>
<div className="grid grid-cols-2 gap-6">
<ColumnTableSection
type="from"
tableName={fromTableDisplayName || fromTableName || "소스 테이블"}
columns={actualFromColumns}
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={actualToColumns}
/>
<ColumnTableSection
type="to"
tableName={toTableDisplayName || toTableName || "대상 테이블"}
columns={actualToColumns}
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={actualFromColumns}
/>
</div>
{/* 빠른 필터 액션 */}
2025-09-19 09:36:36 +09:00
<Card className="mt-6 border-none py-2 shadow-none">
<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: {actualFromColumns.length}
</Badge>
<Badge variant="outline" className="text-xs">
TO: {actualToColumns.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 /
actualToColumns.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 /
actualToColumns.length) *
100
}
className="h-2"
/>
</div>
</CardContent>
</Card>
</div>
);
};