데이터 저장

This commit is contained in:
hyeonsu 2025-09-15 20:07:28 +09:00
parent 6a04ae450d
commit 2c677c2fb8
7 changed files with 636 additions and 253 deletions

View File

@ -5331,7 +5331,7 @@ model dataflow_diagrams {
// 조건부 연결 관련 컬럼들
control Json? // 조건 설정 (트리거 타입, 조건 트리)
category String? @db.VarChar(50) // 연결 종류 ("simple-key", "data-save", "external-call")
category Json? // 연결 종류 배열 (["simple-key", "data-save", "external-call"])
plan Json? // 실행 계획 (대상 액션들)
company_code String @db.VarChar(50)

View File

@ -93,6 +93,9 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
diagram_name,
relationships,
node_positions,
category,
control,
plan,
company_code,
created_by,
updated_by,
@ -100,6 +103,9 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
logger.info(`새 관계도 생성 요청:`, { diagram_name, company_code });
logger.info(`node_positions:`, node_positions);
logger.info(`category:`, category);
logger.info(`control:`, control);
logger.info(`plan:`, plan);
logger.info(`전체 요청 Body:`, JSON.stringify(req.body, null, 2));
const companyCode =
company_code ||
@ -119,10 +125,27 @@ export const createDataflowDiagram = async (req: Request, res: Response) => {
});
}
// 🔍 백엔드에서 받은 실제 데이터 로깅
console.log(
"🔍 백엔드에서 받은 control 데이터:",
JSON.stringify(control, null, 2)
);
console.log(
"🔍 백엔드에서 받은 plan 데이터:",
JSON.stringify(plan, null, 2)
);
console.log(
"🔍 백엔드에서 받은 category 데이터:",
JSON.stringify(category, null, 2)
);
const newDiagram = await createDataflowDiagramService({
diagram_name,
relationships,
node_positions,
category,
control,
plan,
company_code: companyCode,
created_by: userId,
updated_by: userId,

View File

@ -9,10 +9,10 @@ interface CreateDataflowDiagramData {
relationships: Record<string, unknown>; // JSON 데이터
node_positions?: Record<string, unknown> | null; // JSON 데이터 (노드 위치 정보)
// 조건부 연결 관련 필드
control?: Record<string, unknown> | null; // JSON 데이터 (조건 설정)
category?: string; // 연결 종류 ("simple-key", "data-save", "external-call")
plan?: Record<string, unknown> | null; // JSON 데이터 (실행 계획)
// 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드
control?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 조건 설정)
category?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 연결 종류)
plan?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 실행 계획)
company_code: string;
created_by: string;
@ -24,10 +24,10 @@ interface UpdateDataflowDiagramData {
relationships?: Record<string, unknown>; // JSON 데이터
node_positions?: Record<string, unknown> | null; // JSON 데이터 (노드 위치 정보)
// 조건부 연결 관련 필드
control?: Record<string, unknown> | null; // JSON 데이터 (조건 설정)
category?: string; // 연결 종류 ("simple-key", "data-save", "external-call")
plan?: Record<string, unknown> | null; // JSON 데이터 (실행 계획)
// 🔥 수정: 배열 구조로 변경된 조건부 연결 관련 필드
control?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 조건 설정)
category?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 연결 종류)
plan?: Array<Record<string, unknown>> | null; // JSON 배열 (각 관계별 실행 계획)
updated_by: string;
}
@ -142,7 +142,11 @@ export const createDataflowDiagram = async (
node_positions: data.node_positions as
| Prisma.InputJsonValue
| undefined,
category: data.category || undefined,
category: data.category
? (data.category as Prisma.InputJsonValue)
: undefined,
control: data.control as Prisma.InputJsonValue | undefined,
plan: data.plan as Prisma.InputJsonValue | undefined,
company_code: data.company_code,
created_by: data.created_by,
updated_by: data.updated_by,
@ -213,7 +217,17 @@ export const updateDataflowDiagram = async (
? (data.node_positions as Prisma.InputJsonValue)
: Prisma.JsonNull,
}),
...(data.category !== undefined && { category: data.category }),
...(data.category !== undefined && {
category: data.category
? (data.category as Prisma.InputJsonValue)
: undefined,
}),
...(data.control !== undefined && {
control: data.control as Prisma.InputJsonValue | undefined,
}),
...(data.plan !== undefined && {
plan: data.plan as Prisma.InputJsonValue | undefined,
}),
updated_by: data.updated_by,
updated_at: new Date(),
},

View File

@ -91,34 +91,48 @@ export class EventTriggerService {
const results: ExecutionResult[] = [];
try {
// 해당 테이블과 트리거 타입에 맞는 조건부 연결들 조회
const diagrams = await prisma.dataflow_diagrams.findMany({
where: {
company_code: companyCode,
control: {
path: ["triggerType"],
equals:
triggerType === "insert"
? "insert"
: triggerType === "update"
? ["update", "insert_update"]
: triggerType === "delete"
? "delete"
: triggerType,
},
plan: {
path: ["sourceTable"],
equals: tableName,
},
},
// 🔥 수정: Raw SQL을 사용하여 JSON 배열 검색
const diagrams = (await prisma.$queryRaw`
SELECT * FROM dataflow_diagrams
WHERE company_code = ${companyCode}
AND (
category::text = '"data-save"' OR
category::jsonb ? 'data-save' OR
category::jsonb @> '["data-save"]'
)
`) as any[];
// 각 관계도에서 해당 테이블을 소스로 하는 데이터 저장 관계들 필터링
const matchingDiagrams = diagrams.filter((diagram) => {
// category 배열에서 data-save 연결이 있는지 확인
const categories = diagram.category as any[];
const hasDataSave = Array.isArray(categories)
? categories.some((cat) => cat.category === "data-save")
: false;
if (!hasDataSave) return false;
// plan 배열에서 해당 테이블을 소스로 하는 항목이 있는지 확인
const plans = diagram.plan as any[];
const hasMatchingPlan = Array.isArray(plans)
? plans.some((plan) => plan.sourceTable === tableName)
: false;
// control 배열에서 해당 트리거 타입이 있는지 확인
const controls = diagram.control as any[];
const hasMatchingControl = Array.isArray(controls)
? controls.some((control) => control.triggerType === triggerType)
: false;
return hasDataSave && hasMatchingPlan && hasMatchingControl;
});
logger.info(
`Found ${diagrams.length} conditional connections for table ${tableName} with trigger ${triggerType}`
`Found ${matchingDiagrams.length} matching data-save connections for table ${tableName} with trigger ${triggerType}`
);
// 각 다이어그램에 대해 조건부 연결 실행
for (const diagram of diagrams) {
for (const diagram of matchingDiagrams) {
try {
const result = await this.executeDiagramTrigger(
diagram,

View File

@ -62,9 +62,9 @@ interface DataSaveSettings {
conditions?: ConditionNode[];
fieldMappings: Array<{
sourceTable?: string;
sourceField: string;
sourceField: string;
targetTable?: string;
targetField: string;
targetField: string;
defaultValue?: string;
transformFunction?: string;
}>;
@ -188,6 +188,10 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
actions: [],
});
// 🔥 필드 선택 상태 초기화
setSelectedFromColumns([]);
setSelectedToColumns([]);
// 외부 호출 기본값 설정
setExternalCallSettings({
callType: "rest-api",
@ -325,9 +329,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
// 단순 키값 연결일 때만 컬럼 선택 검증
if (config.connectionType === "simple-key") {
if (selectedFromColumns.length === 0 || selectedToColumns.length === 0) {
toast.error("선택된 컬럼이 없습니다. From과 To 테이블에서 각각 최소 1개 이상의 컬럼을 선택해주세요.");
return;
if (selectedFromColumns.length === 0 || selectedToColumns.length === 0) {
toast.error("선택된 컬럼이 없습니다. From과 To 테이블에서 각각 최소 1개 이상의 컬럼을 선택해주세요.");
return;
}
}
@ -622,7 +626,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
const renderActionCondition = (condition: ConditionNode, condIndex: number, actionIndex: number) => {
// 그룹 시작 렌더링
if (condition.type === "group-start") {
return (
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 그룹 시작 앞의 논리 연산자 */}
{condIndex > 0 && (
@ -663,8 +667,8 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
</div>
</div>
</div>
);
}
@ -692,13 +696,13 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
</div>
</div>
);
);
}
// 일반 조건 렌더링 (기존 로직 간소화)
return (
return (
<div key={condition.id} className="flex items-center gap-2">
{/* 그룹 내 첫 번째 조건이 아닐 때만 논리 연산자 표시 */}
{condIndex > 0 && dataSaveSettings.actions[actionIndex].conditions![condIndex - 1]?.type !== "group-start" && (
@ -772,7 +776,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
if (dataType.includes("timestamp") || dataType.includes("datetime") || dataType.includes("date")) {
return (
<Input
<Input
type="datetime-local"
value={String(condition.value || "")}
onChange={(e) => {
@ -785,7 +789,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
);
} else if (dataType.includes("time")) {
return (
<Input
<Input
type="time"
value={String(condition.value || "")}
onChange={(e) => {
@ -876,9 +880,9 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
className="h-6 w-6 p-0"
>
<Trash2 className="h-2 w-2" />
</Button>
</div>
</div>
</Button>
</div>
</div>
);
};
@ -889,7 +893,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
<div className="mb-4 flex items-center gap-2">
<Zap className="h-4 w-4 text-purple-500" />
<span className="text-sm font-medium"> ( )</span>
</div>
</div>
{/* 실행 조건 설정 */}
<div className="mb-4">
@ -906,7 +910,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
<Button size="sm" variant="outline" onClick={() => addGroupEnd()} className="h-7 text-xs">
)
</Button>
</div>
</div>
</div>
{/* 조건 목록 */}
@ -953,14 +957,14 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
);
</div>
</div>
);
}
// 그룹 끝 렌더링
if (condition.type === "group-end") {
return (
return (
<div key={condition.id} className="flex items-center gap-2">
<div
className="flex items-center gap-2 rounded border-2 border-dashed border-blue-300 bg-blue-50/50 p-2"
@ -976,7 +980,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
}
@ -991,13 +995,13 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
onValueChange={(value: "AND" | "OR") => updateCondition(index - 1, "logicalOperator", value)}
>
<SelectTrigger className="h-8 w-24 border-blue-200 bg-blue-50 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND</SelectItem>
<SelectItem value="OR">OR</SelectItem>
</SelectContent>
</Select>
</SelectContent>
</Select>
)}
{/* 그룹 레벨에 따른 들여쓰기와 조건 필드들 */}
@ -1052,7 +1056,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
dataType.includes("date")
) {
return (
<Input
<Input
type="datetime-local"
value={String(condition.value || "")}
onChange={(e) => updateCondition(index, "value", e.target.value)}
@ -1095,18 +1099,18 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
);
} else if (dataType.includes("bool")) {
return (
<Select
<Select
value={String(condition.value || "")}
onValueChange={(value) => updateCondition(index, "value", value)}
>
<SelectTrigger className="h-8 flex-1 text-xs">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
</SelectTrigger>
<SelectContent>
<SelectItem value="true">TRUE</SelectItem>
<SelectItem value="false">FALSE</SelectItem>
</SelectContent>
</Select>
</SelectContent>
</Select>
);
} else {
return (
@ -1124,147 +1128,147 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
<Button size="sm" variant="ghost" onClick={() => removeCondition(index)} className="h-8 w-8 p-0">
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
})
)}
)}
</div>
</div>
</div>
);
</div>
</div>
);
};
// 연결 종류별 설정 패널 렌더링
const renderConnectionTypeSettings = () => {
switch (config.connectionType) {
case "simple-key":
return (
<div className="space-y-4">
{/* 테이블 및 컬럼 선택 */}
<div className="rounded-lg border bg-gray-50 p-4">
<div className="mb-4 text-sm font-medium"> </div>
return (
<div className="space-y-4">
{/* 테이블 및 컬럼 선택 */}
<div className="rounded-lg border bg-gray-50 p-4">
<div className="mb-4 text-sm font-medium"> </div>
{/* 현재 선택된 테이블 표시 */}
<div className="mb-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedFromTable})</span>
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedToTable})</span>
</div>
{/* 현재 선택된 테이블 표시 */}
<div className="mb-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedFromTable)?.displayName || selectedFromTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedFromTable})</span>
</div>
</div>
{/* 컬럼 선택 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{fromTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedFromColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedFromColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedFromColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{fromTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{toTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedToColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedToColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedToColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{toTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-1">
<span className="text-sm font-medium text-gray-800">
{availableTables.find((t) => t.tableName === selectedToTable)?.displayName || selectedToTable}
</span>
<span className="ml-2 text-xs text-gray-500">({selectedToTable})</span>
</div>
</div>
{/* 선택된 컬럼 미리보기 */}
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600"> From </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedFromColumns.length > 0 ? (
selectedFromColumns.map((column) => (
<Badge key={column} variant="outline" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600"> To </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedToColumns.length > 0 ? (
selectedToColumns.map((column) => (
<Badge key={column} variant="secondary" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
</div>
)}
</div>
{/* 컬럼 선택 */}
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600">From </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{fromTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedFromColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedFromColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedFromColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{fromTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedFromTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600">To </Label>
<div className="mt-2 max-h-32 overflow-y-auto rounded border bg-white p-2">
{toTableColumns.map((column) => (
<label key={column.columnName} className="flex items-center gap-2 py-1 text-sm">
<input
type="checkbox"
checked={selectedToColumns.includes(column.columnName)}
onChange={(e) => {
if (e.target.checked) {
setSelectedToColumns((prev) => [...prev, column.columnName]);
} else {
setSelectedToColumns((prev) => prev.filter((col) => col !== column.columnName));
}
}}
className="rounded"
/>
<span>{column.columnName}</span>
<span className="text-xs text-gray-500">({column.dataType})</span>
</label>
))}
{toTableColumns.length === 0 && (
<div className="py-2 text-xs text-gray-500">
{selectedToTable ? "컬럼을 불러오는 중..." : "테이블을 먼저 선택해주세요"}
</div>
)}
</div>
</div>
</div>
{/* 선택된 컬럼 미리보기 */}
{(selectedFromColumns.length > 0 || selectedToColumns.length > 0) && (
<div className="mt-4 grid grid-cols-2 gap-4">
<div>
<Label className="text-xs font-medium text-gray-600"> From </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedFromColumns.length > 0 ? (
selectedFromColumns.map((column) => (
<Badge key={column} variant="outline" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
<div>
<Label className="text-xs font-medium text-gray-600"> To </Label>
<div className="mt-1 flex flex-wrap gap-1">
{selectedToColumns.length > 0 ? (
selectedToColumns.map((column) => (
<Badge key={column} variant="secondary" className="text-xs">
{column}
</Badge>
))
) : (
<span className="text-xs text-gray-400"> </span>
)}
</div>
</div>
</div>
)}
</div>
{/* 단순 키값 연결 설정 */}
<div className="rounded-lg border border-l-4 border-l-blue-500 bg-blue-50/30 p-4">
<div className="mb-3 flex items-center gap-2">
@ -1272,7 +1276,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
<span className="text-sm font-medium"> </span>
</div>
<div className="space-y-3">
<div>
<div>
<Label htmlFor="notes" className="text-sm">
</Label>
@ -1339,7 +1343,7 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
{dataSaveSettings.actions.map((action, actionIndex) => (
<div key={action.id} className="rounded border bg-white p-3">
<div className="mb-3 flex items-center justify-between">
<Input
<Input
value={action.name}
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
@ -1629,18 +1633,26 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
{/* 소스 */}
<div className="flex items-center gap-1 rounded bg-blue-50 px-2 py-1">
<Select
value={mapping.sourceTable || ""}
value={mapping.sourceTable || "__EMPTY__"}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = value;
// 🔥 "__EMPTY__" 값을 빈 문자열로 변환
const actualValue = value === "__EMPTY__" ? "" : value;
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = actualValue;
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = "";
// 🔥 소스 선택 시 기본값 초기화 (빈 값이 아닐 때만)
if (actualValue) {
newActions[actionIndex].fieldMappings[mappingIndex].defaultValue = "";
}
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={!!(mapping.defaultValue && mapping.defaultValue.trim())}
>
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
<SelectValue placeholder="테이블" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__EMPTY__"> ( )</SelectItem>
{availableTables.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
<div className="truncate" title={table.tableName}>
@ -1650,15 +1662,36 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
))}
</SelectContent>
</Select>
{/* 🔥 FROM 테이블 클리어 버튼 */}
{mapping.sourceTable && (
<button
onClick={() => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = "";
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = "";
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
className="ml-1 flex h-4 w-4 items-center justify-center rounded-full text-gray-400 hover:bg-gray-200 hover:text-gray-600"
title="소스 테이블 지우기"
>
×
</button>
)}
<span className="text-gray-400">.</span>
<Select
value={mapping.sourceField}
onValueChange={(value) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = value;
// 🔥 소스 필드 선택 시 기본값 초기화
if (value) {
newActions[actionIndex].fieldMappings[mappingIndex].defaultValue = "";
}
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={!mapping.sourceTable}
disabled={
!mapping.sourceTable || !!(mapping.defaultValue && mapping.defaultValue.trim())
}
>
<SelectTrigger className="h-6 w-24 border-0 bg-transparent p-0 text-xs">
<SelectValue placeholder="컬럼" />
@ -1734,8 +1767,14 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
onChange={(e) => {
const newActions = [...dataSaveSettings.actions];
newActions[actionIndex].fieldMappings[mappingIndex].defaultValue = e.target.value;
// 🔥 기본값 입력 시 소스 테이블/필드 초기화
if (e.target.value.trim()) {
newActions[actionIndex].fieldMappings[mappingIndex].sourceTable = "";
newActions[actionIndex].fieldMappings[mappingIndex].sourceField = "";
}
setDataSaveSettings({ ...dataSaveSettings, actions: newActions });
}}
disabled={!!mapping.sourceTable}
className="h-6 w-20 text-xs"
placeholder="기본값"
/>
@ -1811,31 +1850,31 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
value={externalCallSettings.apiUrl}
onChange={(e) => setExternalCallSettings({ ...externalCallSettings, apiUrl: e.target.value })}
placeholder="https://api.example.com/webhook"
className="text-sm"
/>
</div>
className="text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<div>
<Label htmlFor="httpMethod" className="text-sm">
HTTP Method
</Label>
<Select
<Select
value={externalCallSettings.httpMethod}
onValueChange={(value: "GET" | "POST" | "PUT" | "DELETE") =>
setExternalCallSettings({ ...externalCallSettings, httpMethod: value })
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
}
>
<SelectTrigger className="text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</SelectContent>
</Select>
</div>
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="headers" className="text-sm">
Headers
@ -1957,7 +1996,52 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleConfirm} disabled={!config.relationshipName}>
<Button
onClick={handleConfirm}
disabled={(() => {
const hasRelationshipName = !!config.relationshipName;
const isDataSave = config.connectionType === "data-save";
const hasActions = dataSaveSettings.actions.length > 0;
const allActionsHaveMappings = dataSaveSettings.actions.every(
(action) => action.fieldMappings.length > 0,
);
const allMappingsComplete = dataSaveSettings.actions.every((action) =>
action.fieldMappings.every((mapping) => {
// 타겟은 항상 필요
if (!mapping.targetTable || !mapping.targetField) return false;
// 소스와 기본값 중 하나는 있어야 함
const hasSource = mapping.sourceTable && mapping.sourceField;
const hasDefault = mapping.defaultValue && mapping.defaultValue.trim();
// FROM 테이블이 비어있으면 기본값이 필요
if (!mapping.sourceTable) {
return !!hasDefault;
}
// FROM 테이블이 있으면 소스 매핑 완성 또는 기본값 필요
return hasSource || hasDefault;
}),
);
console.log("🔍 버튼 비활성화 디버깅:", {
hasRelationshipName,
isDataSave,
hasActions,
allActionsHaveMappings,
allMappingsComplete,
dataSaveSettings,
config,
});
const shouldDisable =
!hasRelationshipName ||
(isDataSave && (!hasActions || !allActionsHaveMappings || !allMappingsComplete));
console.log("🔍 최종 비활성화 여부:", shouldDisable);
return shouldDisable;
})()}
>
</Button>
</DialogFooter>

View File

@ -73,7 +73,7 @@ interface DataFlowDesignerProps {
// 내부에서 사용할 확장된 JsonRelationship 타입 (connectionType 포함)
interface ExtendedJsonRelationship extends JsonRelationship {
connectionType: string;
connectionType: "simple-key" | "data-save" | "external-call";
}
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
@ -111,7 +111,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
existingRelationship?: {
relationshipName: string;
connectionType: string;
settings?: any;
settings?: Record<string, unknown>;
};
} | null>(null);
const [relationships, setRelationships] = useState<TableRelationship[]>([]); // eslint-disable-line @typescript-eslint/no-unused-vars
@ -136,7 +136,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
const [currentDiagramCategory, setCurrentDiagramCategory] = useState<string>("simple-key"); // 현재 관계도의 연결 종류
const [selectedEdgeForEdit, setSelectedEdgeForEdit] = useState<Edge | null>(null); // 수정/삭제할 엣지
const [showEdgeActions, setShowEdgeActions] = useState(false); // 엣지 액션 버튼 표시 상태
const [edgeActionPosition, setEdgeActionPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); // 액션 버튼 위치
const [edgeActionPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 }); // 액션 버튼 위치 (사용하지 않지만 기존 코드 호환성 유지)
const [editingRelationshipId, setEditingRelationshipId] = useState<string | null>(null); // 현재 수정 중인 관계 ID
const [showRelationshipListModal, setShowRelationshipListModal] = useState(false); // 관계 목록 모달 표시 상태
const [selectedTablePairRelationships, setSelectedTablePairRelationships] = useState<ExtendedJsonRelationship[]>([]); // 선택된 테이블 쌍의 관계들
@ -225,15 +225,28 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
console.log("📋 관계 목록:", relationships);
console.log("📊 테이블 목록:", tableNames);
// 🔥 수정: category 배열에서 각 관계의 connectionType 복원
const categoryMap = new Map<string, string>();
if (Array.isArray(jsonDiagram.category)) {
jsonDiagram.category.forEach((cat: { id: string; category: string }) => {
if (cat.id && cat.category) {
categoryMap.set(cat.id, cat.category);
}
});
}
// 기존 데이터에서 relationshipName이 없는 경우 기본값 설정
// category를 각 관계의 connectionType으로 복원
const normalizedRelationships: ExtendedJsonRelationship[] = relationships.map((rel: JsonRelationship) => ({
...rel,
relationshipName: rel.relationshipName || `${rel.fromTable}${rel.toTable}`, // 기본값 설정
connectionType: jsonDiagram.category || "simple-key", // 관계도의 category를 각 관계의 connectionType으로 복원
connectionType: (rel.connectionType || categoryMap.get(rel.id) || "simple-key") as
| "simple-key"
| "data-save"
| "external-call", // category 배열에서 복원
}));
// 메모리에 관계 저장 (기존 관계도 편집 시)
console.log("🔥 정규화된 관계들:", normalizedRelationships);
setTempRelationships(normalizedRelationships);
setCurrentDiagramId(currentDiagramId);
setCurrentDiagramCategory(jsonDiagram.category || "simple-key"); // 관계도의 연결 종류 설정
@ -341,15 +354,21 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
const relationshipEdges: Edge[] = [];
const tableRelationshipCount: { [key: string]: number } = {}; // 테이블 쌍별 관계 개수
console.log("🔥 엣지 생성 시작 - 관계 개수:", normalizedRelationships.length);
normalizedRelationships.forEach((rel: ExtendedJsonRelationship) => {
console.log("🔥 관계 처리 중:", rel.id, rel.connectionType, rel.fromTable, "→", rel.toTable);
const fromTable = rel.fromTable;
const toTable = rel.toTable;
const fromColumns = rel.fromColumns || [];
const toColumns = rel.toColumns || [];
// 🔥 수정: 컬럼 정보가 없어도 엣지는 생성 (data-save 연결 등에서는 컬럼이 없을 수 있음)
if (fromColumns.length === 0 || toColumns.length === 0) {
console.warn("⚠️ 컬럼 정보가 없습니다:", { fromColumns, toColumns });
return;
console.warn("⚠️ 컬럼 정보가 없지만 엣지는 생성합니다:", {
fromColumns,
toColumns,
connectionType: rel.connectionType,
});
}
// 테이블 쌍 키 생성 (양방향 동일하게 처리)
@ -404,6 +423,15 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
console.log("🔗 생성된 관계 에지 수:", relationshipEdges.length);
console.log("📍 관계 에지 상세:", relationshipEdges);
console.log(
"🔥 최종 엣지 설정 전 확인:",
relationshipEdges.map((e) => ({
id: e.id,
source: e.source,
target: e.target,
connectionType: e.data?.connectionType,
})),
);
setEdges(relationshipEdges);
toast.success("관계도를 불러왔습니다.", { id: "load-diagram" });
} catch (error) {
@ -708,7 +736,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
toTable,
fromColumns,
toColumns,
connectionType: relationship.connection_type,
connectionType: relationship.connection_type as "simple-key" | "data-save" | "external-call",
settings: relationship.settings || {},
};
@ -723,6 +751,9 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
setTempRelationships((prev) => [...prev, newRelationship]);
setHasUnsavedChanges(true);
console.log("🔥 새 관계 생성:", newRelationship);
console.log("🔥 연결 타입:", newRelationship.connectionType);
// 첫 번째 관계가 추가되면 관계도의 category를 해당 connectionType으로 설정
if (tempRelationships.length === 0) {
setCurrentDiagramCategory(relationship.connection_type);
@ -743,18 +774,19 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
data: {
relationshipId: newRelationship.id,
relationshipName: newRelationship.relationshipName,
connectionType: relationship.connection_type,
connectionType: newRelationship.connectionType, // 🔥 수정: newRelationship 사용
fromTable,
toTable,
fromColumns,
toColumns,
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
connectionType: relationship.connection_type,
connectionType: newRelationship.connectionType, // 🔥 수정: newRelationship 사용
},
},
};
console.log("🔥 새 엣지 생성:", newEdge);
setEdges((eds) => [...eds, newEdge]);
setPendingConnection(null);
@ -764,7 +796,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
console.log("메모리에 관계 생성 완료:", newRelationship);
toast.success("관계가 생성되었습니다. 저장 버튼을 눌러 관계도를 저장하세요.");
},
[pendingConnection, setEdges],
[pendingConnection, setEdges, editingRelationshipId, tempRelationships.length],
);
// 연결 설정 취소
@ -811,26 +843,114 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
console.log("📋 연결된 테이블 목록:", connectedTables);
console.log("🔗 관계 개수:", tempRelationships.length);
// 관계도의 주요 연결 타입 결정 (첫 번째 관계의 connectionType 사용)
const primaryConnectionType = tempRelationships.length > 0 ? tempRelationships[0].connectionType : "simple-key";
// 🔥 주요 연결 타입 변수 제거 (더 이상 사용하지 않음)
// connectionType을 관계에서 제거하고 관계도 레벨로 이동
const relationshipsWithoutConnectionType = tempRelationships.map((rel) => {
const { connectionType, ...relationshipWithoutType } = rel;
return relationshipWithoutType;
// 🔥 수정: relationships는 핵심 관계 정보만 포함, settings 전체 제거
const cleanRelationships = tempRelationships.map((rel) => {
// 🔥 settings 전체를 제거하고 핵심 정보만 유지
const cleanRel: JsonRelationship = {
id: rel.id,
fromTable: rel.fromTable,
toTable: rel.toTable,
relationshipName: rel.relationshipName,
connectionType: rel.connectionType,
// simple-key가 아닌 경우 컬럼 정보 제거
fromColumns: rel.connectionType === "simple-key" ? rel.fromColumns : [],
toColumns: rel.connectionType === "simple-key" ? rel.toColumns : [],
};
return cleanRel;
});
// 저장 요청 데이터 생성
const createRequest: CreateDiagramRequest = {
diagram_name: diagramName,
relationships: {
relationships: relationshipsWithoutConnectionType,
relationships: cleanRelationships as JsonRelationship[],
tables: connectedTables,
},
node_positions: nodePositions,
category: primaryConnectionType, // connectionType을 관계도 레벨의 category로 이동
// 🔥 수정: 각 관계별 category 정보를 배열로 저장
category: tempRelationships.map((rel) => ({
id: rel.id,
category: rel.connectionType,
})),
// 🔥 각 관계별 control 정보를 배열로 저장 (전체 실행 조건)
control: tempRelationships
.filter((rel) => rel.connectionType === "data-save")
.map((rel) => {
console.log("🔍 Control 데이터 추출 중:", {
id: rel.id,
settings: rel.settings,
control: rel.settings?.control,
settingsKeys: Object.keys(rel.settings || {}),
});
const controlData = rel.settings?.control as {
triggerType?: "insert" | "update" | "delete";
conditionTree?: Array<{
field: string;
operator_type: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value: unknown;
logicalOperator?: "AND" | "OR";
}>;
};
console.log("🔍 추출된 controlData:", controlData);
console.log("🔍 conditionTree:", controlData?.conditionTree);
return {
id: rel.id, // relationships의 id와 동일
triggerType: (controlData?.triggerType as "insert" | "update" | "delete") || "insert",
// 🔥 실제 저장된 conditionTree에서 조건 추출
conditions: (controlData?.conditionTree || []).map((cond) => ({
field: cond.field,
operator: cond.operator_type,
value: cond.value,
logicalOperator: cond.logicalOperator || "AND",
})),
};
}),
// 🔥 각 관계별 plan 정보를 배열로 저장 (저장 액션)
plan: tempRelationships
.filter((rel) => rel.connectionType === "data-save")
.map((rel) => ({
id: rel.id, // relationships의 id와 동일
sourceTable: rel.fromTable,
// 🔥 실제 사용자가 설정한 액션들 사용
actions:
(rel.settings?.actions as Array<{
id: string;
name: string;
actionType: "insert" | "update" | "delete" | "upsert";
fieldMappings: Array<{
sourceTable?: string;
sourceField: string;
targetTable?: string;
targetField: string;
defaultValue?: string;
transformFunction?: string;
}>;
conditions?: Array<{
id: string;
type: string;
field: string;
operator_type: string;
value: unknown;
logicalOperator?: string;
}>;
}>) || [],
})),
};
// 🔍 디버깅: tempRelationships 구조 확인
console.log("🔍 tempRelationships 전체 구조:", JSON.stringify(tempRelationships, null, 2));
tempRelationships.forEach((rel, index) => {
console.log(`🔍 관계 ${index + 1} settings:`, rel.settings);
console.log(`🔍 관계 ${index + 1} settings.control:`, rel.settings?.control);
console.log(`🔍 관계 ${index + 1} settings.actions:`, rel.settings?.actions);
});
console.log("🚀 API 요청 데이터:", JSON.stringify(createRequest, null, 2));
let savedDiagram;
@ -1181,35 +1301,121 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
{/* 관계 목록 */}
<div className="p-3">
<div className="max-h-96 space-y-2 overflow-y-auto">
{selectedTablePairRelationships.map((relationship, index) => (
{selectedTablePairRelationships.map((relationship) => (
<div
key={relationship.id}
onClick={() => {
// 관계 선택 시 수정 모드로 전환
setEditingRelationshipId(relationship.id);
// 관련 컬럼 하이라이트
const newSelectedColumns: { [tableName: string]: string[] } = {};
if (relationship.fromTable && relationship.fromColumns) {
newSelectedColumns[relationship.fromTable] = [...relationship.fromColumns];
}
if (relationship.toTable && relationship.toColumns) {
newSelectedColumns[relationship.toTable] = [...relationship.toColumns];
}
setSelectedColumns(newSelectedColumns);
// 모달 닫기
setShowRelationshipListModal(false);
}}
className="cursor-pointer rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50"
className="rounded-lg border border-gray-200 p-3 transition-all hover:border-blue-300 hover:bg-blue-50"
>
<div className="mb-1 flex items-center justify-between">
<h4 className="text-sm font-medium text-gray-900">
{relationship.fromTable} {relationship.toTable}
</h4>
<svg className="h-4 w-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<div className="flex items-center gap-1">
{/* 편집 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
// 관계 선택 시 수정 모드로 전환
setEditingRelationshipId(relationship.id);
// 관련 컬럼 하이라이트
const newSelectedColumns: { [tableName: string]: string[] } = {};
if (relationship.fromTable && relationship.fromColumns) {
newSelectedColumns[relationship.fromTable] = [...relationship.fromColumns];
}
if (relationship.toTable && relationship.toColumns) {
newSelectedColumns[relationship.toTable] = [...relationship.toColumns];
}
setSelectedColumns(newSelectedColumns);
// 🔥 수정: 연결 설정 모달 열기
const fromTable = nodes.find(
(node) => node.data?.table?.tableName === relationship.fromTable,
);
const toTable = nodes.find(
(node) => node.data?.table?.tableName === relationship.toTable,
);
if (fromTable && toTable) {
setPendingConnection({
fromNode: {
id: fromTable.id,
tableName: relationship.fromTable,
displayName: fromTable.data?.table?.displayName || relationship.fromTable,
},
toNode: {
id: toTable.id,
tableName: relationship.toTable,
displayName: toTable.data?.table?.displayName || relationship.toTable,
},
selectedColumnsData: {
[relationship.fromTable]: {
displayName: fromTable.data?.table?.displayName || relationship.fromTable,
columns: relationship.fromColumns || [],
},
[relationship.toTable]: {
displayName: toTable.data?.table?.displayName || relationship.toTable,
columns: relationship.toColumns || [],
},
},
existingRelationship: {
relationshipName: relationship.relationshipName,
connectionType: relationship.connectionType,
settings: relationship.settings || {},
},
});
}
// 모달 닫기
setShowRelationshipListModal(false);
}}
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-blue-100 hover:text-blue-600"
title="관계 편집"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
</button>
{/* 삭제 버튼 */}
<button
onClick={(e) => {
e.stopPropagation();
// 관계 삭제
setTempRelationships((prev) => prev.filter((rel) => rel.id !== relationship.id));
setEdges((prev) =>
prev.filter((edge) => edge.data?.relationshipId !== relationship.id),
);
setHasUnsavedChanges(true);
// 선택된 컬럼 초기화
setSelectedColumns({});
// 편집 모드 해제
if (editingRelationshipId === relationship.id) {
setEditingRelationshipId(null);
}
// 모달 닫기
setShowRelationshipListModal(false);
}}
className="flex h-6 w-6 items-center justify-center rounded text-gray-400 hover:bg-red-100 hover:text-red-600"
title="관계 삭제"
>
<svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
/>
</svg>
</button>
</div>
</div>
<div className="space-y-1 text-xs text-gray-600">
<p>: {relationship.connectionType}</p>
@ -1474,7 +1680,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
isOpen={showSaveModal}
onClose={handleCloseSaveModal}
onSave={handleSaveDiagram}
relationships={tempRelationships}
relationships={tempRelationships as JsonRelationship[]} // 타입 단언 추가
defaultName={
diagramId && diagramId > 0 && currentDiagramName
? currentDiagramName // 편집 모드: 기존 관계도 이름

View File

@ -210,6 +210,7 @@ export interface JsonRelationship {
toTable: string;
fromColumns: string[];
toColumns: string[];
connectionType: "simple-key" | "data-save" | "external-call";
settings?: Record<string, unknown>;
}
@ -220,7 +221,48 @@ export interface CreateDiagramRequest {
tables: string[];
};
node_positions?: NodePositions;
category?: string; // 연결 종류 ("simple-key", "data-save", "external-call")
// 🔥 수정: 각 관계별 정보를 배열로 저장
category?: Array<{
id: string;
category: "simple-key" | "data-save" | "external-call";
}>;
// 🔥 전체 실행 조건 - relationships의 id와 동일한 id 사용
control?: Array<{
id: string; // relationships의 id와 동일
triggerType: "insert" | "update" | "delete";
conditions?: Array<{
field: string;
operator: "=" | "!=" | ">" | "<" | ">=" | "<=" | "LIKE";
value: unknown;
logicalOperator?: "AND" | "OR";
}>;
}>;
// 🔥 저장 액션 - relationships의 id와 동일한 id 사용
plan?: Array<{
id: string; // relationships의 id와 동일
sourceTable: string;
actions: Array<{
id: string;
name: string;
actionType: "insert" | "update" | "delete" | "upsert";
fieldMappings: Array<{
sourceTable?: string;
sourceField: string;
targetTable?: string;
targetField: string;
defaultValue?: string;
transformFunction?: string;
}>;
conditions?: Array<{
id: string;
type: string;
field: string;
operator_type: string;
value: unknown;
logicalOperator?: string;
}>;
}>;
}>;
}
export interface JsonDataFlowDiagramsResponse {