제어관리 데이터 저장기능
This commit is contained in:
parent
2a4e379dc4
commit
9454e3a81f
|
|
@ -88,16 +88,19 @@ app.use(
|
||||||
// Rate Limiting (개발 환경에서는 완화)
|
// Rate Limiting (개발 환경에서는 완화)
|
||||||
const limiter = rateLimit({
|
const limiter = rateLimit({
|
||||||
windowMs: 1 * 60 * 1000, // 1분
|
windowMs: 1 * 60 * 1000, // 1분
|
||||||
max: config.nodeEnv === "development" ? 5000 : 100, // 개발환경에서는 5000으로 증가, 운영환경에서는 100
|
max: config.nodeEnv === "development" ? 10000 : 100, // 개발환경에서는 10000으로 증가, 운영환경에서는 100
|
||||||
message: {
|
message: {
|
||||||
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
error: "너무 많은 요청이 발생했습니다. 잠시 후 다시 시도해주세요.",
|
||||||
},
|
},
|
||||||
skip: (req) => {
|
skip: (req) => {
|
||||||
// 헬스 체크와 테이블/컬럼 조회는 Rate Limiting 완화
|
// 헬스 체크와 자주 호출되는 API들은 Rate Limiting 완화
|
||||||
return (
|
return (
|
||||||
req.path === "/health" ||
|
req.path === "/health" ||
|
||||||
req.path.includes("/table-management/") ||
|
req.path.includes("/table-management/") ||
|
||||||
req.path.includes("/external-db-connections/")
|
req.path.includes("/external-db-connections/") ||
|
||||||
|
req.path.includes("/screen-management/") ||
|
||||||
|
req.path.includes("/multi-connection/") ||
|
||||||
|
req.path.includes("/dataflow-diagrams/")
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -63,9 +63,19 @@ export class CommonCodeController {
|
||||||
size: size ? parseInt(size as string) : undefined,
|
size: size ? parseInt(size as string) : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 프론트엔드가 기대하는 형식으로 데이터 변환
|
||||||
|
const transformedData = result.data.map((code: any) => ({
|
||||||
|
codeValue: code.code_value,
|
||||||
|
codeName: code.code_name,
|
||||||
|
description: code.description,
|
||||||
|
sortOrder: code.sort_order,
|
||||||
|
isActive: code.is_active === "Y",
|
||||||
|
useYn: code.is_active,
|
||||||
|
}));
|
||||||
|
|
||||||
return res.json({
|
return res.json({
|
||||||
success: true,
|
success: true,
|
||||||
data: result.data,
|
data: transformedData,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
message: `코드 목록 조회 성공 (${categoryCode})`,
|
message: `코드 목록 조회 성공 (${categoryCode})`,
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -617,18 +617,56 @@ export class MultiConnectionQueryService {
|
||||||
`✅ 메인 DB 컬럼 조회 성공: ${columnsResult.columns.length}개`
|
`✅ 메인 DB 컬럼 조회 성공: ${columnsResult.columns.length}개`
|
||||||
);
|
);
|
||||||
|
|
||||||
return columnsResult.columns.map((column) => ({
|
// 디버깅: inputType이 'code'인 컬럼들 확인
|
||||||
|
const codeColumns = columnsResult.columns.filter(
|
||||||
|
(col) => col.inputType === "code"
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
"🔍 메인 DB 코드 타입 컬럼들:",
|
||||||
|
codeColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
inputType: col.inputType,
|
||||||
|
webType: col.webType,
|
||||||
|
codeCategory: col.codeCategory,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
const mappedColumns = columnsResult.columns.map((column) => ({
|
||||||
columnName: column.columnName,
|
columnName: column.columnName,
|
||||||
displayName: column.displayName || column.columnName, // 라벨이 있으면 라벨 사용, 없으면 컬럼명
|
displayName: column.displayName || column.columnName, // 라벨이 있으면 라벨 사용, 없으면 컬럼명
|
||||||
dataType: column.dataType,
|
dataType: column.dataType,
|
||||||
dbType: column.dataType, // dataType을 dbType으로 사용
|
dbType: column.dataType, // dataType을 dbType으로 사용
|
||||||
webType: column.webType || "text", // webType 사용, 기본값 text
|
webType: column.webType || "text", // webType 사용, 기본값 text
|
||||||
|
inputType: column.inputType || "direct", // column_labels의 input_type 추가
|
||||||
|
codeCategory: column.codeCategory, // 코드 카테고리 정보 추가
|
||||||
isNullable: column.isNullable === "Y",
|
isNullable: column.isNullable === "Y",
|
||||||
isPrimaryKey: column.isPrimaryKey || false,
|
isPrimaryKey: column.isPrimaryKey || false,
|
||||||
defaultValue: column.defaultValue,
|
defaultValue: column.defaultValue,
|
||||||
maxLength: column.maxLength,
|
maxLength: column.maxLength,
|
||||||
description: column.description,
|
description: column.description,
|
||||||
|
connectionId: 0, // 메인 DB 구분용
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// 디버깅: 매핑된 컬럼 정보 확인
|
||||||
|
console.log(
|
||||||
|
"🔍 매핑된 컬럼 정보 샘플:",
|
||||||
|
mappedColumns.slice(0, 3).map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
inputType: col.inputType,
|
||||||
|
webType: col.webType,
|
||||||
|
connectionId: col.connectionId,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
// status 컬럼 특별 확인
|
||||||
|
const statusColumn = mappedColumns.find(
|
||||||
|
(col) => col.columnName === "status"
|
||||||
|
);
|
||||||
|
if (statusColumn) {
|
||||||
|
console.log("🔍 status 컬럼 상세 정보:", statusColumn);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mappedColumns;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 외부 DB 연결 정보 가져오기
|
// 외부 DB 연결 정보 가져오기
|
||||||
|
|
@ -701,6 +739,7 @@ export class MultiConnectionQueryService {
|
||||||
dataType: dataType,
|
dataType: dataType,
|
||||||
dbType: dataType,
|
dbType: dataType,
|
||||||
webType: this.mapDataTypeToWebType(dataType),
|
webType: this.mapDataTypeToWebType(dataType),
|
||||||
|
inputType: "direct", // 외부 DB는 항상 direct (코드 타입 없음)
|
||||||
isNullable:
|
isNullable:
|
||||||
column.nullable === "YES" || // MSSQL (MSSQLConnector alias)
|
column.nullable === "YES" || // MSSQL (MSSQLConnector alias)
|
||||||
column.is_nullable === "YES" || // PostgreSQL
|
column.is_nullable === "YES" || // PostgreSQL
|
||||||
|
|
@ -715,6 +754,7 @@ export class MultiConnectionQueryService {
|
||||||
column.max_length || // MSSQL (MSSQLConnector alias)
|
column.max_length || // MSSQL (MSSQLConnector alias)
|
||||||
column.character_maximum_length || // PostgreSQL
|
column.character_maximum_length || // PostgreSQL
|
||||||
column.CHARACTER_MAXIMUM_LENGTH,
|
column.CHARACTER_MAXIMUM_LENGTH,
|
||||||
|
connectionId: connectionId, // 외부 DB 구분용
|
||||||
description: columnComment,
|
description: columnComment,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -52,22 +52,45 @@ export default function DataFlowList({ onDesignDiagram }: DataFlowListProps) {
|
||||||
const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode);
|
const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode);
|
||||||
|
|
||||||
// JSON API 응답을 기존 형식으로 변환
|
// JSON API 응답을 기존 형식으로 변환
|
||||||
const convertedDiagrams = response.diagrams.map((diagram) => ({
|
const convertedDiagrams = response.diagrams.map((diagram) => {
|
||||||
diagramId: diagram.diagram_id,
|
// relationships 구조 분석
|
||||||
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
const relationships = diagram.relationships || {};
|
||||||
diagramName: diagram.diagram_name,
|
|
||||||
connectionType: "json-based", // 새로운 JSON 기반 타입
|
// 테이블 정보 추출
|
||||||
relationshipType: "multi-relationship", // 다중 관계 타입
|
const tables: string[] = [];
|
||||||
relationshipCount: diagram.relationships?.relationships?.length || 0,
|
if (relationships.fromTable?.tableName) {
|
||||||
tableCount: diagram.relationships?.tables?.length || 0,
|
tables.push(relationships.fromTable.tableName);
|
||||||
tables: diagram.relationships?.tables || [],
|
}
|
||||||
companyCode: diagram.company_code, // 회사 코드 추가
|
if (
|
||||||
createdAt: new Date(diagram.created_at || new Date()),
|
relationships.toTable?.tableName &&
|
||||||
createdBy: diagram.created_by || "SYSTEM",
|
relationships.toTable.tableName !== relationships.fromTable?.tableName
|
||||||
updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()),
|
) {
|
||||||
updatedBy: diagram.updated_by || "SYSTEM",
|
tables.push(relationships.toTable.tableName);
|
||||||
lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(),
|
}
|
||||||
}));
|
|
||||||
|
// 관계 수 계산 (actionGroups 기준)
|
||||||
|
const actionGroups = relationships.actionGroups || [];
|
||||||
|
const relationshipCount = actionGroups.reduce((count: number, group: any) => {
|
||||||
|
return count + (group.actions?.length || 0);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
diagramId: diagram.diagram_id,
|
||||||
|
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
|
||||||
|
diagramName: diagram.diagram_name,
|
||||||
|
connectionType: relationships.connectionType || "data_save", // 실제 연결 타입 사용
|
||||||
|
relationshipType: "multi-relationship", // 다중 관계 타입
|
||||||
|
relationshipCount: relationshipCount || 1, // 최소 1개는 있다고 가정
|
||||||
|
tableCount: tables.length,
|
||||||
|
tables: tables,
|
||||||
|
companyCode: diagram.company_code, // 회사 코드 추가
|
||||||
|
createdAt: new Date(diagram.created_at || new Date()),
|
||||||
|
createdBy: diagram.created_by || "SYSTEM",
|
||||||
|
updatedAt: new Date(diagram.updated_at || diagram.created_at || new Date()),
|
||||||
|
updatedBy: diagram.updated_by || "SYSTEM",
|
||||||
|
lastUpdated: diagram.updated_at || diagram.created_at || new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
setDiagrams(convertedDiagrams);
|
setDiagrams(convertedDiagrams);
|
||||||
setTotal(response.pagination.total || 0);
|
setTotal(response.pagination.total || 0);
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,8 @@ import { toast } from "sonner";
|
||||||
import { X, ArrowLeft } from "lucide-react";
|
import { X, ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
// API import
|
// API import
|
||||||
import { saveDataflowRelationship } from "@/lib/api/dataflowSave";
|
import { saveDataflowRelationship, checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
||||||
|
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
||||||
|
|
||||||
// 타입 import
|
// 타입 import
|
||||||
import {
|
import {
|
||||||
|
|
@ -26,7 +27,6 @@ import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||||
// 컴포넌트 import
|
// 컴포넌트 import
|
||||||
import LeftPanel from "./LeftPanel/LeftPanel";
|
import LeftPanel from "./LeftPanel/LeftPanel";
|
||||||
import RightPanel from "./RightPanel/RightPanel";
|
import RightPanel from "./RightPanel/RightPanel";
|
||||||
import SaveRelationshipDialog from "./SaveRelationshipDialog";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🎨 데이터 연결 설정 메인 디자이너
|
* 🎨 데이터 연결 설정 메인 디자이너
|
||||||
|
|
@ -74,6 +74,7 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
isEnabled: true,
|
isEnabled: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
groupsLogicalOperator: "AND" as "AND" | "OR",
|
||||||
|
|
||||||
// 기존 호환성 필드들 (deprecated)
|
// 기존 호환성 필드들 (deprecated)
|
||||||
actionType: "insert",
|
actionType: "insert",
|
||||||
|
|
@ -81,11 +82,15 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
actionFieldMappings: [],
|
actionFieldMappings: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
validationErrors: [],
|
validationErrors: [],
|
||||||
|
|
||||||
|
// 컬럼 정보 초기화
|
||||||
|
fromColumns: [],
|
||||||
|
toColumns: [],
|
||||||
...initialData,
|
...initialData,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// 💾 저장 다이얼로그 상태
|
// 🔧 수정 모드 감지 (initialData에 diagramId가 있으면 수정 모드)
|
||||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
const diagramId = initialData?.diagramId;
|
||||||
|
|
||||||
// 🔄 초기 데이터 로드
|
// 🔄 초기 데이터 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -96,15 +101,50 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
connectionType: initialData.connectionType || prev.connectionType,
|
connectionType: initialData.connectionType || prev.connectionType,
|
||||||
|
|
||||||
|
// 🔧 관계 정보 로드
|
||||||
|
relationshipName: initialData.relationshipName || prev.relationshipName,
|
||||||
|
description: initialData.description || prev.description,
|
||||||
|
groupsLogicalOperator: initialData.groupsLogicalOperator || prev.groupsLogicalOperator,
|
||||||
|
|
||||||
fromConnection: initialData.fromConnection || prev.fromConnection,
|
fromConnection: initialData.fromConnection || prev.fromConnection,
|
||||||
toConnection: initialData.toConnection || prev.toConnection,
|
toConnection: initialData.toConnection || prev.toConnection,
|
||||||
fromTable: initialData.fromTable || prev.fromTable,
|
fromTable: initialData.fromTable || prev.fromTable,
|
||||||
toTable: initialData.toTable || prev.toTable,
|
toTable: initialData.toTable || prev.toTable,
|
||||||
actionType: initialData.actionType || prev.actionType,
|
|
||||||
controlConditions: initialData.controlConditions || prev.controlConditions,
|
controlConditions: initialData.controlConditions || prev.controlConditions,
|
||||||
actionConditions: initialData.actionConditions || prev.actionConditions,
|
|
||||||
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
fieldMappings: initialData.fieldMappings || prev.fieldMappings,
|
||||||
currentStep: initialData.fromConnection && initialData.toConnection ? 2 : 1, // 연결 정보가 있으면 2단계부터 시작
|
|
||||||
|
// 🔧 액션 그룹 데이터 로드 (기존 호환성 포함)
|
||||||
|
actionGroups:
|
||||||
|
initialData.actionGroups ||
|
||||||
|
// 기존 단일 액션 데이터를 그룹으로 변환
|
||||||
|
(initialData.actionType || initialData.actionConditions
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "group_1",
|
||||||
|
name: "기본 액션 그룹",
|
||||||
|
logicalOperator: "AND" as const,
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: "action_1",
|
||||||
|
name: "액션 1",
|
||||||
|
actionType: initialData.actionType || ("insert" as const),
|
||||||
|
conditions: initialData.actionConditions || [],
|
||||||
|
fieldMappings: initialData.actionFieldMappings || [],
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
isEnabled: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: prev.actionGroups),
|
||||||
|
|
||||||
|
// 기존 호환성 필드들
|
||||||
|
actionType: initialData.actionType || prev.actionType,
|
||||||
|
actionConditions: initialData.actionConditions || prev.actionConditions,
|
||||||
|
actionFieldMappings: initialData.actionFieldMappings || prev.actionFieldMappings,
|
||||||
|
|
||||||
|
currentStep: initialData.fromConnection && initialData.toConnection ? 4 : 1, // 연결 정보가 있으면 4단계부터 시작
|
||||||
}));
|
}));
|
||||||
|
|
||||||
console.log("✅ 초기 데이터 로드 완료");
|
console.log("✅ 초기 데이터 로드 완료");
|
||||||
|
|
@ -130,6 +170,26 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
toast.success(`연결 타입이 ${type === "data_save" ? "데이터 저장" : "외부 호출"}로 변경되었습니다.`);
|
||||||
}, []),
|
}, []),
|
||||||
|
|
||||||
|
// 🔧 관계 정보 설정
|
||||||
|
setRelationshipName: useCallback((name: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
relationshipName: name,
|
||||||
|
}));
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
setDescription: useCallback((description: string) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
description: description,
|
||||||
|
}));
|
||||||
|
}, []),
|
||||||
|
|
||||||
|
setGroupsLogicalOperator: useCallback((operator: "AND" | "OR") => {
|
||||||
|
setState((prev) => ({ ...prev, groupsLogicalOperator: operator }));
|
||||||
|
console.log("🔄 그룹 간 논리 연산자 변경:", operator);
|
||||||
|
}, []),
|
||||||
|
|
||||||
// 단계 이동
|
// 단계 이동
|
||||||
goToStep: useCallback((step: 1 | 2 | 3 | 4) => {
|
goToStep: useCallback((step: 1 | 2 | 3 | 4) => {
|
||||||
setState((prev) => ({ ...prev, currentStep: step }));
|
setState((prev) => ({ ...prev, currentStep: step }));
|
||||||
|
|
@ -152,21 +212,75 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[type === "from" ? "fromTable" : "toTable"]: table,
|
[type === "from" ? "fromTable" : "toTable"]: table,
|
||||||
// 테이블 변경 시 매핑 초기화
|
// 테이블 변경 시 매핑과 컬럼 정보 초기화
|
||||||
fieldMappings: [],
|
fieldMappings: [],
|
||||||
|
fromColumns: type === "from" ? [] : prev.fromColumns,
|
||||||
|
toColumns: type === "to" ? [] : prev.toColumns,
|
||||||
}));
|
}));
|
||||||
toast.success(
|
toast.success(
|
||||||
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
`${type === "from" ? "소스" : "대상"} 테이블이 선택되었습니다: ${table.displayName || table.tableName}`,
|
||||||
);
|
);
|
||||||
}, []),
|
}, []),
|
||||||
|
|
||||||
// 필드 매핑 생성
|
// 컬럼 정보 로드 (중앙 관리)
|
||||||
|
loadColumns: useCallback(async () => {
|
||||||
|
if (!state.fromConnection || !state.toConnection || !state.fromTable || !state.toTable) {
|
||||||
|
console.log("❌ 컬럼 로드: 필수 정보 누락");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미 로드된 경우 스킵 (배열 길이로 확인)
|
||||||
|
if (state.fromColumns && state.toColumns && state.fromColumns.length > 0 && state.toColumns.length > 0) {
|
||||||
|
console.log("✅ 컬럼 정보 이미 로드됨, 스킵", {
|
||||||
|
fromColumns: state.fromColumns.length,
|
||||||
|
toColumns: state.toColumns.length,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("🔄 중앙 컬럼 로드 시작:", {
|
||||||
|
from: `${state.fromConnection.id}/${state.fromTable.tableName}`,
|
||||||
|
to: `${state.toConnection.id}/${state.toTable.tableName}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: true,
|
||||||
|
fromColumns: [],
|
||||||
|
toColumns: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [fromCols, toCols] = await Promise.all([
|
||||||
|
getColumnsFromConnection(state.fromConnection.id, state.fromTable.tableName),
|
||||||
|
getColumnsFromConnection(state.toConnection.id, state.toTable.tableName),
|
||||||
|
]);
|
||||||
|
|
||||||
|
console.log("✅ 중앙 컬럼 로드 완료:", {
|
||||||
|
fromColumns: fromCols.length,
|
||||||
|
toColumns: toCols.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
fromColumns: Array.isArray(fromCols) ? fromCols : [],
|
||||||
|
toColumns: Array.isArray(toCols) ? toCols : [],
|
||||||
|
isLoading: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 중앙 컬럼 로드 실패:", error);
|
||||||
|
setState((prev) => ({ ...prev, isLoading: false }));
|
||||||
|
toast.error("컬럼 정보를 불러오는데 실패했습니다.");
|
||||||
|
}
|
||||||
|
}, [state.fromConnection, state.toConnection, state.fromTable, state.toTable, state.fromColumns, state.toColumns]),
|
||||||
|
|
||||||
|
// 필드 매핑 생성 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||||
createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => {
|
createMapping: useCallback((fromField: ColumnInfo, toField: ColumnInfo) => {
|
||||||
const newMapping: FieldMapping = {
|
const newMapping: FieldMapping = {
|
||||||
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
||||||
fromField,
|
fromField,
|
||||||
toField,
|
toField,
|
||||||
isValid: true, // 기본적으로 유효하다고 가정, 나중에 검증
|
isValid: true,
|
||||||
validationMessage: undefined,
|
validationMessage: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -175,6 +289,11 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
fieldMappings: [...prev.fieldMappings, newMapping],
|
fieldMappings: [...prev.fieldMappings, newMapping],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log("🔗 전역 매핑 생성 (호환성):", {
|
||||||
|
newMapping,
|
||||||
|
fieldName: `${fromField.columnName} → ${toField.columnName}`,
|
||||||
|
});
|
||||||
|
|
||||||
toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`);
|
toast.success(`매핑이 생성되었습니다: ${fromField.columnName} → ${toField.columnName}`);
|
||||||
}, []),
|
}, []),
|
||||||
|
|
||||||
|
|
@ -188,12 +307,14 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
}));
|
}));
|
||||||
}, []),
|
}, []),
|
||||||
|
|
||||||
// 필드 매핑 삭제
|
// 필드 매핑 삭제 (호환성용 - 실제로는 각 액션에서 직접 관리)
|
||||||
deleteMapping: useCallback((mappingId: string) => {
|
deleteMapping: useCallback((mappingId: string) => {
|
||||||
setState((prev) => ({
|
setState((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId),
|
fieldMappings: prev.fieldMappings.filter((mapping) => mapping.id !== mappingId),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
console.log("🗑️ 전역 매핑 삭제 (호환성):", { mappingId });
|
||||||
toast.success("매핑이 삭제되었습니다.");
|
toast.success("매핑이 삭제되었습니다.");
|
||||||
}, []),
|
}, []),
|
||||||
|
|
||||||
|
|
@ -404,10 +525,73 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
toast.success("액션이 삭제되었습니다.");
|
toast.success("액션이 삭제되었습니다.");
|
||||||
}, []),
|
}, []),
|
||||||
|
|
||||||
// 매핑 저장 (다이얼로그 표시)
|
// 매핑 저장 (직접 저장)
|
||||||
saveMappings: useCallback(async () => {
|
saveMappings: useCallback(async () => {
|
||||||
setShowSaveDialog(true);
|
// 관계명과 설명이 없으면 저장할 수 없음
|
||||||
}, []),
|
if (!state.relationshipName?.trim()) {
|
||||||
|
toast.error("관계 이름을 입력해주세요.");
|
||||||
|
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 중복 체크 (수정 모드가 아닌 경우에만)
|
||||||
|
if (!diagramId) {
|
||||||
|
try {
|
||||||
|
const duplicateCheck = await checkRelationshipNameDuplicate(state.relationshipName, diagramId);
|
||||||
|
if (duplicateCheck.isDuplicate) {
|
||||||
|
toast.error(`"${state.relationshipName}" 이름이 이미 사용 중입니다. 다른 이름을 사용해주세요.`);
|
||||||
|
actions.goToStep(1); // 첫 번째 단계로 이동
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("중복 체크 실패:", error);
|
||||||
|
toast.error("관계명 중복 체크 중 오류가 발생했습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isLoading: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 실제 저장 로직 구현
|
||||||
|
const saveData = {
|
||||||
|
relationshipName: state.relationshipName,
|
||||||
|
description: state.description,
|
||||||
|
connectionType: state.connectionType,
|
||||||
|
fromConnection: state.fromConnection,
|
||||||
|
toConnection: state.toConnection,
|
||||||
|
fromTable: state.fromTable,
|
||||||
|
toTable: state.toTable,
|
||||||
|
// 🔧 멀티 액션 그룹 데이터 포함
|
||||||
|
actionGroups: state.actionGroups,
|
||||||
|
groupsLogicalOperator: state.groupsLogicalOperator,
|
||||||
|
// 기존 호환성을 위한 필드들 (첫 번째 액션 그룹의 첫 번째 액션에서 추출)
|
||||||
|
actionType: state.actionGroups[0]?.actions[0]?.actionType || state.actionType || "insert",
|
||||||
|
controlConditions: state.controlConditions,
|
||||||
|
actionConditions: state.actionGroups[0]?.actions[0]?.conditions || state.actionConditions || [],
|
||||||
|
fieldMappings: state.actionGroups[0]?.actions[0]?.fieldMappings || state.fieldMappings || [],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("💾 직접 저장 시작:", { saveData, diagramId, isEdit: !!diagramId });
|
||||||
|
|
||||||
|
// 백엔드 API 호출 (수정 모드인 경우 diagramId 전달)
|
||||||
|
const result = await saveDataflowRelationship(saveData, diagramId);
|
||||||
|
|
||||||
|
console.log("✅ 저장 완료:", result);
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isLoading: false }));
|
||||||
|
toast.success(`"${state.relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
||||||
|
|
||||||
|
// 저장 후 닫기
|
||||||
|
if (onClose) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 저장 실패:", error);
|
||||||
|
setState((prev) => ({ ...prev, isLoading: false }));
|
||||||
|
toast.error(error.message || "저장 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
}, [state, diagramId, onClose]),
|
||||||
|
|
||||||
// 테스트 실행
|
// 테스트 실행
|
||||||
testExecution: useCallback(async (): Promise<TestResult> => {
|
testExecution: useCallback(async (): Promise<TestResult> => {
|
||||||
|
|
@ -434,52 +618,6 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
}, []),
|
}, []),
|
||||||
};
|
};
|
||||||
|
|
||||||
// 💾 실제 저장 함수
|
|
||||||
const handleSaveWithName = useCallback(
|
|
||||||
async (relationshipName: string, description?: string) => {
|
|
||||||
setState((prev) => ({ ...prev, isLoading: true }));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 실제 저장 로직 구현
|
|
||||||
const saveData = {
|
|
||||||
relationshipName,
|
|
||||||
description,
|
|
||||||
connectionType: state.connectionType,
|
|
||||||
fromConnection: state.fromConnection,
|
|
||||||
toConnection: state.toConnection,
|
|
||||||
fromTable: state.fromTable,
|
|
||||||
toTable: state.toTable,
|
|
||||||
actionType: state.actionType,
|
|
||||||
controlConditions: state.controlConditions,
|
|
||||||
actionConditions: state.actionConditions,
|
|
||||||
fieldMappings: state.fieldMappings,
|
|
||||||
};
|
|
||||||
|
|
||||||
console.log("💾 저장 시작:", saveData);
|
|
||||||
|
|
||||||
// 백엔드 API 호출
|
|
||||||
const result = await saveDataflowRelationship(saveData);
|
|
||||||
|
|
||||||
console.log("✅ 저장 완료:", result);
|
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
toast.success(`"${relationshipName}" 관계가 성공적으로 저장되었습니다.`);
|
|
||||||
|
|
||||||
// 저장 후 상위 컴포넌트에 알림 (필요한 경우)
|
|
||||||
if (onClose) {
|
|
||||||
onClose();
|
|
||||||
}
|
|
||||||
} catch (error: any) {
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false }));
|
|
||||||
|
|
||||||
const errorMessage = error.message || "저장 중 오류가 발생했습니다.";
|
|
||||||
toast.error(errorMessage);
|
|
||||||
console.error("❌ 저장 오류:", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[state, onClose],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
<div className="overflow-hidden rounded-lg border bg-white shadow-sm">
|
||||||
{/* 상단 네비게이션 */}
|
{/* 상단 네비게이션 */}
|
||||||
|
|
@ -514,16 +652,6 @@ const DataConnectionDesigner: React.FC<DataConnectionDesignerProps> = ({
|
||||||
<RightPanel state={state} actions={actions} />
|
<RightPanel state={state} actions={actions} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 💾 저장 다이얼로그 */}
|
|
||||||
<SaveRelationshipDialog
|
|
||||||
open={showSaveDialog}
|
|
||||||
onOpenChange={setShowSaveDialog}
|
|
||||||
onSave={handleSaveWithName}
|
|
||||||
actionType={state.actionType}
|
|
||||||
fromTable={state.fromTable?.tableName}
|
|
||||||
toTable={state.toTable?.tableName}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -11,8 +11,6 @@ import { LeftPanelProps } from "../types/redesigned";
|
||||||
import ConnectionTypeSelector from "./ConnectionTypeSelector";
|
import ConnectionTypeSelector from "./ConnectionTypeSelector";
|
||||||
import MappingDetailList from "./MappingDetailList";
|
import MappingDetailList from "./MappingDetailList";
|
||||||
import ActionSummaryPanel from "./ActionSummaryPanel";
|
import ActionSummaryPanel from "./ActionSummaryPanel";
|
||||||
import AdvancedSettings from "./AdvancedSettings";
|
|
||||||
import ActionButtons from "./ActionButtons";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 📋 좌측 패널 (30% 너비)
|
* 📋 좌측 패널 (30% 너비)
|
||||||
|
|
@ -35,45 +33,53 @@ const LeftPanel: React.FC<LeftPanelProps> = ({ state, actions }) => {
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
{/* 매핑 상세 목록 */}
|
{/* 매핑 상세 목록 */}
|
||||||
{state.fieldMappings.length > 0 && (
|
{(() => {
|
||||||
<>
|
// 액션 그룹에서 모든 매핑 수집
|
||||||
<div>
|
const allMappings = state.actionGroups.flatMap((group) =>
|
||||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">매핑 상세 목록</h3>
|
group.actions.flatMap((action) => action.fieldMappings || []),
|
||||||
<MappingDetailList
|
);
|
||||||
mappings={state.fieldMappings}
|
|
||||||
selectedMapping={state.selectedMapping}
|
|
||||||
onSelectMapping={(mappingId) => {
|
|
||||||
// TODO: 선택된 매핑 상태 업데이트
|
|
||||||
}}
|
|
||||||
onUpdateMapping={actions.updateMapping}
|
|
||||||
onDeleteMapping={actions.deleteMapping}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator />
|
// 기존 fieldMappings와 병합 (중복 제거)
|
||||||
</>
|
const combinedMappings = [...state.fieldMappings, ...allMappings];
|
||||||
)}
|
const uniqueMappings = combinedMappings.filter(
|
||||||
|
(mapping, index, arr) => arr.findIndex((m) => m.id === mapping.id) === index,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("🔍 LeftPanel - 매핑 데이터 수집:", {
|
||||||
|
stateFieldMappings: state.fieldMappings,
|
||||||
|
actionGroupMappings: allMappings,
|
||||||
|
combinedMappings: uniqueMappings,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
uniqueMappings.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-muted-foreground mb-2 text-sm font-medium">매핑 상세 목록</h3>
|
||||||
|
<MappingDetailList
|
||||||
|
mappings={uniqueMappings}
|
||||||
|
selectedMapping={state.selectedMapping}
|
||||||
|
onSelectMapping={(mappingId) => {
|
||||||
|
// TODO: 선택된 매핑 상태 업데이트
|
||||||
|
}}
|
||||||
|
onUpdateMapping={actions.updateMapping}
|
||||||
|
onDeleteMapping={actions.deleteMapping}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* 액션 설정 요약 */}
|
{/* 액션 설정 요약 */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">액션 설정</h3>
|
<h3 className="text-muted-foreground mb-2 text-sm font-medium">액션 설정</h3>
|
||||||
<ActionSummaryPanel state={state} />
|
<ActionSummaryPanel state={state} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* 고급 설정 */}
|
|
||||||
<div>
|
|
||||||
<h3 className="text-muted-foreground mb-2 text-sm font-medium">고급 설정</h3>
|
|
||||||
<AdvancedSettings connectionType={state.connectionType} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
{/* 하단 액션 버튼들 - 고정 위치 */}
|
|
||||||
<div className="flex-shrink-0 border-t bg-white p-3 shadow-sm">
|
|
||||||
<ActionButtons state={state} actions={actions} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -28,80 +28,102 @@ const MappingDetailList: React.FC<MappingDetailListProps> = ({
|
||||||
<CardContent className="p-0">
|
<CardContent className="p-0">
|
||||||
<ScrollArea className="h-[300px]">
|
<ScrollArea className="h-[300px]">
|
||||||
<div className="space-y-3 p-4">
|
<div className="space-y-3 p-4">
|
||||||
{mappings.map((mapping, index) => (
|
{(() => {
|
||||||
<div
|
console.log("🔍 MappingDetailList - 전체 매핑 데이터:", mappings);
|
||||||
key={mapping.id}
|
|
||||||
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
const validMappings = mappings.filter((mapping) => {
|
||||||
selectedMapping === mapping.id ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
|
const isValid =
|
||||||
}`}
|
mapping.fromField && mapping.toField && mapping.fromField.columnName && mapping.toField.columnName;
|
||||||
onClick={() => onSelectMapping(mapping.id)}
|
console.log(`🔍 매핑 유효성 검사:`, {
|
||||||
>
|
mapping,
|
||||||
{/* 매핑 헤더 */}
|
isValid,
|
||||||
<div className="mb-2 flex items-start justify-between">
|
hasFromField: !!mapping.fromField,
|
||||||
<div className="min-w-0 flex-1">
|
hasToField: !!mapping.toField,
|
||||||
<h4 className="truncate text-sm font-medium">
|
fromColumnName: mapping.fromField?.columnName,
|
||||||
{index + 1}. {mapping.fromField.displayName || mapping.fromField.columnName} →{" "}
|
toColumnName: mapping.toField?.columnName,
|
||||||
{mapping.toField.displayName || mapping.toField.columnName}
|
});
|
||||||
</h4>
|
return isValid;
|
||||||
<div className="mt-1 flex items-center gap-2">
|
});
|
||||||
{mapping.isValid ? (
|
|
||||||
<Badge variant="outline" className="text-xs text-green-600">
|
if (validMappings.length === 0) {
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
return (
|
||||||
{mapping.fromField.webType} → {mapping.toField.webType}
|
<div className="text-muted-foreground flex h-[200px] items-center justify-center">
|
||||||
</Badge>
|
<div className="text-center">
|
||||||
) : (
|
<p className="text-sm">매핑된 필드가 없습니다</p>
|
||||||
<Badge variant="outline" className="text-xs text-orange-600">
|
<p className="text-xs">INSERT 액션이 있을 때 필드 매핑을 설정하세요</p>
|
||||||
<AlertTriangle className="mr-1 h-3 w-3" />
|
</div>
|
||||||
타입 불일치
|
</div>
|
||||||
</Badge>
|
);
|
||||||
)}
|
}
|
||||||
|
|
||||||
|
return validMappings.map((mapping, index) => (
|
||||||
|
<div
|
||||||
|
key={mapping.id}
|
||||||
|
className={`cursor-pointer rounded-lg border p-3 transition-colors ${
|
||||||
|
selectedMapping === mapping.id ? "border-primary bg-primary/5" : "border-border hover:bg-muted/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => onSelectMapping(mapping.id)}
|
||||||
|
>
|
||||||
|
{/* 매핑 헤더 */}
|
||||||
|
<div className="mb-2 flex items-start justify-between">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h4 className="truncate text-sm font-medium">
|
||||||
|
{index + 1}. {mapping.fromField?.displayName || mapping.fromField?.columnName || "Unknown"} →{" "}
|
||||||
|
{mapping.toField?.displayName || mapping.toField?.columnName || "Unknown"}
|
||||||
|
</h4>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
{mapping.isValid ? (
|
||||||
|
<Badge variant="outline" className="text-xs text-green-600">
|
||||||
|
<CheckCircle className="mr-1 h-3 w-3" />
|
||||||
|
{mapping.fromField?.webType || "Unknown"} → {mapping.toField?.webType || "Unknown"}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="text-xs text-orange-600">
|
||||||
|
<AlertTriangle className="mr-1 h-3 w-3" />
|
||||||
|
타입 불일치
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="ml-2 flex gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-6 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
// TODO: 매핑 편집 모달 열기
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-destructive hover:text-destructive h-6 w-6 p-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteMapping(mapping.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-2 flex gap-1">
|
{/* 변환 규칙 */}
|
||||||
<Button
|
{mapping.transformRule && (
|
||||||
variant="ghost"
|
<div className="text-muted-foreground text-xs">변환: {mapping.transformRule}</div>
|
||||||
size="sm"
|
)}
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
{/* 검증 메시지 */}
|
||||||
e.stopPropagation();
|
{mapping.validationMessage && (
|
||||||
// TODO: 매핑 편집 모달 열기
|
<div className="mt-1 text-xs text-orange-600">{mapping.validationMessage}</div>
|
||||||
}}
|
)}
|
||||||
>
|
|
||||||
<Edit className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-destructive hover:text-destructive h-6 w-6 p-0"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteMapping(mapping.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
));
|
||||||
{/* 변환 규칙 */}
|
})()}
|
||||||
{mapping.transformRule && (
|
|
||||||
<div className="text-muted-foreground text-xs">변환: {mapping.transformRule}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 검증 메시지 */}
|
|
||||||
{mapping.validationMessage && (
|
|
||||||
<div className="mt-1 text-xs text-orange-600">{mapping.validationMessage}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{mappings.length === 0 && (
|
|
||||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
|
||||||
<p>매핑된 필드가 없습니다.</p>
|
|
||||||
<p className="mt-1 text-xs">우측에서 필드를 연결해주세요.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,7 @@ interface ActionConditionBuilderProps {
|
||||||
toColumns: ColumnInfo[];
|
toColumns: ColumnInfo[];
|
||||||
conditions: ActionCondition[];
|
conditions: ActionCondition[];
|
||||||
fieldMappings: FieldValueMapping[];
|
fieldMappings: FieldValueMapping[];
|
||||||
|
columnMappings?: any[]; // 컬럼 매핑 정보 (이미 매핑된 필드들)
|
||||||
onConditionsChange: (conditions: ActionCondition[]) => void;
|
onConditionsChange: (conditions: ActionCondition[]) => void;
|
||||||
onFieldMappingsChange: (mappings: FieldValueMapping[]) => void;
|
onFieldMappingsChange: (mappings: FieldValueMapping[]) => void;
|
||||||
showFieldMappings?: boolean; // 필드 매핑 섹션 표시 여부
|
showFieldMappings?: boolean; // 필드 매핑 섹션 표시 여부
|
||||||
|
|
@ -53,12 +54,41 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
toColumns,
|
toColumns,
|
||||||
conditions,
|
conditions,
|
||||||
fieldMappings,
|
fieldMappings,
|
||||||
|
columnMappings = [],
|
||||||
onConditionsChange,
|
onConditionsChange,
|
||||||
onFieldMappingsChange,
|
onFieldMappingsChange,
|
||||||
showFieldMappings = true,
|
showFieldMappings = true,
|
||||||
}) => {
|
}) => {
|
||||||
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||||
|
|
||||||
|
// 컬럼 매핑인지 필드값 매핑인지 구분하는 함수
|
||||||
|
const isColumnMapping = (mapping: any): boolean => {
|
||||||
|
return mapping.fromField && mapping.toField && mapping.fromField.columnName && mapping.toField.columnName;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 이미 컬럼 매핑된 필드들을 가져오는 함수
|
||||||
|
const getMappedFieldNames = (): string[] => {
|
||||||
|
if (!columnMappings || columnMappings.length === 0) return [];
|
||||||
|
return columnMappings.filter((mapping) => isColumnMapping(mapping)).map((mapping) => mapping.toField.columnName);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 매핑되지 않은 필드들만 필터링하는 함수
|
||||||
|
const getUnmappedToColumns = (): ColumnInfo[] => {
|
||||||
|
const mappedFieldNames = getMappedFieldNames();
|
||||||
|
return toColumns.filter((column) => !mappedFieldNames.includes(column.columnName));
|
||||||
|
};
|
||||||
|
|
||||||
|
// 필드값 설정에서 사용 가능한 필드들 (이미 필드값 설정에서 사용된 필드 제외)
|
||||||
|
const getAvailableFieldsForMapping = (currentIndex?: number): ColumnInfo[] => {
|
||||||
|
const unmappedColumns = getUnmappedToColumns();
|
||||||
|
const usedFieldNames = fieldMappings
|
||||||
|
.filter((_, index) => index !== currentIndex) // 현재 편집 중인 항목 제외
|
||||||
|
.map((mapping) => mapping.targetField)
|
||||||
|
.filter((field) => field); // 빈 값 제외
|
||||||
|
|
||||||
|
return unmappedColumns.filter((column) => !usedFieldNames.includes(column.columnName));
|
||||||
|
};
|
||||||
|
|
||||||
const operators = [
|
const operators = [
|
||||||
{ value: "=", label: "같음 (=)" },
|
{ value: "=", label: "같음 (=)" },
|
||||||
{ value: "!=", label: "다름 (!=)" },
|
{ value: "!=", label: "다름 (!=)" },
|
||||||
|
|
@ -75,9 +105,25 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
// 코드 정보 로드
|
// 코드 정보 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCodes = async () => {
|
const loadCodes = async () => {
|
||||||
const codeFields = [...fromColumns, ...toColumns].filter(
|
const codeFields = [...fromColumns, ...toColumns].filter((col) => {
|
||||||
(col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
|
// 메인 DB(connectionId === 0 또는 undefined)인 경우: column_labels의 input_type이 'code'인 경우만
|
||||||
|
if (col.connectionId === 0 || col.connectionId === undefined) {
|
||||||
|
return col.inputType === "code";
|
||||||
|
}
|
||||||
|
// 외부 DB인 경우: 코드 타입 없음
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"🔍 ActionConditionBuilder - 모든 컬럼 정보:",
|
||||||
|
[...fromColumns, ...toColumns].map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
connectionId: col.connectionId,
|
||||||
|
inputType: col.inputType,
|
||||||
|
webType: col.webType,
|
||||||
|
})),
|
||||||
);
|
);
|
||||||
|
console.log("🔍 ActionConditionBuilder - 코드 타입 컬럼들:", codeFields);
|
||||||
|
|
||||||
for (const field of codeFields) {
|
for (const field of codeFields) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -100,6 +146,23 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
}
|
}
|
||||||
}, [fromColumns, toColumns]);
|
}, [fromColumns, toColumns]);
|
||||||
|
|
||||||
|
// 컬럼 매핑이 변경될 때 필드값 설정에서 이미 매핑된 필드들 제거
|
||||||
|
useEffect(() => {
|
||||||
|
const mappedFieldNames = getMappedFieldNames();
|
||||||
|
if (mappedFieldNames.length > 0) {
|
||||||
|
const updatedFieldMappings = fieldMappings.filter((mapping) => !mappedFieldNames.includes(mapping.targetField));
|
||||||
|
|
||||||
|
// 변경된 내용이 있으면 업데이트
|
||||||
|
if (updatedFieldMappings.length !== fieldMappings.length) {
|
||||||
|
console.log("🧹 매핑된 필드들을 필드값 설정에서 제거:", {
|
||||||
|
removed: fieldMappings.filter((mapping) => mappedFieldNames.includes(mapping.targetField)),
|
||||||
|
remaining: updatedFieldMappings,
|
||||||
|
});
|
||||||
|
onFieldMappingsChange(updatedFieldMappings);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [columnMappings]); // columnMappings 변경 시에만 실행
|
||||||
|
|
||||||
// 조건 추가
|
// 조건 추가
|
||||||
const addCondition = () => {
|
const addCondition = () => {
|
||||||
const newCondition: ActionCondition = {
|
const newCondition: ActionCondition = {
|
||||||
|
|
@ -129,6 +192,20 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
|
|
||||||
// 필드 매핑 추가
|
// 필드 매핑 추가
|
||||||
const addFieldMapping = () => {
|
const addFieldMapping = () => {
|
||||||
|
// 임시로 검증을 완화 - 매핑되지 않은 필드가 있으면 추가 허용
|
||||||
|
const unmappedColumns = getUnmappedToColumns();
|
||||||
|
console.log("🔍 필드 추가 시도:", {
|
||||||
|
unmappedColumns,
|
||||||
|
unmappedColumnsCount: unmappedColumns.length,
|
||||||
|
fieldMappings,
|
||||||
|
columnMappings,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (unmappedColumns.length === 0) {
|
||||||
|
console.warn("매핑되지 않은 필드가 없습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const newMapping: FieldValueMapping = {
|
const newMapping: FieldValueMapping = {
|
||||||
id: Date.now().toString(),
|
id: Date.now().toString(),
|
||||||
targetField: "",
|
targetField: "",
|
||||||
|
|
@ -136,6 +213,7 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
value: "",
|
value: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log("✅ 새 필드 매핑 추가:", newMapping);
|
||||||
onFieldMappingsChange([...fieldMappings, newMapping]);
|
onFieldMappingsChange([...fieldMappings, newMapping]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -153,7 +231,11 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
|
|
||||||
// 필드의 값 입력 컴포넌트 렌더링
|
// 필드의 값 입력 컴포넌트 렌더링
|
||||||
const renderValueInput = (mapping: FieldValueMapping, index: number, targetColumn?: ColumnInfo) => {
|
const renderValueInput = (mapping: FieldValueMapping, index: number, targetColumn?: ColumnInfo) => {
|
||||||
if (mapping.valueType === "code" && targetColumn?.webType === "code") {
|
if (
|
||||||
|
mapping.valueType === "code" &&
|
||||||
|
(targetColumn?.connectionId === 0 || targetColumn?.connectionId === undefined) &&
|
||||||
|
targetColumn?.inputType === "code"
|
||||||
|
) {
|
||||||
const codes = availableCodes[targetColumn.columnName] || [];
|
const codes = availableCodes[targetColumn.columnName] || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -164,12 +246,7 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{codes.map((code) => (
|
{codes.map((code) => (
|
||||||
<SelectItem key={code.code} value={code.code}>
|
<SelectItem key={code.code} value={code.code}>
|
||||||
<div className="flex items-center gap-2">
|
{code.name}
|
||||||
<Badge variant="outline" className="text-xs">
|
|
||||||
{code.code}
|
|
||||||
</Badge>
|
|
||||||
<span>{code.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|
@ -227,6 +304,129 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 날짜 타입에 대한 특별 처리
|
||||||
|
if (
|
||||||
|
targetColumn?.webType === "date" ||
|
||||||
|
targetColumn?.webType === "datetime" ||
|
||||||
|
targetColumn?.dataType?.toLowerCase().includes("date")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 날짜 타입 선택 */}
|
||||||
|
<Select
|
||||||
|
value={mapping.value?.startsWith("#") ? mapping.value : "#custom"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "#custom") {
|
||||||
|
updateFieldMapping(index, { value: "" });
|
||||||
|
} else {
|
||||||
|
updateFieldMapping(index, { value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="날짜 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="#NOW">🕐 현재 시간 (NOW)</SelectItem>
|
||||||
|
<SelectItem value="#TODAY">📅 오늘 날짜 (TODAY)</SelectItem>
|
||||||
|
<SelectItem value="#YESTERDAY">📅 어제 날짜</SelectItem>
|
||||||
|
<SelectItem value="#TOMORROW">📅 내일 날짜</SelectItem>
|
||||||
|
<SelectItem value="#WEEK_START">📅 이번 주 시작일</SelectItem>
|
||||||
|
<SelectItem value="#MONTH_START">📅 이번 달 시작일</SelectItem>
|
||||||
|
<SelectItem value="#YEAR_START">📅 올해 시작일</SelectItem>
|
||||||
|
<SelectItem value="#custom">✏️ 직접 입력</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 직접 입력이 선택된 경우 */}
|
||||||
|
{(!mapping.value?.startsWith("#") || mapping.value === "#custom") && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
type={targetColumn?.webType === "datetime" ? "datetime-local" : "date"}
|
||||||
|
placeholder="날짜 입력"
|
||||||
|
value={mapping.value?.startsWith("#") ? "" : mapping.value}
|
||||||
|
onChange={(e) => updateFieldMapping(index, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
상대적 날짜: +7D (7일 후), -30D (30일 전), +1M (1개월 후), +1Y (1년 후)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 날짜 타입에 대한 설명 */}
|
||||||
|
{mapping.value?.startsWith("#") && mapping.value !== "#custom" && (
|
||||||
|
<div className="text-muted-foreground rounded bg-blue-50 p-2 text-xs">
|
||||||
|
{mapping.value === "#NOW" && "⏰ 현재 날짜와 시간이 저장됩니다"}
|
||||||
|
{mapping.value === "#TODAY" && "📅 현재 날짜 (00:00:00)가 저장됩니다"}
|
||||||
|
{mapping.value === "#YESTERDAY" && "📅 어제 날짜가 저장됩니다"}
|
||||||
|
{mapping.value === "#TOMORROW" && "📅 내일 날짜가 저장됩니다"}
|
||||||
|
{mapping.value === "#WEEK_START" && "📅 이번 주 월요일이 저장됩니다"}
|
||||||
|
{mapping.value === "#MONTH_START" && "📅 이번 달 1일이 저장됩니다"}
|
||||||
|
{mapping.value === "#YEAR_START" && "📅 올해 1월 1일이 저장됩니다"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 숫자 타입에 대한 특별 처리
|
||||||
|
if (
|
||||||
|
targetColumn?.webType === "number" ||
|
||||||
|
targetColumn?.webType === "decimal" ||
|
||||||
|
targetColumn?.dataType?.toLowerCase().includes("int") ||
|
||||||
|
targetColumn?.dataType?.toLowerCase().includes("decimal") ||
|
||||||
|
targetColumn?.dataType?.toLowerCase().includes("numeric")
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 숫자 타입 선택 */}
|
||||||
|
<Select
|
||||||
|
value={mapping.value?.startsWith("#") ? mapping.value : "#custom"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value === "#custom") {
|
||||||
|
updateFieldMapping(index, { value: "" });
|
||||||
|
} else {
|
||||||
|
updateFieldMapping(index, { value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="숫자 타입 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="#AUTO_INCREMENT">🔢 자동 증가 (AUTO_INCREMENT)</SelectItem>
|
||||||
|
<SelectItem value="#RANDOM_INT">🎲 랜덤 정수 (1-1000)</SelectItem>
|
||||||
|
<SelectItem value="#ZERO">0️⃣ 0</SelectItem>
|
||||||
|
<SelectItem value="#ONE">1️⃣ 1</SelectItem>
|
||||||
|
<SelectItem value="#SEQUENCE">📈 시퀀스값</SelectItem>
|
||||||
|
<SelectItem value="#custom">✏️ 직접 입력</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 직접 입력이 선택된 경우 */}
|
||||||
|
{(!mapping.value?.startsWith("#") || mapping.value === "#custom") && (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
placeholder="숫자 입력"
|
||||||
|
value={mapping.value?.startsWith("#") ? "" : mapping.value}
|
||||||
|
onChange={(e) => updateFieldMapping(index, { value: e.target.value })}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 선택된 숫자 타입에 대한 설명 */}
|
||||||
|
{mapping.value?.startsWith("#") && mapping.value !== "#custom" && (
|
||||||
|
<div className="text-muted-foreground rounded bg-green-50 p-2 text-xs">
|
||||||
|
{mapping.value === "#AUTO_INCREMENT" && "🔢 데이터베이스에서 자동으로 증가하는 값이 할당됩니다"}
|
||||||
|
{mapping.value === "#RANDOM_INT" && "🎲 1부터 1000 사이의 랜덤한 정수가 생성됩니다"}
|
||||||
|
{mapping.value === "#ZERO" && "0️⃣ 0 값이 저장됩니다"}
|
||||||
|
{mapping.value === "#ONE" && "1️⃣ 1 값이 저장됩니다"}
|
||||||
|
{mapping.value === "#SEQUENCE" && "📈 시퀀스에서 다음 값을 가져옵니다"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Input
|
<Input
|
||||||
placeholder="값 입력"
|
placeholder="값 입력"
|
||||||
|
|
@ -467,8 +667,16 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
<CardTitle className="flex items-center justify-between text-base">
|
<CardTitle className="flex items-center justify-between text-base">
|
||||||
<span>필드 값 설정 (SET)</span>
|
<div>
|
||||||
<Button variant="outline" size="sm" onClick={addFieldMapping}>
|
<span>필드 값 설정 (SET)</span>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">매핑되지 않은 필드의 기본값 설정</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addFieldMapping}
|
||||||
|
disabled={getUnmappedToColumns().length === 0}
|
||||||
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
필드 추가
|
필드 추가
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -476,65 +684,98 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{fieldMappings.length === 0 ? (
|
{/* 매핑되지 않은 필드가 없는 경우 */}
|
||||||
|
{getUnmappedToColumns().length === 0 ? (
|
||||||
|
<div className="rounded-lg border bg-green-50 p-4 text-center">
|
||||||
|
<div className="mb-2 text-green-600">✅ 모든 필드가 매핑되었습니다</div>
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
컬럼 매핑으로 모든 TO 테이블 필드가 처리되고 있어 별도의 기본값 설정이 필요하지 않습니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : fieldMappings.length === 0 ? (
|
||||||
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
<div className="rounded-lg border-2 border-dashed p-6 text-center">
|
||||||
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
<Settings className="text-muted-foreground mx-auto mb-2 h-6 w-6" />
|
||||||
<p className="text-muted-foreground text-sm">조건을 만족할 때 설정할 필드 값을 지정하세요</p>
|
<p className="text-muted-foreground text-sm">매핑되지 않은 필드의 기본값을 설정하세요</p>
|
||||||
|
<p className="text-muted-foreground mt-1 text-xs">
|
||||||
|
컬럼 매핑으로 처리되지 않은 필드들만 여기서 설정됩니다
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
현재 {getUnmappedToColumns().length}개 필드가 매핑되지 않음
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
fieldMappings.map((mapping, index) => {
|
(() => {
|
||||||
const targetColumn = toColumns.find((col) => col.columnName === mapping.targetField);
|
console.log("🎨 필드값 설정 렌더링:", {
|
||||||
|
fieldMappings,
|
||||||
|
fieldMappingsCount: fieldMappings.length,
|
||||||
|
});
|
||||||
|
return fieldMappings.map((mapping, index) => {
|
||||||
|
const targetColumn = toColumns.find((col) => col.columnName === mapping.targetField);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={mapping.id} className="flex items-center gap-3 rounded-lg border p-3">
|
<div key={mapping.id} className="flex items-center gap-3 rounded-lg border p-3">
|
||||||
{/* 대상 필드 */}
|
{/* 대상 필드 */}
|
||||||
<Select
|
<Select
|
||||||
value={mapping.targetField}
|
value={mapping.targetField}
|
||||||
onValueChange={(value) => updateFieldMapping(index, { targetField: value })}
|
onValueChange={(value) =>
|
||||||
>
|
updateFieldMapping(index, {
|
||||||
<SelectTrigger className="w-40">
|
targetField: value,
|
||||||
<SelectValue placeholder="대상 필드" />
|
value: "", // 필드 변경 시 값 초기화
|
||||||
</SelectTrigger>
|
sourceField: "", // 소스 필드도 초기화
|
||||||
<SelectContent>
|
})
|
||||||
{toColumns.map((column) => (
|
}
|
||||||
<SelectItem key={column.columnName} value={column.columnName}>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<SelectTrigger className="w-40">
|
||||||
<span>{column.displayName || column.columnName}</span>
|
<SelectValue placeholder="대상 필드" />
|
||||||
<Badge variant="outline" className="text-xs">
|
</SelectTrigger>
|
||||||
{column.webType || column.dataType}
|
<SelectContent>
|
||||||
</Badge>
|
{getAvailableFieldsForMapping(index).map((column) => (
|
||||||
</div>
|
<SelectItem key={column.columnName} value={column.columnName}>
|
||||||
</SelectItem>
|
<div className="flex items-center gap-2">
|
||||||
))}
|
<span>{column.displayName || column.columnName}</span>
|
||||||
</SelectContent>
|
<Badge variant="outline" className="text-xs">
|
||||||
</Select>
|
{column.webType || column.dataType}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* 값 타입 */}
|
{/* 값 타입 */}
|
||||||
<Select
|
<Select
|
||||||
value={mapping.valueType}
|
value={mapping.valueType}
|
||||||
onValueChange={(value) => updateFieldMapping(index, { valueType: value as any })}
|
onValueChange={(value) =>
|
||||||
>
|
updateFieldMapping(index, {
|
||||||
<SelectTrigger className="w-32">
|
valueType: value as any,
|
||||||
<SelectValue />
|
value: "", // 값 타입 변경 시 값 초기화
|
||||||
</SelectTrigger>
|
sourceField: "", // 소스 필드도 초기화
|
||||||
<SelectContent>
|
})
|
||||||
<SelectItem value="static">고정값</SelectItem>
|
}
|
||||||
<SelectItem value="source_field">소스필드</SelectItem>
|
>
|
||||||
{targetColumn?.webType === "code" && <SelectItem value="code">코드선택</SelectItem>}
|
<SelectTrigger className="w-32">
|
||||||
<SelectItem value="calculated">계산식</SelectItem>
|
<SelectValue />
|
||||||
</SelectContent>
|
</SelectTrigger>
|
||||||
</Select>
|
<SelectContent>
|
||||||
|
<SelectItem value="static">고정값</SelectItem>
|
||||||
|
<SelectItem value="source_field">소스필드</SelectItem>
|
||||||
|
{(targetColumn?.connectionId === 0 || targetColumn?.connectionId === undefined) &&
|
||||||
|
targetColumn?.inputType === "code" && <SelectItem value="code">코드선택</SelectItem>}
|
||||||
|
<SelectItem value="calculated">계산식</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
{/* 값 입력 */}
|
{/* 값 입력 */}
|
||||||
<div className="flex-1">{renderValueInput(mapping, index, targetColumn)}</div>
|
<div className="flex-1">{renderValueInput(mapping, index, targetColumn)}</div>
|
||||||
|
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<Button variant="ghost" size="sm" onClick={() => deleteFieldMapping(index)}>
|
<Button variant="ghost" size="sm" onClick={() => deleteFieldMapping(index)}>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})
|
});
|
||||||
|
})()
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect, useCallback } from "react";
|
||||||
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { ArrowRight, Database, Globe, Loader2 } from "lucide-react";
|
import { ArrowRight, Database, Globe, Loader2, AlertTriangle, CheckCircle } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// API import
|
// API import
|
||||||
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
|
||||||
|
import { checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
|
||||||
|
|
||||||
// 타입 import
|
// 타입 import
|
||||||
import { Connection } from "@/lib/types/multiConnection";
|
import { Connection } from "@/lib/types/multiConnection";
|
||||||
|
|
@ -18,7 +22,12 @@ interface ConnectionStepProps {
|
||||||
connectionType: "data_save" | "external_call";
|
connectionType: "data_save" | "external_call";
|
||||||
fromConnection?: Connection;
|
fromConnection?: Connection;
|
||||||
toConnection?: Connection;
|
toConnection?: Connection;
|
||||||
|
relationshipName?: string;
|
||||||
|
description?: string;
|
||||||
|
diagramId?: number; // 🔧 수정 모드 감지용
|
||||||
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
|
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||||
|
onSetRelationshipName: (name: string) => void;
|
||||||
|
onSetDescription: (description: string) => void;
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,9 +38,21 @@ interface ConnectionStepProps {
|
||||||
* - 지연시간 정보
|
* - 지연시간 정보
|
||||||
*/
|
*/
|
||||||
const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
||||||
({ connectionType, fromConnection, toConnection, onSelectConnection, onNext }) => {
|
({
|
||||||
|
connectionType,
|
||||||
|
fromConnection,
|
||||||
|
toConnection,
|
||||||
|
relationshipName,
|
||||||
|
description,
|
||||||
|
diagramId,
|
||||||
|
onSelectConnection,
|
||||||
|
onSetRelationshipName,
|
||||||
|
onSetDescription,
|
||||||
|
onNext,
|
||||||
|
}) => {
|
||||||
const [connections, setConnections] = useState<Connection[]>([]);
|
const [connections, setConnections] = useState<Connection[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [nameCheckStatus, setNameCheckStatus] = useState<"idle" | "checking" | "valid" | "duplicate">("idle");
|
||||||
|
|
||||||
// API 응답을 Connection 타입으로 변환
|
// API 응답을 Connection 타입으로 변환
|
||||||
const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({
|
const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({
|
||||||
|
|
@ -48,6 +69,45 @@ const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
||||||
updatedDate: connectionInfo.updated_date,
|
updatedDate: connectionInfo.updated_date,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 🔍 관계명 중복 체크 (디바운스 적용)
|
||||||
|
const checkNameDuplicate = useCallback(
|
||||||
|
async (name: string) => {
|
||||||
|
if (!name.trim()) {
|
||||||
|
setNameCheckStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNameCheckStatus("checking");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await checkRelationshipNameDuplicate(name, diagramId);
|
||||||
|
setNameCheckStatus(result.isDuplicate ? "duplicate" : "valid");
|
||||||
|
|
||||||
|
if (result.isDuplicate) {
|
||||||
|
toast.warning(`"${name}" 이름이 이미 사용 중입니다. (${result.duplicateCount}개 발견)`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("중복 체크 실패:", error);
|
||||||
|
setNameCheckStatus("idle");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[diagramId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 관계명 변경 시 중복 체크 (디바운스)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!relationshipName) {
|
||||||
|
setNameCheckStatus("idle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
checkNameDuplicate(relationshipName);
|
||||||
|
}, 500); // 500ms 디바운스
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [relationshipName, checkNameDuplicate]);
|
||||||
|
|
||||||
// 연결 목록 로드
|
// 연결 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadConnections = async () => {
|
const loadConnections = async () => {
|
||||||
|
|
@ -150,6 +210,50 @@ const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
<CardContent className="max-h-[calc(100vh-400px)] min-h-[400px] space-y-6 overflow-y-auto">
|
||||||
|
{/* 관계 정보 입력 */}
|
||||||
|
<div className="bg-muted/30 space-y-4 rounded-lg border p-4">
|
||||||
|
<h3 className="font-medium">관계 정보</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="relationshipName">관계 이름 *</Label>
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="relationshipName"
|
||||||
|
placeholder="예: 사용자 데이터 동기화"
|
||||||
|
value={relationshipName || ""}
|
||||||
|
onChange={(e) => onSetRelationshipName(e.target.value)}
|
||||||
|
className={`pr-10 ${
|
||||||
|
nameCheckStatus === "duplicate"
|
||||||
|
? "border-red-500 focus:border-red-500"
|
||||||
|
: nameCheckStatus === "valid"
|
||||||
|
? "border-green-500 focus:border-green-500"
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div className="absolute top-1/2 right-3 -translate-y-1/2">
|
||||||
|
{nameCheckStatus === "checking" && (
|
||||||
|
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{nameCheckStatus === "valid" && <CheckCircle className="h-4 w-4 text-green-500" />}
|
||||||
|
{nameCheckStatus === "duplicate" && <AlertTriangle className="h-4 w-4 text-red-500" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{nameCheckStatus === "duplicate" && <p className="text-sm text-red-600">이미 사용 중인 이름입니다.</p>}
|
||||||
|
{nameCheckStatus === "valid" && <p className="text-sm text-green-600">사용 가능한 이름입니다.</p>}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">설명</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
placeholder="이 관계에 대한 설명을 입력하세요"
|
||||||
|
value={description || ""}
|
||||||
|
onChange={(e) => onSetDescription(e.target.value)}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8">
|
<div className="flex items-center justify-center py-8">
|
||||||
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
<Loader2 className="mr-2 h-6 w-6 animate-spin" />
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import { ArrowLeft, CheckCircle, AlertCircle, Settings, Plus, Trash2 } from "luc
|
||||||
// 타입 import
|
// 타입 import
|
||||||
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
|
||||||
import { ColumnInfo } from "@/lib/types/multiConnection";
|
import { ColumnInfo } from "@/lib/types/multiConnection";
|
||||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
// 컬럼 로드는 중앙에서 관리
|
||||||
import { getCodesForColumn, CodeItem } from "@/lib/api/codeManagement";
|
import { getCodesForColumn, CodeItem } from "@/lib/api/codeManagement";
|
||||||
|
|
||||||
// 컴포넌트 import
|
// 컴포넌트 import
|
||||||
|
|
@ -29,61 +29,66 @@ interface ControlConditionStepProps {
|
||||||
* - INSERT/UPDATE/DELETE 트리거 조건
|
* - INSERT/UPDATE/DELETE 트리거 조건
|
||||||
*/
|
*/
|
||||||
const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, actions, onBack, onNext }) => {
|
const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, actions, onBack, onNext }) => {
|
||||||
const { controlConditions, fromTable, toTable, fromConnection, toConnection } = state;
|
const {
|
||||||
|
controlConditions,
|
||||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
fromTable,
|
||||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
toTable,
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
fromConnection,
|
||||||
|
toConnection,
|
||||||
|
fromColumns = [],
|
||||||
|
toColumns = [],
|
||||||
|
isLoading = false,
|
||||||
|
} = state;
|
||||||
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
|
||||||
|
|
||||||
// 컬럼 정보 로드
|
// 컴포넌트 마운트 시 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColumns = async () => {
|
if (
|
||||||
console.log("🔄 ControlConditionStep 컬럼 로드 시작");
|
fromConnection &&
|
||||||
console.log("fromConnection:", fromConnection);
|
toConnection &&
|
||||||
console.log("toConnection:", toConnection);
|
fromTable &&
|
||||||
console.log("fromTable:", fromTable);
|
toTable &&
|
||||||
console.log("toTable:", toTable);
|
fromColumns.length === 0 &&
|
||||||
|
toColumns.length === 0 &&
|
||||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
!isLoading
|
||||||
console.log("❌ 필수 정보 누락으로 컬럼 로드 중단");
|
) {
|
||||||
return;
|
console.log("🔄 ControlConditionStep: 컬럼 로드 시작");
|
||||||
}
|
actions.loadColumns();
|
||||||
|
}
|
||||||
setIsLoading(true);
|
}, [
|
||||||
try {
|
fromConnection?.id,
|
||||||
console.log(
|
toConnection?.id,
|
||||||
`🚀 컬럼 조회 시작: FROM=${fromConnection.id}/${fromTable.tableName}, TO=${toConnection.id}/${toTable.tableName}`,
|
fromTable?.tableName,
|
||||||
);
|
toTable?.tableName,
|
||||||
|
fromColumns.length,
|
||||||
const [fromCols, toCols] = await Promise.all([
|
toColumns.length,
|
||||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
isLoading,
|
||||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
]);
|
||||||
]);
|
|
||||||
|
|
||||||
console.log(`✅ 컬럼 조회 완료: FROM=${fromCols.length}개, TO=${toCols.length}개`);
|
|
||||||
setFromColumns(fromCols);
|
|
||||||
setToColumns(toCols);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadColumns();
|
|
||||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
|
||||||
|
|
||||||
// 코드 타입 컬럼의 코드 로드
|
// 코드 타입 컬럼의 코드 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadCodes = async () => {
|
const loadCodes = async () => {
|
||||||
const allColumns = [...fromColumns, ...toColumns];
|
const allColumns = [...fromColumns, ...toColumns];
|
||||||
const codeColumns = allColumns.filter(
|
const codeColumns = allColumns.filter((col) => {
|
||||||
(col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
|
// 메인 DB(connectionId === 0 또는 undefined)인 경우: column_labels의 input_type이 'code'인 경우만
|
||||||
);
|
if (col.connectionId === 0 || col.connectionId === undefined) {
|
||||||
|
return col.inputType === "code";
|
||||||
|
}
|
||||||
|
// 외부 DB인 경우: 코드 타입 없음
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
if (codeColumns.length === 0) return;
|
if (codeColumns.length === 0) return;
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
"🔍 모든 컬럼 정보:",
|
||||||
|
allColumns.map((col) => ({
|
||||||
|
columnName: col.columnName,
|
||||||
|
connectionId: col.connectionId,
|
||||||
|
inputType: col.inputType,
|
||||||
|
webType: col.webType,
|
||||||
|
})),
|
||||||
|
);
|
||||||
console.log("🔍 코드 타입 컬럼들:", codeColumns);
|
console.log("🔍 코드 타입 컬럼들:", codeColumns);
|
||||||
|
|
||||||
const codePromises = codeColumns.map(async (col) => {
|
const codePromises = codeColumns.map(async (col) => {
|
||||||
|
|
@ -213,6 +218,7 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
|
||||||
actions.updateControlCondition(index, {
|
actions.updateControlCondition(index, {
|
||||||
...condition,
|
...condition,
|
||||||
field: value,
|
field: value,
|
||||||
|
value: "", // 필드 변경 시 값 초기화
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|
@ -272,15 +278,18 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
|
||||||
);
|
);
|
||||||
const isCodeField =
|
const isCodeField =
|
||||||
selectedField &&
|
selectedField &&
|
||||||
(selectedField.webType === "code" ||
|
(selectedField.connectionId === 0 || selectedField.connectionId === undefined) && // 임시: undefined도 메인 DB로 간주
|
||||||
selectedField.dataType?.toLowerCase().includes("code"));
|
selectedField.inputType === "code";
|
||||||
const fieldCodes = condition.field ? availableCodes[condition.field] : [];
|
const fieldCodes = condition.field ? availableCodes[condition.field] : [];
|
||||||
|
|
||||||
// 디버깅 정보 출력
|
// 디버깅 정보 출력
|
||||||
console.log("🔍 값 입력 필드 디버깅:", {
|
console.log("🔍 값 입력 필드 디버깅:", {
|
||||||
conditionField: condition.field,
|
conditionField: condition.field,
|
||||||
selectedField: selectedField,
|
selectedField: selectedField,
|
||||||
|
selectedFieldKeys: selectedField ? Object.keys(selectedField) : [],
|
||||||
webType: selectedField?.webType,
|
webType: selectedField?.webType,
|
||||||
|
inputType: selectedField?.inputType,
|
||||||
|
connectionId: selectedField?.connectionId,
|
||||||
dataType: selectedField?.dataType,
|
dataType: selectedField?.dataType,
|
||||||
isCodeField: isCodeField,
|
isCodeField: isCodeField,
|
||||||
fieldCodes: fieldCodes,
|
fieldCodes: fieldCodes,
|
||||||
|
|
@ -323,7 +332,7 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
|
||||||
key={`code_${condition.field}_${code.code || codeIndex}_${codeIndex}`}
|
key={`code_${condition.field}_${code.code || codeIndex}_${codeIndex}`}
|
||||||
value={code.code || `unknown_${codeIndex}`}
|
value={code.code || `unknown_${codeIndex}`}
|
||||||
>
|
>
|
||||||
{code.name || code.description || `코드 ${codeIndex + 1}`}
|
{code.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
@ -373,7 +382,7 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 조건 없음 안내 */}
|
{/* 조건 없음 안내 */}
|
||||||
{!isLoading && controlConditions.length === 0 && (
|
{!isLoading && fromColumns.length > 0 && toColumns.length > 0 && controlConditions.length === 0 && (
|
||||||
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
<div className="rounded-lg border-2 border-dashed p-8 text-center">
|
||||||
<AlertCircle className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
|
<AlertCircle className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
|
||||||
<h3 className="mb-2 text-lg font-medium">제어 실행 조건 없음</h3>
|
<h3 className="mb-2 text-lg font-medium">제어 실행 조건 없음</h3>
|
||||||
|
|
@ -395,7 +404,7 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 컬럼 정보 로드 실패 시 안내 */}
|
{/* 컬럼 정보 로드 실패 시 안내 */}
|
||||||
{!isLoading && fromColumns.length === 0 && toColumns.length === 0 && controlConditions.length === 0 && (
|
{fromColumns.length === 0 && toColumns.length === 0 && controlConditions.length === 0 && (
|
||||||
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
<div className="rounded-lg border border-yellow-200 bg-yellow-50 p-4">
|
||||||
<h4 className="mb-2 text-sm font-medium text-yellow-800">컬럼 정보를 불러올 수 없습니다</h4>
|
<h4 className="mb-2 text-sm font-medium text-yellow-800">컬럼 정보를 불러올 수 없습니다</h4>
|
||||||
<div className="space-y-2 text-sm text-yellow-700">
|
<div className="space-y-2 text-sm text-yellow-700">
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ import {
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
// API import
|
// API import (컬럼 로드는 중앙에서 관리)
|
||||||
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
|
|
||||||
|
|
||||||
// 타입 import
|
// 타입 import
|
||||||
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
|
||||||
|
|
@ -40,6 +39,9 @@ interface MultiActionConfigStepProps {
|
||||||
toTable?: TableInfo;
|
toTable?: TableInfo;
|
||||||
fromConnection?: Connection;
|
fromConnection?: Connection;
|
||||||
toConnection?: Connection;
|
toConnection?: Connection;
|
||||||
|
// 컬럼 정보 (중앙에서 관리) 🔧 추가
|
||||||
|
fromColumns?: ColumnInfo[];
|
||||||
|
toColumns?: ColumnInfo[];
|
||||||
// 제어 조건 관련
|
// 제어 조건 관련
|
||||||
controlConditions: any[];
|
controlConditions: any[];
|
||||||
onUpdateControlCondition: (index: number, condition: any) => void;
|
onUpdateControlCondition: (index: number, condition: any) => void;
|
||||||
|
|
@ -47,12 +49,14 @@ interface MultiActionConfigStepProps {
|
||||||
onAddControlCondition: () => void;
|
onAddControlCondition: () => void;
|
||||||
// 액션 그룹 관련
|
// 액션 그룹 관련
|
||||||
actionGroups: ActionGroup[];
|
actionGroups: ActionGroup[];
|
||||||
|
groupsLogicalOperator?: "AND" | "OR";
|
||||||
onUpdateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
onUpdateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
|
||||||
onDeleteActionGroup: (groupId: string) => void;
|
onDeleteActionGroup: (groupId: string) => void;
|
||||||
onAddActionGroup: () => void;
|
onAddActionGroup: () => void;
|
||||||
onAddActionToGroup: (groupId: string) => void;
|
onAddActionToGroup: (groupId: string) => void;
|
||||||
onUpdateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
onUpdateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
|
||||||
onDeleteActionFromGroup: (groupId: string, actionId: string) => void;
|
onDeleteActionFromGroup: (groupId: string, actionId: string) => void;
|
||||||
|
onSetGroupsLogicalOperator?: (operator: "AND" | "OR") => void;
|
||||||
// 필드 매핑 관련
|
// 필드 매핑 관련
|
||||||
fieldMappings: FieldMapping[];
|
fieldMappings: FieldMapping[];
|
||||||
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||||
|
|
@ -60,6 +64,8 @@ interface MultiActionConfigStepProps {
|
||||||
// 네비게이션
|
// 네비게이션
|
||||||
onNext: () => void;
|
onNext: () => void;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
|
// 컬럼 로드 액션
|
||||||
|
onLoadColumns?: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -75,55 +81,41 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
||||||
toTable,
|
toTable,
|
||||||
fromConnection,
|
fromConnection,
|
||||||
toConnection,
|
toConnection,
|
||||||
|
fromColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
|
||||||
|
toColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
|
||||||
controlConditions,
|
controlConditions,
|
||||||
onUpdateControlCondition,
|
onUpdateControlCondition,
|
||||||
onDeleteControlCondition,
|
onDeleteControlCondition,
|
||||||
onAddControlCondition,
|
onAddControlCondition,
|
||||||
actionGroups,
|
actionGroups,
|
||||||
|
groupsLogicalOperator = "AND",
|
||||||
onUpdateActionGroup,
|
onUpdateActionGroup,
|
||||||
onDeleteActionGroup,
|
onDeleteActionGroup,
|
||||||
onAddActionGroup,
|
onAddActionGroup,
|
||||||
onAddActionToGroup,
|
onAddActionToGroup,
|
||||||
onUpdateActionInGroup,
|
onUpdateActionInGroup,
|
||||||
onDeleteActionFromGroup,
|
onDeleteActionFromGroup,
|
||||||
|
onSetGroupsLogicalOperator,
|
||||||
fieldMappings,
|
fieldMappings,
|
||||||
onCreateMapping,
|
onCreateMapping,
|
||||||
onDeleteMapping,
|
onDeleteMapping,
|
||||||
onNext,
|
onNext,
|
||||||
onBack,
|
onBack,
|
||||||
|
onLoadColumns,
|
||||||
}) => {
|
}) => {
|
||||||
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(["group_1"])); // 첫 번째 그룹은 기본 열림
|
||||||
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
|
const [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
|
||||||
|
|
||||||
// 컬럼 정보 로드
|
// 컬럼 로딩 상태 확인
|
||||||
|
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
|
||||||
|
|
||||||
|
// 컴포넌트 마운트 시 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadColumns = async () => {
|
if (!isColumnsLoaded && fromConnection && toConnection && fromTable && toTable && onLoadColumns) {
|
||||||
if (!fromConnection || !toConnection || !fromTable || !toTable) {
|
console.log("🔄 MultiActionConfigStep: 컬럼 로드 시작");
|
||||||
return;
|
onLoadColumns();
|
||||||
}
|
}
|
||||||
|
}, [isColumnsLoaded, fromConnection?.id, toConnection?.id, fromTable?.tableName, toTable?.tableName]);
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const [fromCols, toCols] = await Promise.all([
|
|
||||||
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
|
|
||||||
getColumnsFromConnection(toConnection.id, toTable.tableName),
|
|
||||||
]);
|
|
||||||
|
|
||||||
setFromColumns(Array.isArray(fromCols) ? fromCols : []);
|
|
||||||
setToColumns(Array.isArray(toCols) ? toCols : []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("❌ 컬럼 정보 로드 실패:", error);
|
|
||||||
toast.error("필드 정보를 불러오는데 실패했습니다.");
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadColumns();
|
|
||||||
}, [fromConnection, toConnection, fromTable, toTable]);
|
|
||||||
|
|
||||||
// 그룹 확장/축소 토글
|
// 그룹 확장/축소 토글
|
||||||
const toggleGroupExpansion = (groupId: string) => {
|
const toggleGroupExpansion = (groupId: string) => {
|
||||||
|
|
@ -171,13 +163,10 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
||||||
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
|
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 탭 정보
|
// 탭 정보 (컬럼 매핑 탭 제거)
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
|
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
|
||||||
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
|
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
|
||||||
...(hasInsertActions
|
|
||||||
? [{ id: "mapping" as const, label: "컬럼 매핑", icon: "🔗", description: "INSERT 액션 필드 매핑" }]
|
|
||||||
: []),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -280,265 +269,405 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 액션 그룹 목록 */}
|
{/* 그룹 간 논리 연산자 선택 */}
|
||||||
<div className="space-y-4">
|
{actionGroups.length > 1 && (
|
||||||
{actionGroups.map((group, groupIndex) => (
|
<div className="rounded-md border bg-blue-50 p-3">
|
||||||
<div key={group.id} className="bg-card rounded-lg border">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
{/* 그룹 헤더 */}
|
<h4 className="text-sm font-medium text-blue-900">그룹 간 실행 조건</h4>
|
||||||
<Collapsible
|
|
||||||
open={expandedGroups.has(group.id)}
|
|
||||||
onOpenChange={() => toggleGroupExpansion(group.id)}
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger asChild>
|
|
||||||
<div className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{expandedGroups.has(group.id) ? (
|
|
||||||
<ChevronDown className="h-4 w-4" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Input
|
|
||||||
value={group.name}
|
|
||||||
onChange={(e) => onUpdateActionGroup(group.id, { name: e.target.value })}
|
|
||||||
className="h-8 w-40"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<Badge className={getLogicalOperatorColor(group.logicalOperator)}>
|
|
||||||
{group.logicalOperator}
|
|
||||||
</Badge>
|
|
||||||
<Badge variant={group.isEnabled ? "default" : "secondary"}>
|
|
||||||
{group.actions.length}개 액션
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* 그룹 논리 연산자 선택 */}
|
|
||||||
<Select
|
|
||||||
value={group.logicalOperator}
|
|
||||||
onValueChange={(value: "AND" | "OR") =>
|
|
||||||
onUpdateActionGroup(group.id, { logicalOperator: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-20" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="AND">AND</SelectItem>
|
|
||||||
<SelectItem value="OR">OR</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* 그룹 활성화/비활성화 */}
|
|
||||||
<Switch
|
|
||||||
checked={group.isEnabled}
|
|
||||||
onCheckedChange={(checked) => onUpdateActionGroup(group.id, { isEnabled: checked })}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 그룹 삭제 */}
|
|
||||||
{actionGroups.length > 1 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
onDeleteActionGroup(group.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
|
|
||||||
{/* 그룹 내용 */}
|
|
||||||
<CollapsibleContent>
|
|
||||||
<div className="bg-muted/20 border-t p-4">
|
|
||||||
{/* 액션 추가 버튼 */}
|
|
||||||
<div className="mb-4 flex justify-end">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onAddActionToGroup(group.id)}
|
|
||||||
className="flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
액션 추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 목록 */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
{group.actions.map((action, actionIndex) => (
|
|
||||||
<div key={action.id} className="rounded-md border bg-white p-3">
|
|
||||||
{/* 액션 헤더 */}
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-lg">{getActionTypeIcon(action.actionType)}</span>
|
|
||||||
<Input
|
|
||||||
value={action.name}
|
|
||||||
onChange={(e) =>
|
|
||||||
onUpdateActionInGroup(group.id, action.id, { name: e.target.value })
|
|
||||||
}
|
|
||||||
className="h-8 w-32"
|
|
||||||
/>
|
|
||||||
<Select
|
|
||||||
value={action.actionType}
|
|
||||||
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
|
||||||
onUpdateActionInGroup(group.id, action.id, { actionType: value })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-24">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="insert">INSERT</SelectItem>
|
|
||||||
<SelectItem value="update">UPDATE</SelectItem>
|
|
||||||
<SelectItem value="delete">DELETE</SelectItem>
|
|
||||||
<SelectItem value="upsert">UPSERT</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
checked={action.isEnabled}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
onUpdateActionInGroup(group.id, action.id, { isEnabled: checked })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{group.actions.length > 1 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onDeleteActionFromGroup(group.id, action.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 액션 조건 설정 */}
|
|
||||||
<ActionConditionBuilder
|
|
||||||
actionType={action.actionType}
|
|
||||||
fromColumns={fromColumns}
|
|
||||||
toColumns={toColumns}
|
|
||||||
conditions={action.conditions}
|
|
||||||
fieldMappings={action.fieldMappings}
|
|
||||||
onConditionsChange={(conditions) =>
|
|
||||||
onUpdateActionInGroup(group.id, action.id, { conditions })
|
|
||||||
}
|
|
||||||
onFieldMappingsChange={(fieldMappings) =>
|
|
||||||
onUpdateActionInGroup(group.id, action.id, { fieldMappings })
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 그룹 로직 설명 */}
|
|
||||||
<div className="mt-4 rounded-md bg-blue-50 p-3">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<AlertTriangle className="mt-0.5 h-4 w-4 text-blue-600" />
|
|
||||||
<div className="text-sm">
|
|
||||||
<div className="font-medium text-blue-900">{group.logicalOperator} 조건 그룹</div>
|
|
||||||
<div className="text-blue-700">
|
|
||||||
{group.logicalOperator === "AND"
|
|
||||||
? "이 그룹의 모든 액션이 실행 가능한 조건일 때만 실행됩니다."
|
|
||||||
: "이 그룹의 액션 중 하나라도 실행 가능한 조건이면 해당 액션만 실행됩니다."}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</Collapsible>
|
|
||||||
|
|
||||||
{/* 그룹 간 연결선 (마지막 그룹이 아닌 경우) */}
|
|
||||||
{groupIndex < actionGroups.length - 1 && (
|
|
||||||
<div className="flex justify-center py-2">
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-xs">
|
|
||||||
<div className="bg-border h-px w-8"></div>
|
|
||||||
<span>다음 그룹</span>
|
|
||||||
<div className="bg-border h-px w-8"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="flex items-center gap-3">
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
</div>
|
<input
|
||||||
)}
|
type="radio"
|
||||||
|
id="groups-and"
|
||||||
{activeTab === "mapping" && hasInsertActions && (
|
name="groups-operator"
|
||||||
<div className="space-y-4">
|
checked={groupsLogicalOperator === "AND"}
|
||||||
{/* 컬럼 매핑 헤더 */}
|
onChange={() => onSetGroupsLogicalOperator?.("AND")}
|
||||||
<div className="flex items-center justify-between">
|
className="h-4 w-4"
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<h3 className="text-lg font-medium">컬럼 매핑</h3>
|
<label htmlFor="groups-and" className="text-sm text-blue-800">
|
||||||
<Badge variant="outline" className="text-xs">
|
<span className="font-medium">AND</span> - 모든 그룹의 조건이 참일 때 실행
|
||||||
{fieldMappings.length}개 매핑
|
</label>
|
||||||
</Badge>
|
</div>
|
||||||
</div>
|
<div className="flex items-center gap-2">
|
||||||
<div className="text-muted-foreground text-sm">INSERT 액션에 필요한 필드들을 매핑하세요</div>
|
<input
|
||||||
</div>
|
type="radio"
|
||||||
|
id="groups-or"
|
||||||
{/* 컬럼 매핑 캔버스 */}
|
name="groups-operator"
|
||||||
{isLoading ? (
|
checked={groupsLogicalOperator === "OR"}
|
||||||
<div className="flex h-64 items-center justify-center">
|
onChange={() => onSetGroupsLogicalOperator?.("OR")}
|
||||||
<div className="text-muted-foreground">컬럼 정보를 불러오는 중...</div>
|
className="h-4 w-4"
|
||||||
</div>
|
/>
|
||||||
) : fromColumns.length > 0 && toColumns.length > 0 ? (
|
<label htmlFor="groups-or" className="text-sm text-blue-800">
|
||||||
<div className="rounded-lg border bg-white p-4">
|
<span className="font-medium">OR</span> - 하나 이상의 그룹 조건이 참일 때 실행
|
||||||
<FieldMappingCanvas
|
</label>
|
||||||
fromFields={fromColumns}
|
</div>
|
||||||
toFields={toColumns}
|
|
||||||
mappings={fieldMappings}
|
|
||||||
onCreateMapping={onCreateMapping}
|
|
||||||
onDeleteMapping={onDeleteMapping}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex h-64 flex-col items-center justify-center space-y-3 rounded-lg border border-dashed">
|
|
||||||
<AlertTriangle className="text-muted-foreground h-8 w-8" />
|
|
||||||
<div className="text-muted-foreground">컬럼 정보를 찾을 수 없습니다.</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
FROM 컬럼: {fromColumns.length}개, TO 컬럼: {toColumns.length}개
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 매핑되지 않은 필드 처리 옵션 */}
|
{/* 액션 그룹 목록 */}
|
||||||
<div className="rounded-md border bg-yellow-50 p-4">
|
<div className="space-y-4">
|
||||||
<h4 className="mb-3 flex items-center gap-2 font-medium text-yellow-800">
|
{actionGroups.map((group, groupIndex) => (
|
||||||
<AlertTriangle className="h-4 w-4" />
|
<div key={group.id}>
|
||||||
매핑되지 않은 필드 처리
|
{/* 그룹 간 논리 연산자 표시 (첫 번째 그룹 제외) */}
|
||||||
</h4>
|
{groupIndex > 0 && (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="my-2 flex items-center justify-center">
|
||||||
<div className="flex items-center gap-2">
|
<div
|
||||||
<input type="radio" id="empty" name="unmapped-strategy" defaultChecked className="h-4 w-4" />
|
className={`rounded px-2 py-1 text-xs font-medium ${
|
||||||
<label htmlFor="empty" className="text-yellow-700">
|
groupsLogicalOperator === "AND"
|
||||||
비워두기 (NULL 또는 빈 값)
|
? "bg-green-100 text-green-800"
|
||||||
</label>
|
: "bg-orange-100 text-orange-800"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{groupsLogicalOperator}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="bg-card rounded-lg border">
|
||||||
|
{/* 그룹 헤더 */}
|
||||||
|
<Collapsible
|
||||||
|
open={expandedGroups.has(group.id)}
|
||||||
|
onOpenChange={() => toggleGroupExpansion(group.id)}
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<div className="hover:bg-muted/50 flex cursor-pointer items-center justify-between p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{expandedGroups.has(group.id) ? (
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={group.name}
|
||||||
|
onChange={(e) => onUpdateActionGroup(group.id, { name: e.target.value })}
|
||||||
|
className="h-8 w-40"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<Badge className={getLogicalOperatorColor(group.logicalOperator)}>
|
||||||
|
{group.logicalOperator}
|
||||||
|
</Badge>
|
||||||
|
<Badge variant={group.isEnabled ? "default" : "secondary"}>
|
||||||
|
{group.actions.length}개 액션
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* 그룹 논리 연산자 선택 */}
|
||||||
|
<Select
|
||||||
|
value={group.logicalOperator}
|
||||||
|
onValueChange={(value: "AND" | "OR") =>
|
||||||
|
onUpdateActionGroup(group.id, { logicalOperator: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-20" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="AND">AND</SelectItem>
|
||||||
|
<SelectItem value="OR">OR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 그룹 활성화/비활성화 */}
|
||||||
|
<Switch
|
||||||
|
checked={group.isEnabled}
|
||||||
|
onCheckedChange={(checked) => onUpdateActionGroup(group.id, { isEnabled: checked })}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 그룹 삭제 */}
|
||||||
|
{actionGroups.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDeleteActionGroup(group.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
|
||||||
|
{/* 그룹 내용 */}
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="bg-muted/20 border-t p-4">
|
||||||
|
{/* 액션 추가 버튼 */}
|
||||||
|
<div className="mb-4 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onAddActionToGroup(group.id)}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
액션 추가
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 목록 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{group.actions.map((action, actionIndex) => (
|
||||||
|
<div key={action.id} className="rounded-md border bg-white p-3">
|
||||||
|
{/* 액션 헤더 */}
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-lg">{getActionTypeIcon(action.actionType)}</span>
|
||||||
|
<Input
|
||||||
|
value={action.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateActionInGroup(group.id, action.id, { name: e.target.value })
|
||||||
|
}
|
||||||
|
className="h-8 w-32"
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
value={action.actionType}
|
||||||
|
onValueChange={(value: "insert" | "update" | "delete" | "upsert") =>
|
||||||
|
onUpdateActionInGroup(group.id, action.id, { actionType: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="insert">INSERT</SelectItem>
|
||||||
|
<SelectItem value="update">UPDATE</SelectItem>
|
||||||
|
<SelectItem value="delete">DELETE</SelectItem>
|
||||||
|
<SelectItem value="upsert">UPSERT</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
checked={action.isEnabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
onUpdateActionInGroup(group.id, action.id, { isEnabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{group.actions.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDeleteActionFromGroup(group.id, action.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 액션 조건 설정 */}
|
||||||
|
{isColumnsLoaded ? (
|
||||||
|
<ActionConditionBuilder
|
||||||
|
actionType={action.actionType}
|
||||||
|
fromColumns={fromColumns}
|
||||||
|
toColumns={toColumns}
|
||||||
|
conditions={action.conditions}
|
||||||
|
fieldMappings={(() => {
|
||||||
|
// 필드값 설정용: FieldValueMapping 타입만 필터링
|
||||||
|
const fieldValueMappings = (action.fieldMappings || []).filter(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.valueType && // valueType이 있고
|
||||||
|
!mapping.fromField && // fromField가 없고
|
||||||
|
!mapping.toField, // toField가 없으면 FieldValueMapping
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📋 ActionConditionBuilder에 전달되는 필드값 설정:", {
|
||||||
|
allMappings: action.fieldMappings,
|
||||||
|
filteredFieldValueMappings: fieldValueMappings,
|
||||||
|
});
|
||||||
|
|
||||||
|
return fieldValueMappings;
|
||||||
|
})()}
|
||||||
|
columnMappings={
|
||||||
|
// 컬럼 매핑용: FieldMapping 타입만 필터링
|
||||||
|
(action.fieldMappings || []).filter(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.fromField &&
|
||||||
|
mapping.toField &&
|
||||||
|
mapping.fromField.columnName &&
|
||||||
|
mapping.toField.columnName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onConditionsChange={(conditions) =>
|
||||||
|
onUpdateActionInGroup(group.id, action.id, { conditions })
|
||||||
|
}
|
||||||
|
onFieldMappingsChange={(newFieldMappings) => {
|
||||||
|
// 필드값 설정만 업데이트, 컬럼 매핑은 유지
|
||||||
|
const existingColumnMappings = (action.fieldMappings || []).filter(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.fromField &&
|
||||||
|
mapping.toField &&
|
||||||
|
mapping.fromField.columnName &&
|
||||||
|
mapping.toField.columnName,
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("🔄 필드값 설정 업데이트:", {
|
||||||
|
existingColumnMappings,
|
||||||
|
newFieldMappings,
|
||||||
|
combined: [...existingColumnMappings, ...newFieldMappings],
|
||||||
|
});
|
||||||
|
|
||||||
|
onUpdateActionInGroup(group.id, action.id, {
|
||||||
|
fieldMappings: [...existingColumnMappings, ...newFieldMappings],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center py-4">
|
||||||
|
컬럼 정보를 불러오는 중...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* INSERT 액션일 때만 필드 매핑 UI 표시 */}
|
||||||
|
{action.actionType === "insert" && isColumnsLoaded && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<Separator />
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h5 className="text-sm font-medium">필드 매핑</h5>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{action.fieldMappings?.length || 0}개 매핑
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 컬럼 매핑 캔버스 */}
|
||||||
|
<div className="rounded-lg border bg-white p-3">
|
||||||
|
<FieldMappingCanvas
|
||||||
|
fromFields={fromColumns}
|
||||||
|
toFields={toColumns}
|
||||||
|
mappings={
|
||||||
|
// 컬럼 매핑만 FieldMappingCanvas에 전달
|
||||||
|
(action.fieldMappings || []).filter(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.fromField &&
|
||||||
|
mapping.toField &&
|
||||||
|
mapping.fromField.columnName &&
|
||||||
|
mapping.toField.columnName,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCreateMapping={(fromField, toField) => {
|
||||||
|
const newMapping = {
|
||||||
|
id: `${fromField.columnName}_to_${toField.columnName}_${Date.now()}`,
|
||||||
|
fromField,
|
||||||
|
toField,
|
||||||
|
isValid: true,
|
||||||
|
validationMessage: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 기존 필드값 설정은 유지하고 새 컬럼 매핑만 추가
|
||||||
|
const existingFieldValueMappings = (action.fieldMappings || []).filter(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.valueType && // valueType이 있고
|
||||||
|
!mapping.fromField && // fromField가 없고
|
||||||
|
!mapping.toField, // toField가 없으면 FieldValueMapping
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingColumnMappings = (action.fieldMappings || []).filter(
|
||||||
|
(mapping) =>
|
||||||
|
mapping.fromField &&
|
||||||
|
mapping.toField &&
|
||||||
|
mapping.fromField.columnName &&
|
||||||
|
mapping.toField.columnName,
|
||||||
|
);
|
||||||
|
|
||||||
|
onUpdateActionInGroup(group.id, action.id, {
|
||||||
|
fieldMappings: [
|
||||||
|
...existingFieldValueMappings,
|
||||||
|
...existingColumnMappings,
|
||||||
|
newMapping,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onDeleteMapping={(mappingId) => {
|
||||||
|
// 컬럼 매핑만 삭제하고 필드값 설정은 유지
|
||||||
|
const remainingMappings = (action.fieldMappings || []).filter(
|
||||||
|
(mapping) => mapping.id !== mappingId,
|
||||||
|
);
|
||||||
|
|
||||||
|
onUpdateActionInGroup(group.id, action.id, {
|
||||||
|
fieldMappings: remainingMappings,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 매핑되지 않은 필드 처리 옵션 */}
|
||||||
|
<div className="rounded-md border bg-yellow-50 p-3">
|
||||||
|
<h6 className="mb-2 flex items-center gap-1 text-xs font-medium text-yellow-800">
|
||||||
|
<AlertTriangle className="h-3 w-3" />
|
||||||
|
매핑되지 않은 필드 처리
|
||||||
|
</h6>
|
||||||
|
<div className="space-y-2 text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={`empty-${action.id}`}
|
||||||
|
name={`unmapped-${action.id}`}
|
||||||
|
defaultChecked
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`empty-${action.id}`} className="text-yellow-700">
|
||||||
|
비워두기 (NULL 값)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={`default-${action.id}`}
|
||||||
|
name={`unmapped-${action.id}`}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`default-${action.id}`} className="text-yellow-700">
|
||||||
|
기본값 사용
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id={`skip-${action.id}`}
|
||||||
|
name={`unmapped-${action.id}`}
|
||||||
|
className="h-3 w-3"
|
||||||
|
/>
|
||||||
|
<label htmlFor={`skip-${action.id}`} className="text-yellow-700">
|
||||||
|
필드 제외
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 그룹 로직 설명 */}
|
||||||
|
<div className="mt-4 rounded-md bg-blue-50 p-3">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<AlertTriangle className="mt-0.5 h-4 w-4 text-blue-600" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-medium text-blue-900">{group.logicalOperator} 조건 그룹</div>
|
||||||
|
<div className="text-blue-700">
|
||||||
|
{group.logicalOperator === "AND"
|
||||||
|
? "이 그룹의 모든 액션이 실행 가능한 조건일 때만 실행됩니다."
|
||||||
|
: "이 그룹의 액션 중 하나라도 실행 가능한 조건이면 해당 액션만 실행됩니다."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
</Collapsible>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
))}
|
||||||
<input type="radio" id="default" name="unmapped-strategy" className="h-4 w-4" />
|
|
||||||
<label htmlFor="default" className="text-yellow-700">
|
|
||||||
기본값 사용 (데이터베이스 DEFAULT 값)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input type="radio" id="skip" name="unmapped-strategy" className="h-4 w-4" />
|
|
||||||
<label htmlFor="skip" className="text-yellow-700">
|
|
||||||
해당 필드 제외 (INSERT 구문에 포함하지 않음)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,12 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
|
||||||
connectionType={state.connectionType}
|
connectionType={state.connectionType}
|
||||||
fromConnection={state.fromConnection}
|
fromConnection={state.fromConnection}
|
||||||
toConnection={state.toConnection}
|
toConnection={state.toConnection}
|
||||||
|
relationshipName={state.relationshipName}
|
||||||
|
description={state.description}
|
||||||
|
diagramId={state.diagramId} // 🔧 수정 모드 감지용
|
||||||
onSelectConnection={actions.selectConnection}
|
onSelectConnection={actions.selectConnection}
|
||||||
|
onSetRelationshipName={actions.setRelationshipName}
|
||||||
|
onSetDescription={actions.setDescription}
|
||||||
onNext={() => actions.goToStep(2)}
|
onNext={() => actions.goToStep(2)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -84,7 +89,11 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
|
||||||
state={state}
|
state={state}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
onBack={() => actions.goToStep(2)}
|
onBack={() => actions.goToStep(2)}
|
||||||
onNext={() => actions.goToStep(4)}
|
onNext={() => {
|
||||||
|
// 4단계로 넘어가기 전에 컬럼 로드
|
||||||
|
actions.loadColumns();
|
||||||
|
actions.goToStep(4);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -96,20 +105,25 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
|
||||||
toTable={state.toTable}
|
toTable={state.toTable}
|
||||||
fromConnection={state.fromConnection}
|
fromConnection={state.fromConnection}
|
||||||
toConnection={state.toConnection}
|
toConnection={state.toConnection}
|
||||||
|
fromColumns={state.fromColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||||
|
toColumns={state.toColumns} // 🔧 중앙에서 관리되는 컬럼 정보
|
||||||
controlConditions={state.controlConditions}
|
controlConditions={state.controlConditions}
|
||||||
onUpdateControlCondition={actions.updateControlCondition}
|
onUpdateControlCondition={actions.updateControlCondition}
|
||||||
onDeleteControlCondition={actions.deleteControlCondition}
|
onDeleteControlCondition={actions.deleteControlCondition}
|
||||||
onAddControlCondition={actions.addControlCondition}
|
onAddControlCondition={actions.addControlCondition}
|
||||||
actionGroups={state.actionGroups}
|
actionGroups={state.actionGroups}
|
||||||
|
groupsLogicalOperator={state.groupsLogicalOperator}
|
||||||
onUpdateActionGroup={actions.updateActionGroup}
|
onUpdateActionGroup={actions.updateActionGroup}
|
||||||
onDeleteActionGroup={actions.deleteActionGroup}
|
onDeleteActionGroup={actions.deleteActionGroup}
|
||||||
onAddActionGroup={actions.addActionGroup}
|
onAddActionGroup={actions.addActionGroup}
|
||||||
onAddActionToGroup={actions.addActionToGroup}
|
onAddActionToGroup={actions.addActionToGroup}
|
||||||
onUpdateActionInGroup={actions.updateActionInGroup}
|
onUpdateActionInGroup={actions.updateActionInGroup}
|
||||||
onDeleteActionFromGroup={actions.deleteActionFromGroup}
|
onDeleteActionFromGroup={actions.deleteActionFromGroup}
|
||||||
|
onSetGroupsLogicalOperator={actions.setGroupsLogicalOperator}
|
||||||
fieldMappings={state.fieldMappings}
|
fieldMappings={state.fieldMappings}
|
||||||
onCreateMapping={actions.createMapping}
|
onCreateMapping={actions.createMapping}
|
||||||
onDeleteMapping={actions.deleteMapping}
|
onDeleteMapping={actions.deleteMapping}
|
||||||
|
onLoadColumns={actions.loadColumns}
|
||||||
onNext={() => {
|
onNext={() => {
|
||||||
// 완료 처리 - 저장 및 상위 컴포넌트 알림
|
// 완료 처리 - 저장 및 상위 컴포넌트 알림
|
||||||
actions.saveMappings();
|
actions.saveMappings();
|
||||||
|
|
|
||||||
|
|
@ -214,11 +214,13 @@ const FieldMappingCanvas: React.FC<FieldMappingCanvasProps> = ({
|
||||||
// 매핑 여부 확인
|
// 매핑 여부 확인
|
||||||
const isFieldMapped = useCallback(
|
const isFieldMapped = useCallback(
|
||||||
(field: ColumnInfo, type: "from" | "to") => {
|
(field: ColumnInfo, type: "from" | "to") => {
|
||||||
return mappings.some((mapping) =>
|
return mappings
|
||||||
type === "from"
|
.filter((mapping) => mapping.fromField && mapping.toField) // 유효한 매핑만 확인
|
||||||
? mapping.fromField.columnName === field.columnName
|
.some((mapping) =>
|
||||||
: mapping.toField.columnName === field.columnName,
|
type === "from"
|
||||||
);
|
? mapping.fromField?.columnName === field.columnName
|
||||||
|
: mapping.toField?.columnName === field.columnName,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[mappings],
|
[mappings],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,150 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import React, { useState } from "react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
|
||||||
import { CheckCircle, Save } from "lucide-react";
|
|
||||||
|
|
||||||
interface SaveRelationshipDialogProps {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
onSave: (relationshipName: string, description?: string) => void;
|
|
||||||
actionType: "insert" | "update" | "delete" | "upsert";
|
|
||||||
fromTable?: string;
|
|
||||||
toTable?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 💾 관계 저장 다이얼로그
|
|
||||||
* - 관계 이름 입력
|
|
||||||
* - 설명 입력 (선택사항)
|
|
||||||
* - 액션 타입별 제안 이름
|
|
||||||
*/
|
|
||||||
const SaveRelationshipDialog: React.FC<SaveRelationshipDialogProps> = ({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
onSave,
|
|
||||||
actionType,
|
|
||||||
fromTable,
|
|
||||||
toTable,
|
|
||||||
}) => {
|
|
||||||
const [relationshipName, setRelationshipName] = useState("");
|
|
||||||
const [description, setDescription] = useState("");
|
|
||||||
|
|
||||||
// 액션 타입별 제안 이름 생성
|
|
||||||
const generateSuggestedName = () => {
|
|
||||||
if (!fromTable || !toTable) return "";
|
|
||||||
|
|
||||||
const actionMap = {
|
|
||||||
insert: "입력",
|
|
||||||
update: "수정",
|
|
||||||
delete: "삭제",
|
|
||||||
upsert: "병합",
|
|
||||||
};
|
|
||||||
|
|
||||||
return `${fromTable}_${toTable}_${actionMap[actionType]}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
if (!relationshipName.trim()) return;
|
|
||||||
|
|
||||||
onSave(relationshipName.trim(), description.trim() || undefined);
|
|
||||||
onOpenChange(false);
|
|
||||||
|
|
||||||
// 폼 초기화
|
|
||||||
setRelationshipName("");
|
|
||||||
setDescription("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestName = () => {
|
|
||||||
const suggested = generateSuggestedName();
|
|
||||||
if (suggested) {
|
|
||||||
setRelationshipName(suggested);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<Save className="h-5 w-5" />
|
|
||||||
관계 저장
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>데이터 연결 관계의 이름과 설명을 입력하세요.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* 관계 이름 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="relationshipName">관계 이름 *</Label>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Input
|
|
||||||
id="relationshipName"
|
|
||||||
placeholder="예: 사용자_주문_입력"
|
|
||||||
value={relationshipName}
|
|
||||||
onChange={(e) => setRelationshipName(e.target.value)}
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<Button variant="outline" size="sm" onClick={handleSuggestName} disabled={!fromTable || !toTable}>
|
|
||||||
제안
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 설명 (선택사항) */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="description">설명 (선택사항)</Label>
|
|
||||||
<Textarea
|
|
||||||
id="description"
|
|
||||||
placeholder="이 관계에 대한 설명을 입력하세요..."
|
|
||||||
value={description}
|
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정보 요약 */}
|
|
||||||
<div className="bg-muted/50 rounded-lg p-3">
|
|
||||||
<div className="space-y-1 text-sm">
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">액션 타입:</span>
|
|
||||||
<span className="font-medium">{actionType.toUpperCase()}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">소스 테이블:</span>
|
|
||||||
<span className="font-medium">{fromTable || "미선택"}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-between">
|
|
||||||
<span className="text-muted-foreground">대상 테이블:</span>
|
|
||||||
<span className="font-medium">{toTable || "미선택"}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
취소
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={!relationshipName.trim()}>
|
|
||||||
<CheckCircle className="mr-2 h-4 w-4" />
|
|
||||||
저장
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SaveRelationshipDialog;
|
|
||||||
|
|
@ -89,6 +89,11 @@ export interface DataConnectionState {
|
||||||
connectionType: "data_save" | "external_call";
|
connectionType: "data_save" | "external_call";
|
||||||
currentStep: 1 | 2 | 3 | 4;
|
currentStep: 1 | 2 | 3 | 4;
|
||||||
|
|
||||||
|
// 관계 정보
|
||||||
|
diagramId?: number; // 🔧 수정 모드 감지용
|
||||||
|
relationshipName?: string;
|
||||||
|
description?: string;
|
||||||
|
|
||||||
// 연결 정보
|
// 연결 정보
|
||||||
fromConnection?: Connection;
|
fromConnection?: Connection;
|
||||||
toConnection?: Connection;
|
toConnection?: Connection;
|
||||||
|
|
@ -104,6 +109,7 @@ export interface DataConnectionState {
|
||||||
|
|
||||||
// 액션 설정 (멀티 액션 지원)
|
// 액션 설정 (멀티 액션 지원)
|
||||||
actionGroups: ActionGroup[];
|
actionGroups: ActionGroup[];
|
||||||
|
groupsLogicalOperator?: "AND" | "OR"; // 그룹 간의 논리 연산자
|
||||||
|
|
||||||
// 기존 호환성을 위한 필드들 (deprecated)
|
// 기존 호환성을 위한 필드들 (deprecated)
|
||||||
actionType?: "insert" | "update" | "delete" | "upsert";
|
actionType?: "insert" | "update" | "delete" | "upsert";
|
||||||
|
|
@ -112,6 +118,8 @@ export interface DataConnectionState {
|
||||||
|
|
||||||
// UI 상태
|
// UI 상태
|
||||||
selectedMapping?: string;
|
selectedMapping?: string;
|
||||||
|
fromColumns?: ColumnInfo[]; // 🔧 FROM 테이블 컬럼 정보 (중앙 관리)
|
||||||
|
toColumns?: ColumnInfo[]; // 🔧 TO 테이블 컬럼 정보 (중앙 관리)
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
validationErrors: ValidationError[];
|
validationErrors: ValidationError[];
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +129,11 @@ export interface DataConnectionActions {
|
||||||
// 연결 타입
|
// 연결 타입
|
||||||
setConnectionType: (type: "data_save" | "external_call") => void;
|
setConnectionType: (type: "data_save" | "external_call") => void;
|
||||||
|
|
||||||
|
// 관계 정보
|
||||||
|
setRelationshipName: (name: string) => void;
|
||||||
|
setDescription: (description: string) => void;
|
||||||
|
setGroupsLogicalOperator: (operator: "AND" | "OR") => void;
|
||||||
|
|
||||||
// 단계 진행
|
// 단계 진행
|
||||||
goToStep: (step: 1 | 2 | 3 | 4) => void;
|
goToStep: (step: 1 | 2 | 3 | 4) => void;
|
||||||
|
|
||||||
|
|
@ -128,6 +141,9 @@ export interface DataConnectionActions {
|
||||||
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
selectConnection: (type: "from" | "to", connection: Connection) => void;
|
||||||
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
selectTable: (type: "from" | "to", table: TableInfo) => void;
|
||||||
|
|
||||||
|
// 컬럼 정보 로드 (중앙 관리)
|
||||||
|
loadColumns: () => Promise<void>;
|
||||||
|
|
||||||
// 필드 매핑
|
// 필드 매핑
|
||||||
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
createMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
|
||||||
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
updateMapping: (mappingId: string, updates: Partial<FieldMapping>) => void;
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,43 @@
|
||||||
import { apiClient } from "./client";
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
// 관계명 중복 체크
|
||||||
|
export const checkRelationshipNameDuplicate = async (relationshipName: string, excludeDiagramId?: number) => {
|
||||||
|
try {
|
||||||
|
console.log(`🔍 관계명 중복 체크: "${relationshipName}", 제외 ID: ${excludeDiagramId}`);
|
||||||
|
|
||||||
|
const response = await apiClient.get("/dataflow-diagrams", {
|
||||||
|
params: {
|
||||||
|
searchTerm: relationshipName,
|
||||||
|
page: 1,
|
||||||
|
size: 100, // 충분히 큰 수로 설정
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.data.success) {
|
||||||
|
throw new Error("관계명 중복 체크 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagrams = response.data.data.diagrams || [];
|
||||||
|
|
||||||
|
// 정확히 일치하는 이름 찾기 (대소문자 구분)
|
||||||
|
const duplicates = diagrams.filter(
|
||||||
|
(diagram: any) =>
|
||||||
|
diagram.diagram_name === relationshipName && (!excludeDiagramId || diagram.diagram_id !== excludeDiagramId),
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ 중복 체크 결과: ${duplicates.length}개 중복 발견`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDuplicate: duplicates.length > 0,
|
||||||
|
duplicateCount: duplicates.length,
|
||||||
|
duplicateDiagrams: duplicates,
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 관계명 중복 체크 실패:", error);
|
||||||
|
throw new Error("관계명 중복 체크 중 오류가 발생했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장된 dataflow diagram으로부터 관계 정보를 조회하여 DataConnectionDesigner에서 사용할 수 있는 형태로 변환
|
* 저장된 dataflow diagram으로부터 관계 정보를 조회하여 DataConnectionDesigner에서 사용할 수 있는 형태로 변환
|
||||||
*/
|
*/
|
||||||
|
|
@ -75,31 +113,22 @@ export const loadDataflowRelationship = async (diagramId: number) => {
|
||||||
else if (diagram.relationships && typeof diagram.relationships === "object") {
|
else if (diagram.relationships && typeof diagram.relationships === "object") {
|
||||||
console.log("🔄 다른 구조 감지, relationships 전체를 확인 중:", diagram.relationships);
|
console.log("🔄 다른 구조 감지, relationships 전체를 확인 중:", diagram.relationships);
|
||||||
|
|
||||||
// relationships 자체가 데이터인 경우 (레거시 구조)
|
// 현재 DB에 저장된 구조 처리 (relationships 객체에 모든 데이터가 있음)
|
||||||
if (
|
relationshipsData = {
|
||||||
diagram.relationships.description ||
|
description: diagram.relationships.description || "",
|
||||||
diagram.relationships.connectionType ||
|
connectionType: diagram.relationships.connectionType || "data_save",
|
||||||
diagram.relationships.fromTable
|
fromConnection: diagram.relationships.fromConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
) {
|
toConnection: diagram.relationships.toConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
relationshipsData = diagram.relationships;
|
fromTable: diagram.relationships.fromTable,
|
||||||
console.log("✅ 레거시 구조 감지:", relationshipsData);
|
toTable: diagram.relationships.toTable,
|
||||||
}
|
actionType: diagram.relationships.actionType || "insert",
|
||||||
// relationships가 빈 객체이거나 예상치 못한 구조인 경우
|
controlConditions: diagram.relationships.controlConditions || [],
|
||||||
else {
|
actionConditions: diagram.relationships.actionConditions || [],
|
||||||
console.log("⚠️ 알 수 없는 relationships 구조, 기본값 생성");
|
fieldMappings: diagram.relationships.fieldMappings || [],
|
||||||
relationshipsData = {
|
// 🔧 멀티 액션 그룹 데이터 로드
|
||||||
description: "",
|
actionGroups: diagram.relationships.actionGroups || null,
|
||||||
connectionType: "data_save",
|
};
|
||||||
fromConnection: { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
console.log("✅ 현재 DB 구조 처리 완료:", relationshipsData);
|
||||||
toConnection: { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
|
||||||
fromTable: "",
|
|
||||||
toTable: "",
|
|
||||||
actionType: "insert",
|
|
||||||
controlConditions: [],
|
|
||||||
actionConditions: [],
|
|
||||||
fieldMappings: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
throw new Error("관계 데이터가 없습니다.");
|
throw new Error("관계 데이터가 없습니다.");
|
||||||
}
|
}
|
||||||
|
|
@ -108,8 +137,9 @@ export const loadDataflowRelationship = async (diagramId: number) => {
|
||||||
|
|
||||||
// DataConnectionDesigner에서 사용하는 형태로 변환
|
// DataConnectionDesigner에서 사용하는 형태로 변환
|
||||||
const loadedData = {
|
const loadedData = {
|
||||||
|
diagramId: diagram.diagram_id, // 🔧 수정 모드를 위한 diagramId 추가
|
||||||
relationshipName: diagram.diagram_name,
|
relationshipName: diagram.diagram_name,
|
||||||
description: relationshipsData.description || "",
|
description: relationshipsData.description || diagram.description || "", // 🔧 diagram.description도 확인
|
||||||
connectionType: relationshipsData.connectionType || "data_save",
|
connectionType: relationshipsData.connectionType || "data_save",
|
||||||
fromConnection: relationshipsData.fromConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
fromConnection: relationshipsData.fromConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
toConnection: relationshipsData.toConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
toConnection: relationshipsData.toConnection || { id: 0, name: "메인 데이터베이스 (현재 시스템)" },
|
||||||
|
|
@ -119,6 +149,8 @@ export const loadDataflowRelationship = async (diagramId: number) => {
|
||||||
controlConditions: relationshipsData.controlConditions || [],
|
controlConditions: relationshipsData.controlConditions || [],
|
||||||
actionConditions: relationshipsData.actionConditions || [],
|
actionConditions: relationshipsData.actionConditions || [],
|
||||||
fieldMappings: relationshipsData.fieldMappings || [],
|
fieldMappings: relationshipsData.fieldMappings || [],
|
||||||
|
// 🔧 멀티 액션 그룹 데이터 포함
|
||||||
|
actionGroups: relationshipsData.actionGroups,
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log("✨ 변환된 로드 데이터:", loadedData);
|
console.log("✨ 변환된 로드 데이터:", loadedData);
|
||||||
|
|
@ -141,9 +173,9 @@ export const loadDataflowRelationship = async (diagramId: number) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const saveDataflowRelationship = async (data: any) => {
|
export const saveDataflowRelationship = async (data: any, diagramId?: number) => {
|
||||||
try {
|
try {
|
||||||
console.log("💾 임시 저장 방식 사용 - dataflow-diagrams API 활용");
|
console.log("💾 데이터플로우 관계 저장:", { diagramId, isEdit: !!diagramId });
|
||||||
|
|
||||||
// dataflow-diagrams API 형식에 맞게 데이터 변환
|
// dataflow-diagrams API 형식에 맞게 데이터 변환
|
||||||
const requestData = {
|
const requestData = {
|
||||||
|
|
@ -160,6 +192,8 @@ export const saveDataflowRelationship = async (data: any) => {
|
||||||
controlConditions: data.controlConditions,
|
controlConditions: data.controlConditions,
|
||||||
actionConditions: data.actionConditions,
|
actionConditions: data.actionConditions,
|
||||||
description: data.description,
|
description: data.description,
|
||||||
|
// 🔧 멀티 액션 그룹 데이터 추가
|
||||||
|
actionGroups: data.actionGroups,
|
||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
type: "data-connection",
|
type: "data-connection",
|
||||||
|
|
@ -177,8 +211,10 @@ export const saveDataflowRelationship = async (data: any) => {
|
||||||
|
|
||||||
console.log("📡 변환된 요청 데이터:", requestData);
|
console.log("📡 변환된 요청 데이터:", requestData);
|
||||||
|
|
||||||
// dataflow-diagrams API로 저장 (임시 해결책)
|
// 수정 모드인 경우 PUT, 신규 생성인 경우 POST
|
||||||
const response = await apiClient.post("/dataflow-diagrams", requestData);
|
const response = diagramId
|
||||||
|
? await apiClient.put(`/dataflow-diagrams/${diagramId}`, requestData)
|
||||||
|
: await apiClient.post("/dataflow-diagrams", requestData);
|
||||||
|
|
||||||
console.log("✅ dataflow-diagrams 저장 성공:", response.data);
|
console.log("✅ dataflow-diagrams 저장 성공:", response.data);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,9 @@ export interface ColumnInfo {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
dataType: string;
|
dataType: string;
|
||||||
webType?: string;
|
webType?: string;
|
||||||
|
inputType?: string; // column_labels의 input_type (코드 타입 판단용)
|
||||||
|
codeCategory?: string; // 코드 카테고리 정보
|
||||||
|
connectionId?: number; // 메인 DB(0) vs 외부 DB 구분
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue