제어관리 데이터 저장기능

This commit is contained in:
kjs
2025-09-26 13:52:32 +09:00
parent 2a4e379dc4
commit 9454e3a81f
17 changed files with 1417 additions and 781 deletions

View File

@@ -36,6 +36,7 @@ interface ActionConditionBuilderProps {
toColumns: ColumnInfo[];
conditions: ActionCondition[];
fieldMappings: FieldValueMapping[];
columnMappings?: any[]; // 컬럼 매핑 정보 (이미 매핑된 필드들)
onConditionsChange: (conditions: ActionCondition[]) => void;
onFieldMappingsChange: (mappings: FieldValueMapping[]) => void;
showFieldMappings?: boolean; // 필드 매핑 섹션 표시 여부
@@ -53,12 +54,41 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
toColumns,
conditions,
fieldMappings,
columnMappings = [],
onConditionsChange,
onFieldMappingsChange,
showFieldMappings = true,
}) => {
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 = [
{ value: "=", label: "같음 (=)" },
{ value: "!=", label: "다름 (!=)" },
@@ -75,9 +105,25 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
// 코드 정보 로드
useEffect(() => {
const loadCodes = async () => {
const codeFields = [...fromColumns, ...toColumns].filter(
(col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
const codeFields = [...fromColumns, ...toColumns].filter((col) => {
// 메인 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) {
try {
@@ -100,6 +146,23 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
}
}, [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 newCondition: ActionCondition = {
@@ -129,6 +192,20 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
// 필드 매핑 추가
const addFieldMapping = () => {
// 임시로 검증을 완화 - 매핑되지 않은 필드가 있으면 추가 허용
const unmappedColumns = getUnmappedToColumns();
console.log("🔍 필드 추가 시도:", {
unmappedColumns,
unmappedColumnsCount: unmappedColumns.length,
fieldMappings,
columnMappings,
});
if (unmappedColumns.length === 0) {
console.warn("매핑되지 않은 필드가 없습니다.");
return;
}
const newMapping: FieldValueMapping = {
id: Date.now().toString(),
targetField: "",
@@ -136,6 +213,7 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
value: "",
};
console.log("✅ 새 필드 매핑 추가:", newMapping);
onFieldMappingsChange([...fieldMappings, newMapping]);
};
@@ -153,7 +231,11 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
// 필드의 값 입력 컴포넌트 렌더링
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] || [];
return (
@@ -164,12 +246,7 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
<SelectContent>
{codes.map((code) => (
<SelectItem key={code.code} value={code.code}>
<div className="flex items-center gap-2">
<Badge variant="outline" className="text-xs">
{code.code}
</Badge>
<span>{code.name}</span>
</div>
{code.name}
</SelectItem>
))}
</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 (
<Input
placeholder="값 입력"
@@ -467,8 +667,16 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center justify-between text-base">
<span> (SET)</span>
<Button variant="outline" size="sm" onClick={addFieldMapping}>
<div>
<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" />
</Button>
@@ -476,65 +684,98 @@ const ActionConditionBuilder: React.FC<ActionConditionBuilderProps> = ({
</CardHeader>
<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">
<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>
) : (
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 (
<div key={mapping.id} className="flex items-center gap-3 rounded-lg border p-3">
{/* 대상 필드 */}
<Select
value={mapping.targetField}
onValueChange={(value) => updateFieldMapping(index, { targetField: value })}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="대상 필드" />
</SelectTrigger>
<SelectContent>
{toColumns.map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
<div className="flex items-center gap-2">
<span>{column.displayName || column.columnName}</span>
<Badge variant="outline" className="text-xs">
{column.webType || column.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
return (
<div key={mapping.id} className="flex items-center gap-3 rounded-lg border p-3">
{/* 대상 필드 */}
<Select
value={mapping.targetField}
onValueChange={(value) =>
updateFieldMapping(index, {
targetField: value,
value: "", // 필드 변경 시 값 초기화
sourceField: "", // 소스 필드도 초기화
})
}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="대상 필드" />
</SelectTrigger>
<SelectContent>
{getAvailableFieldsForMapping(index).map((column) => (
<SelectItem key={column.columnName} value={column.columnName}>
<div className="flex items-center gap-2">
<span>{column.displayName || column.columnName}</span>
<Badge variant="outline" className="text-xs">
{column.webType || column.dataType}
</Badge>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 값 타입 */}
<Select
value={mapping.valueType}
onValueChange={(value) => updateFieldMapping(index, { valueType: value as any })}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="static"></SelectItem>
<SelectItem value="source_field"></SelectItem>
{targetColumn?.webType === "code" && <SelectItem value="code"></SelectItem>}
<SelectItem value="calculated"></SelectItem>
</SelectContent>
</Select>
{/* 값 타입 */}
<Select
value={mapping.valueType}
onValueChange={(value) =>
updateFieldMapping(index, {
valueType: value as any,
value: "", // 값 타입 변경 시 값 초기화
sourceField: "", // 소스 필드도 초기화
})
}
>
<SelectTrigger className="w-32">
<SelectValue />
</SelectTrigger>
<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)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
})
{/* 삭제 버튼 */}
<Button variant="ghost" size="sm" onClick={() => deleteFieldMapping(index)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
);
});
})()
)}
</CardContent>
</Card>

View File

@@ -1,15 +1,19 @@
"use client";
import React, { useState, useEffect } from "react";
import React, { useState, useEffect, useCallback } from "react";
import { CardContent, CardHeader, CardTitle } from "@/components/ui/card";
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 { 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";
// API import
import { getActiveConnections, ConnectionInfo } from "@/lib/api/multiConnection";
import { checkRelationshipNameDuplicate } from "@/lib/api/dataflowSave";
// 타입 import
import { Connection } from "@/lib/types/multiConnection";
@@ -18,7 +22,12 @@ interface ConnectionStepProps {
connectionType: "data_save" | "external_call";
fromConnection?: Connection;
toConnection?: Connection;
relationshipName?: string;
description?: string;
diagramId?: number; // 🔧 수정 모드 감지용
onSelectConnection: (type: "from" | "to", connection: Connection) => void;
onSetRelationshipName: (name: string) => void;
onSetDescription: (description: string) => void;
onNext: () => void;
}
@@ -29,9 +38,21 @@ interface ConnectionStepProps {
* - 지연시간 정보
*/
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 [isLoading, setIsLoading] = useState(true);
const [nameCheckStatus, setNameCheckStatus] = useState<"idle" | "checking" | "valid" | "duplicate">("idle");
// API 응답을 Connection 타입으로 변환
const convertToConnection = (connectionInfo: ConnectionInfo): Connection => ({
@@ -48,6 +69,45 @@ const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
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(() => {
const loadConnections = async () => {
@@ -150,6 +210,50 @@ const ConnectionStep: React.FC<ConnectionStepProps> = React.memo(
</CardHeader>
<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 ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="mr-2 h-6 w-6 animate-spin" />

View File

@@ -11,7 +11,7 @@ import { ArrowLeft, CheckCircle, AlertCircle, Settings, Plus, Trash2 } from "luc
// 타입 import
import { DataConnectionState, DataConnectionActions } from "../types/redesigned";
import { ColumnInfo } from "@/lib/types/multiConnection";
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
// 컬럼 로드는 중앙에서 관리
import { getCodesForColumn, CodeItem } from "@/lib/api/codeManagement";
// 컴포넌트 import
@@ -29,61 +29,66 @@ interface ControlConditionStepProps {
* - INSERT/UPDATE/DELETE 트리거 조건
*/
const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, actions, onBack, onNext }) => {
const { controlConditions, fromTable, toTable, fromConnection, toConnection } = state;
const [fromColumns, setFromColumns] = useState<ColumnInfo[]>([]);
const [toColumns, setToColumns] = useState<ColumnInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const {
controlConditions,
fromTable,
toTable,
fromConnection,
toConnection,
fromColumns = [],
toColumns = [],
isLoading = false,
} = state;
const [availableCodes, setAvailableCodes] = useState<Record<string, CodeItem[]>>({});
// 컬럼 정보 로드
// 컴포넌트 마운트 시 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
console.log("🔄 ControlConditionStep 컬럼 로드 시작");
console.log("fromConnection:", fromConnection);
console.log("toConnection:", toConnection);
console.log("fromTable:", fromTable);
console.log("toTable:", toTable);
if (!fromConnection || !toConnection || !fromTable || !toTable) {
console.log("❌ 필수 정보 누락으로 컬럼 로드 중단");
return;
}
setIsLoading(true);
try {
console.log(
`🚀 컬럼 조회 시작: FROM=${fromConnection.id}/${fromTable.tableName}, TO=${toConnection.id}/${toTable.tableName}`,
);
const [fromCols, toCols] = await Promise.all([
getColumnsFromConnection(fromConnection.id, fromTable.tableName),
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]);
if (
fromConnection &&
toConnection &&
fromTable &&
toTable &&
fromColumns.length === 0 &&
toColumns.length === 0 &&
!isLoading
) {
console.log("🔄 ControlConditionStep: 컬럼 로드 시작");
actions.loadColumns();
}
}, [
fromConnection?.id,
toConnection?.id,
fromTable?.tableName,
toTable?.tableName,
fromColumns.length,
toColumns.length,
isLoading,
]);
// 코드 타입 컬럼의 코드 로드
useEffect(() => {
const loadCodes = async () => {
const allColumns = [...fromColumns, ...toColumns];
const codeColumns = allColumns.filter(
(col) => col.webType === "code" || col.dataType?.toLowerCase().includes("code"),
);
const codeColumns = allColumns.filter((col) => {
// 메인 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;
console.log(
"🔍 모든 컬럼 정보:",
allColumns.map((col) => ({
columnName: col.columnName,
connectionId: col.connectionId,
inputType: col.inputType,
webType: col.webType,
})),
);
console.log("🔍 코드 타입 컬럼들:", codeColumns);
const codePromises = codeColumns.map(async (col) => {
@@ -213,6 +218,7 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
actions.updateControlCondition(index, {
...condition,
field: value,
value: "", // 필드 변경 시 값 초기화
});
}
}}
@@ -272,15 +278,18 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
);
const isCodeField =
selectedField &&
(selectedField.webType === "code" ||
selectedField.dataType?.toLowerCase().includes("code"));
(selectedField.connectionId === 0 || selectedField.connectionId === undefined) && // 임시: undefined도 메인 DB로 간주
selectedField.inputType === "code";
const fieldCodes = condition.field ? availableCodes[condition.field] : [];
// 디버깅 정보 출력
console.log("🔍 값 입력 필드 디버깅:", {
conditionField: condition.field,
selectedField: selectedField,
selectedFieldKeys: selectedField ? Object.keys(selectedField) : [],
webType: selectedField?.webType,
inputType: selectedField?.inputType,
connectionId: selectedField?.connectionId,
dataType: selectedField?.dataType,
isCodeField: isCodeField,
fieldCodes: fieldCodes,
@@ -323,7 +332,7 @@ const ControlConditionStep: React.FC<ControlConditionStepProps> = ({ state, acti
key={`code_${condition.field}_${code.code || codeIndex}_${codeIndex}`}
value={code.code || `unknown_${codeIndex}`}
>
{code.name || code.description || `코드 ${codeIndex + 1}`}
{code.name}
</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">
<AlertCircle className="text-muted-foreground mx-auto mb-3 h-8 w-8" />
<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">
<h4 className="mb-2 text-sm font-medium text-yellow-800"> </h4>
<div className="space-y-2 text-sm text-yellow-700">

View File

@@ -24,8 +24,7 @@ import {
} from "lucide-react";
import { toast } from "sonner";
// API import
import { getColumnsFromConnection } from "@/lib/api/multiConnection";
// API import (컬럼 로드는 중앙에서 관리)
// 타입 import
import { ColumnInfo, Connection, TableInfo } from "@/lib/types/multiConnection";
@@ -40,6 +39,9 @@ interface MultiActionConfigStepProps {
toTable?: TableInfo;
fromConnection?: Connection;
toConnection?: Connection;
// 컬럼 정보 (중앙에서 관리) 🔧 추가
fromColumns?: ColumnInfo[];
toColumns?: ColumnInfo[];
// 제어 조건 관련
controlConditions: any[];
onUpdateControlCondition: (index: number, condition: any) => void;
@@ -47,12 +49,14 @@ interface MultiActionConfigStepProps {
onAddControlCondition: () => void;
// 액션 그룹 관련
actionGroups: ActionGroup[];
groupsLogicalOperator?: "AND" | "OR";
onUpdateActionGroup: (groupId: string, updates: Partial<ActionGroup>) => void;
onDeleteActionGroup: (groupId: string) => void;
onAddActionGroup: () => void;
onAddActionToGroup: (groupId: string) => void;
onUpdateActionInGroup: (groupId: string, actionId: string, updates: Partial<SingleAction>) => void;
onDeleteActionFromGroup: (groupId: string, actionId: string) => void;
onSetGroupsLogicalOperator?: (operator: "AND" | "OR") => void;
// 필드 매핑 관련
fieldMappings: FieldMapping[];
onCreateMapping: (fromField: ColumnInfo, toField: ColumnInfo) => void;
@@ -60,6 +64,8 @@ interface MultiActionConfigStepProps {
// 네비게이션
onNext: () => void;
onBack: () => void;
// 컬럼 로드 액션
onLoadColumns?: () => Promise<void>;
}
/**
@@ -75,55 +81,41 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
toTable,
fromConnection,
toConnection,
fromColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
toColumns = [], // 🔧 중앙에서 관리되는 컬럼 정보
controlConditions,
onUpdateControlCondition,
onDeleteControlCondition,
onAddControlCondition,
actionGroups,
groupsLogicalOperator = "AND",
onUpdateActionGroup,
onDeleteActionGroup,
onAddActionGroup,
onAddActionToGroup,
onUpdateActionInGroup,
onDeleteActionFromGroup,
onSetGroupsLogicalOperator,
fieldMappings,
onCreateMapping,
onDeleteMapping,
onNext,
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 [activeTab, setActiveTab] = useState<"control" | "actions" | "mapping">("control"); // 현재 활성 탭
// 컬럼 정보 로드
// 컬럼 로딩 상태 확인
const isColumnsLoaded = fromColumns.length > 0 && toColumns.length > 0;
// 컴포넌트 마운트 시 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!fromConnection || !toConnection || !fromTable || !toTable) {
return;
}
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]);
if (!isColumnsLoaded && fromConnection && toConnection && fromTable && toTable && onLoadColumns) {
console.log("🔄 MultiActionConfigStep: 컬럼 로드 시작");
onLoadColumns();
}
}, [isColumnsLoaded, fromConnection?.id, toConnection?.id, fromTable?.tableName, toTable?.tableName]);
// 그룹 확장/축소 토글
const toggleGroupExpansion = (groupId: string) => {
@@ -171,13 +163,10 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
group.actions.some((action) => action.actionType === "insert" && action.isEnabled),
);
// 탭 정보
// 탭 정보 (컬럼 매핑 탭 제거)
const tabs = [
{ id: "control" as const, label: "제어 조건", icon: "🎯", description: "전체 제어 실행 조건" },
{ id: "actions" as const, label: "액션 설정", icon: "⚙️", description: "액션 그룹 및 실행 조건" },
...(hasInsertActions
? [{ id: "mapping" as const, label: "컬럼 매핑", icon: "🔗", description: "INSERT 액션 필드 매핑" }]
: []),
];
return (
@@ -280,265 +269,405 @@ const MultiActionConfigStep: React.FC<MultiActionConfigStepProps> = ({
</Button>
</div>
{/* 액션 그룹 목록 */}
<div className="space-y-4">
{actionGroups.map((group, groupIndex) => (
<div key={group.id} 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>
{/* 액션 조건 설정 */}
<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>
)}
{/* 그룹 간 논리 연산자 선택 */}
{actionGroups.length > 1 && (
<div className="rounded-md border bg-blue-50 p-3">
<div className="mb-2 flex items-center gap-2">
<h4 className="text-sm font-medium text-blue-900"> </h4>
</div>
))}
</div>
</div>
)}
{activeTab === "mapping" && hasInsertActions && (
<div className="space-y-4">
{/* 컬럼 매핑 헤더 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h3 className="text-lg font-medium"> </h3>
<Badge variant="outline" className="text-xs">
{fieldMappings.length}
</Badge>
</div>
<div className="text-muted-foreground text-sm">INSERT </div>
</div>
{/* 컬럼 매핑 캔버스 */}
{isLoading ? (
<div className="flex h-64 items-center justify-center">
<div className="text-muted-foreground"> ...</div>
</div>
) : fromColumns.length > 0 && toColumns.length > 0 ? (
<div className="rounded-lg border bg-white p-4">
<FieldMappingCanvas
fromFields={fromColumns}
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 className="flex items-center gap-3">
<div className="flex items-center gap-2">
<input
type="radio"
id="groups-and"
name="groups-operator"
checked={groupsLogicalOperator === "AND"}
onChange={() => onSetGroupsLogicalOperator?.("AND")}
className="h-4 w-4"
/>
<label htmlFor="groups-and" className="text-sm text-blue-800">
<span className="font-medium">AND</span> -
</label>
</div>
<div className="flex items-center gap-2">
<input
type="radio"
id="groups-or"
name="groups-operator"
checked={groupsLogicalOperator === "OR"}
onChange={() => onSetGroupsLogicalOperator?.("OR")}
className="h-4 w-4"
/>
<label htmlFor="groups-or" className="text-sm text-blue-800">
<span className="font-medium">OR</span> -
</label>
</div>
</div>
</div>
)}
{/* 매핑되지 않은 필드 처리 옵션 */}
<div className="rounded-md border bg-yellow-50 p-4">
<h4 className="mb-3 flex items-center gap-2 font-medium text-yellow-800">
<AlertTriangle className="h-4 w-4" />
</h4>
<div className="space-y-3 text-sm">
<div className="flex items-center gap-2">
<input type="radio" id="empty" name="unmapped-strategy" defaultChecked className="h-4 w-4" />
<label htmlFor="empty" className="text-yellow-700">
(NULL )
</label>
{/* 액션 그룹 목록 */}
<div className="space-y-4">
{actionGroups.map((group, groupIndex) => (
<div key={group.id}>
{/* 그룹 간 논리 연산자 표시 (첫 번째 그룹 제외) */}
{groupIndex > 0 && (
<div className="my-2 flex items-center justify-center">
<div
className={`rounded px-2 py-1 text-xs font-medium ${
groupsLogicalOperator === "AND"
? "bg-green-100 text-green-800"
: "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 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>
)}

View File

@@ -59,7 +59,12 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
connectionType={state.connectionType}
fromConnection={state.fromConnection}
toConnection={state.toConnection}
relationshipName={state.relationshipName}
description={state.description}
diagramId={state.diagramId} // 🔧 수정 모드 감지용
onSelectConnection={actions.selectConnection}
onSetRelationshipName={actions.setRelationshipName}
onSetDescription={actions.setDescription}
onNext={() => actions.goToStep(2)}
/>
);
@@ -84,7 +89,11 @@ const RightPanel: React.FC<RightPanelProps> = ({ state, actions }) => {
state={state}
actions={actions}
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}
fromConnection={state.fromConnection}
toConnection={state.toConnection}
fromColumns={state.fromColumns} // 🔧 중앙에서 관리되는 컬럼 정보
toColumns={state.toColumns} // 🔧 중앙에서 관리되는 컬럼 정보
controlConditions={state.controlConditions}
onUpdateControlCondition={actions.updateControlCondition}
onDeleteControlCondition={actions.deleteControlCondition}
onAddControlCondition={actions.addControlCondition}
actionGroups={state.actionGroups}
groupsLogicalOperator={state.groupsLogicalOperator}
onUpdateActionGroup={actions.updateActionGroup}
onDeleteActionGroup={actions.deleteActionGroup}
onAddActionGroup={actions.addActionGroup}
onAddActionToGroup={actions.addActionToGroup}
onUpdateActionInGroup={actions.updateActionInGroup}
onDeleteActionFromGroup={actions.deleteActionFromGroup}
onSetGroupsLogicalOperator={actions.setGroupsLogicalOperator}
fieldMappings={state.fieldMappings}
onCreateMapping={actions.createMapping}
onDeleteMapping={actions.deleteMapping}
onLoadColumns={actions.loadColumns}
onNext={() => {
// 완료 처리 - 저장 및 상위 컴포넌트 알림
actions.saveMappings();

View File

@@ -214,11 +214,13 @@ const FieldMappingCanvas: React.FC<FieldMappingCanvasProps> = ({
// 매핑 여부 확인
const isFieldMapped = useCallback(
(field: ColumnInfo, type: "from" | "to") => {
return mappings.some((mapping) =>
type === "from"
? mapping.fromField.columnName === field.columnName
: mapping.toField.columnName === field.columnName,
);
return mappings
.filter((mapping) => mapping.fromField && mapping.toField) // 유효한 매핑만 확인
.some((mapping) =>
type === "from"
? mapping.fromField?.columnName === field.columnName
: mapping.toField?.columnName === field.columnName,
);
},
[mappings],
);