데이터 저장 방식을 json으로 변경

This commit is contained in:
hyeonsu
2025-09-10 15:30:14 +09:00
parent 0a8413ee8c
commit 1b7bdab4c6
14 changed files with 2014 additions and 268 deletions

View File

@@ -151,84 +151,73 @@ export const ConnectionSetupModal: React.FC<ConnectionSetupModalProps> = ({
}
}, [isOpen, connection]);
const handleConfirm = async () => {
const handleConfirm = () => {
if (!config.relationshipName || !connection) {
toast.error("필수 정보를 모두 입력해주세요.");
return;
}
try {
// 연결 종류별 설정을 준비
let settings = {};
// 연결 종류별 설정을 준비
let settings = {};
switch (config.connectionType) {
case "simple-key":
settings = simpleKeySettings;
break;
case "data-save":
settings = dataSaveSettings;
break;
case "external-call":
settings = externalCallSettings;
break;
}
// 선택된 컬럼들 추출
const selectedColumnsData = connection.selectedColumnsData || {};
const tableNames = Object.keys(selectedColumnsData);
const fromTable = tableNames[0];
const toTable = tableNames[1];
const fromColumns = selectedColumnsData[fromTable]?.columns || [];
const toColumns = selectedColumnsData[toTable]?.columns || [];
if (fromColumns.length === 0 || toColumns.length === 0) {
toast.error("선택된 컬럼이 없습니다.");
return;
}
toast.loading("관계를 생성하고 있습니다...", { id: "create-relationship" });
// 단일 관계 데이터 준비 (모든 선택된 컬럼 정보 포함)
// API 요청용 데이터 (camelCase)
const apiRequestData = {
...(diagramId && diagramId > 0 ? { diagramId: diagramId } : {}), // diagramId가 유효할 때만 추가
relationshipName: config.relationshipName,
fromTableName: connection.fromNode.tableName,
fromColumnName: fromColumns.join(","), // 여러 컬럼을 콤마로 구분
toTableName: connection.toNode.tableName,
toColumnName: toColumns.join(","), // 여러 컬럼을 콤마로 구분
relationshipType: config.relationshipType,
connectionType: config.connectionType,
companyCode: companyCode,
settings: {
...settings,
multiColumnMapping: {
fromColumns: fromColumns,
toColumns: toColumns,
fromTable: selectedColumnsData[fromTable]?.displayName || fromTable,
toTable: selectedColumnsData[toTable]?.displayName || toTable,
},
isMultiColumn: fromColumns.length > 1 || toColumns.length > 1,
columnCount: {
from: fromColumns.length,
to: toColumns.length,
},
},
};
// API 호출
const createdRelationship = await DataFlowAPI.createRelationship(apiRequestData as any);
toast.success("관계가 성공적으로 생성되었습니다!", { id: "create-relationship" });
// 성공 콜백 호출
onConfirm(createdRelationship);
handleCancel(); // 모달 닫기
} catch (error) {
console.error("관계 생성 오류:", error);
toast.error("관계 생성에 실패했습니다. 다시 시도해주세요.", { id: "create-relationship" });
switch (config.connectionType) {
case "simple-key":
settings = simpleKeySettings;
break;
case "data-save":
settings = dataSaveSettings;
break;
case "external-call":
settings = externalCallSettings;
break;
}
// 선택된 컬럼들 추출
const selectedColumnsData = connection.selectedColumnsData || {};
const tableNames = Object.keys(selectedColumnsData);
const fromTable = tableNames[0];
const toTable = tableNames[1];
const fromColumns = selectedColumnsData[fromTable]?.columns || [];
const toColumns = selectedColumnsData[toTable]?.columns || [];
if (fromColumns.length === 0 || toColumns.length === 0) {
toast.error("선택된 컬럼이 없습니다.");
return;
}
// 메모리 기반 시스템: 관계 데이터만 생성하여 부모로 전달
const relationshipData: TableRelationship = {
relationship_name: config.relationshipName,
from_table_name: connection.fromNode.tableName,
to_table_name: connection.toNode.tableName,
from_column_name: fromColumns.join(","), // 여러 컬럼을 콤마로 구분
to_column_name: toColumns.join(","), // 여러 컬럼을 콤마로 구분
relationship_type: config.relationshipType as any,
connection_type: config.connectionType as any,
company_code: companyCode,
settings: {
...settings,
description: config.description,
multiColumnMapping: {
fromColumns: fromColumns,
toColumns: toColumns,
fromTable: selectedColumnsData[fromTable]?.displayName || fromTable,
toTable: selectedColumnsData[toTable]?.displayName || toTable,
},
isMultiColumn: fromColumns.length > 1 || toColumns.length > 1,
columnCount: {
from: fromColumns.length,
to: toColumns.length,
},
},
};
toast.success("관계가 생성되었습니다!");
// 부모 컴포넌트로 관계 데이터 전달 (DB 저장 없이)
onConfirm(relationshipData);
handleCancel(); // 모달 닫기
};
const handleCancel = () => {

View File

@@ -17,7 +17,16 @@ import "@xyflow/react/dist/style.css";
import { TableNode } from "./TableNode";
import { TableSelector } from "./TableSelector";
import { ConnectionSetupModal } from "./ConnectionSetupModal";
import { TableDefinition, TableRelationship, DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
import {
TableDefinition,
TableRelationship,
DataFlowAPI,
DataFlowDiagram,
JsonRelationship,
CreateDiagramRequest,
} from "@/lib/api/dataflow";
import SaveDiagramModal from "./SaveDiagramModal";
import { useAuth } from "@/hooks/useAuth";
// 고유 ID 생성 함수
const generateUniqueId = (prefix: string, diagramId?: number): string => {
@@ -61,13 +70,18 @@ interface DataFlowDesignerProps {
// TableRelationship 타입은 dataflow.ts에서 import
export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
companyCode = "*",
companyCode: propCompanyCode = "*",
diagramId,
relationshipId, // 하위 호환성 유지
onSave,
onSave, // eslint-disable-line @typescript-eslint/no-unused-vars
selectedDiagram,
onBackToList, // eslint-disable-line @typescript-eslint/no-unused-vars
}) => {
const { user } = useAuth();
// 실제 사용자 회사 코드 사용 (prop보다 사용자 정보 우선)
const companyCode = user?.company_code || user?.companyCode || propCompanyCode;
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TableNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [selectedColumns, setSelectedColumns] = useState<{
@@ -89,9 +103,46 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
} | null>(null);
const [relationships, setRelationships] = useState<TableRelationship[]>([]); // eslint-disable-line @typescript-eslint/no-unused-vars
const [currentDiagramId, setCurrentDiagramId] = useState<number | null>(null); // 현재 화면의 diagram_id
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<any | null>(null); // 선택된 엣지 정보
const [selectedEdgeInfo, setSelectedEdgeInfo] = useState<{
relationshipId: string;
relationshipName: string;
fromTable: string;
toTable: string;
fromColumns: string[];
toColumns: string[];
relationshipType: string;
connectionType: string;
connectionInfo: string;
} | null>(null); // 선택된 엣지 정보
// 새로운 메모리 기반 상태들
const [tempRelationships, setTempRelationships] = useState<JsonRelationship[]>([]); // 메모리에 저장된 관계들
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); // 저장되지 않은 변경사항
const [showSaveModal, setShowSaveModal] = useState(false); // 저장 모달 표시 상태
const [isSaving, setIsSaving] = useState(false); // 저장 중 상태
const [currentDiagramName, setCurrentDiagramName] = useState<string>(""); // 현재 편집 중인 관계도 이름
const toastShownRef = useRef(false); // eslint-disable-line @typescript-eslint/no-unused-vars
// 편집 모드일 때 관계도 이름 로드
useEffect(() => {
const loadDiagramName = async () => {
if (diagramId && diagramId > 0) {
try {
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(diagramId, companyCode);
if (jsonDiagram && jsonDiagram.diagram_name) {
setCurrentDiagramName(jsonDiagram.diagram_name);
}
} catch (error) {
console.error("관계도 이름 로드 실패:", error);
}
} else {
setCurrentDiagramName(""); // 신규 생성 모드
}
};
loadDiagramName();
}, [diagramId, companyCode]);
// 키보드 이벤트 핸들러 (Del 키로 선택된 노드 삭제)
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
@@ -177,38 +228,28 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
if (!currentDiagramId || isNaN(currentDiagramId)) return;
try {
console.log("🔍 관계도 로드 시작 (diagramId):", currentDiagramId);
console.log("🔍 JSON 관계도 로드 시작 (diagramId):", currentDiagramId);
toast.loading("관계도를 불러오는 중...", { id: "load-diagram" });
// diagramId로 해당 관계도의 모든 관계 조회
const diagramRelationships = await DataFlowAPI.getDiagramRelationshipsByDiagramId(currentDiagramId);
console.log("📋 관계도 관계 데이터:", diagramRelationships);
// 새로운 JSON API로 관계도 조회
const jsonDiagram = await DataFlowAPI.getJsonDataFlowDiagramById(currentDiagramId);
console.log("📋 JSON 관계 데이터:", jsonDiagram);
if (!Array.isArray(diagramRelationships)) {
throw new Error("관계도 데이터 형식이 올바르지 않습니다.");
if (!jsonDiagram || !jsonDiagram.relationships) {
throw new Error("관계도 데이터를 찾을 수 없습니다.");
}
console.log("📋 첫 번째 관계 상세:", diagramRelationships[0]);
console.log(
"📋 관계 객체 키들:",
diagramRelationships[0] ? Object.keys(diagramRelationships[0]) : "배열이 비어있음",
);
setRelationships(diagramRelationships);
const relationships = jsonDiagram.relationships.relationships || [];
const tableNames = jsonDiagram.relationships.tables || [];
// 현재 diagram_id 설정 (기존 관계도 편집 시)
if (diagramRelationships.length > 0) {
setCurrentDiagramId(diagramRelationships[0].diagram_id || null);
}
console.log("📋 관계 목록:", relationships);
console.log("📊 테이블 목록:", tableNames);
// 관계도의 모든 테이블 추출
const tableNames = new Set<string>();
diagramRelationships.forEach((rel) => {
if (rel && rel.from_table_name && rel.to_table_name) {
tableNames.add(rel.from_table_name);
tableNames.add(rel.to_table_name);
}
});
console.log("📊 추출된 테이블 이름들:", Array.from(tableNames));
// 메모리에 관계 저장 (기존 관계도 편집 시)
setTempRelationships(relationships);
setCurrentDiagramId(currentDiagramId);
// 테이블 노드 생성을 위한 테이블 정보 로드
// 테이블 정보 로드
const allTables = await DataFlowAPI.getTables();
@@ -239,26 +280,15 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
[tableName: string]: { [columnName: string]: { direction: "source" | "target" | "both" } };
} = {};
diagramRelationships.forEach((rel) => {
if (!rel || !rel.from_table_name || !rel.to_table_name || !rel.from_column_name || !rel.to_column_name) {
console.warn("⚠️ 관계 데이터가 불완전합니다:", rel);
return;
}
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
const toColumns = rel.to_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
relationships.forEach((rel: JsonRelationship) => {
const fromTable = rel.fromTable;
const toTable = rel.toTable;
const fromColumns = rel.fromColumns || [];
const toColumns = rel.toColumns || [];
// 소스 테이블의 컬럼들을 source로 표시
if (!connectedColumnsInfo[fromTable]) connectedColumnsInfo[fromTable] = {};
fromColumns.forEach((col) => {
fromColumns.forEach((col: string) => {
if (connectedColumnsInfo[fromTable][col]) {
connectedColumnsInfo[fromTable][col].direction = "both";
} else {
@@ -268,7 +298,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
// 타겟 테이블의 컬럼들을 target으로 표시
if (!connectedColumnsInfo[toTable]) connectedColumnsInfo[toTable] = {};
toColumns.forEach((col) => {
toColumns.forEach((col: string) => {
if (connectedColumnsInfo[toTable][col]) {
connectedColumnsInfo[toTable][col].direction = "both";
} else {
@@ -312,25 +342,14 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
console.log("📍 테이블 노드 상세:", tableNodes);
setNodes(tableNodes);
// 관계를 엣지로 변환하여 표시 (테이블 간 번들 연결)
// JSON 관계를 엣지로 변환하여 표시 (테이블 간 번들 연결)
const relationshipEdges: Edge[] = [];
diagramRelationships.forEach((rel) => {
if (!rel || !rel.from_table_name || !rel.to_table_name || !rel.from_column_name || !rel.to_column_name) {
console.warn("⚠️ 에지 생성 시 관계 데이터가 불완전합니다:", rel);
return;
}
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
const toColumns = rel.to_column_name
.split(",")
.map((col) => col.trim())
.filter((col) => col);
relationships.forEach((rel: JsonRelationship) => {
const fromTable = rel.fromTable;
const toTable = rel.toTable;
const fromColumns = rel.fromColumns || [];
const toColumns = rel.toColumns || [];
if (fromColumns.length === 0 || toColumns.length === 0) {
console.warn("⚠️ 컬럼 정보가 없습니다:", { fromColumns, toColumns });
@@ -339,7 +358,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
// 테이블 간 하나의 번들 엣지 생성 (컬럼별 개별 엣지 대신)
relationshipEdges.push({
id: generateUniqueId("edge", rel.diagram_id),
id: generateUniqueId("edge", currentDiagramId),
source: `table-${fromTable}`,
target: `table-${toTable}`,
type: "smoothstep",
@@ -350,10 +369,10 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
strokeDasharray: "none",
},
data: {
relationshipId: rel.relationship_id,
relationshipName: rel.relationship_name,
relationshipType: rel.relationship_type,
connectionType: rel.connection_type,
relationshipId: rel.id,
relationshipName: "기존 관계",
relationshipType: rel.relationshipType,
connectionType: rel.connectionType,
fromTable: fromTable,
toTable: toTable,
fromColumns: fromColumns,
@@ -361,8 +380,8 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
// 클릭 시 표시할 상세 정보
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
relationshipType: rel.relationship_type,
connectionType: rel.connection_type,
relationshipType: rel.relationshipType,
connectionType: rel.connectionType,
},
},
});
@@ -383,57 +402,17 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
if (selectedDiagram) return; // 선택된 관계도가 있으면 실행하지 않음
try {
const existingRelationships = await DataFlowAPI.getRelationshipsByCompany(companyCode);
setRelationships(existingRelationships);
// 새로운 JSON 기반 시스템에서는 기존 관계를 미리 로드하지 않음
console.log("새 관계도 생성 모드: 빈 캔버스로 시작");
setRelationships([]);
// 기존 관계를 엣지로 변환하여 표시 (컬럼별 연결)
const existingEdges: Edge[] = [];
existingRelationships.forEach((rel) => {
const fromTable = rel.from_table_name;
const toTable = rel.to_table_name;
const fromColumns = rel.from_column_name.split(",").map((col) => col.trim());
const toColumns = rel.to_column_name.split(",").map((col) => col.trim());
// 각 from 컬럼을 각 to 컬럼에 연결
const maxConnections = Math.max(fromColumns.length, toColumns.length);
for (let i = 0; i < maxConnections; i++) {
const fromColumn = fromColumns[i] || fromColumns[0];
const toColumn = toColumns[i] || toColumns[0];
existingEdges.push({
id: generateUniqueId("edge", rel.diagram_id),
source: `table-${fromTable}`,
target: `table-${toTable}`,
sourceHandle: `${fromTable}-${fromColumn}-source`,
targetHandle: `${toTable}-${toColumn}-target`,
type: "smoothstep",
animated: false,
style: {
stroke: "#3b82f6",
strokeWidth: 1.5,
strokeDasharray: "none",
},
data: {
relationshipId: rel.relationship_id,
relationshipType: rel.relationship_type,
connectionType: rel.connection_type,
fromTable: fromTable,
toTable: toTable,
fromColumn: fromColumn,
toColumn: toColumn,
},
});
}
});
setEdges(existingEdges);
// 빈 캔버스로 시작
setEdges([]);
} catch (error) {
console.error("기존 관계 로드 실패:", error);
toast.error("기존 관계를 불러오는데 실패했습니다.");
}
}, [companyCode, setEdges, selectedDiagram]);
}, [setEdges, selectedDiagram]);
// 컴포넌트 마운트 시 관계 로드
useEffect(() => {
@@ -468,7 +447,21 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
// 엣지 클릭 시 연결 정보 표시
const onEdgeClick = useCallback((event: React.MouseEvent, edge: Edge) => {
event.stopPropagation();
const edgeData = edge.data as any;
const edgeData = edge.data as {
relationshipId: string;
relationshipName: string;
fromTable: string;
toTable: string;
fromColumns: string[];
toColumns: string[];
relationshipType: string;
connectionType: string;
details?: {
connectionInfo: string;
relationshipType: string;
connectionType: string;
};
};
if (edgeData) {
setSelectedEdgeInfo({
relationshipId: edgeData.relationshipId,
@@ -644,33 +637,6 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
[handleColumnClick, selectedColumns, setNodes],
);
// 샘플 테이블 노드 추가 (개발용)
const addSampleNode = useCallback(() => {
const tableName = `sample_table_${nodes.length + 1}`;
const newNode: Node<TableNodeData> = {
id: `sample-${Date.now()}`,
type: "tableNode",
position: { x: Math.random() * 300, y: Math.random() * 200 },
data: {
table: {
tableName,
displayName: `샘플 테이블 ${nodes.length + 1}`,
description: `샘플 테이블 설명 ${nodes.length + 1}`,
columns: [
{ name: "id", type: "INTEGER", description: "고유 식별자" },
{ name: "name", type: "VARCHAR(100)", description: "이름" },
{ name: "code", type: "VARCHAR(50)", description: "코드" },
{ name: "created_date", type: "TIMESTAMP", description: "생성일시" },
],
},
onColumnClick: handleColumnClick,
selectedColumns: selectedColumns[tableName] || [],
},
};
setNodes((nds) => nds.concat(newNode));
}, [nodes.length, handleColumnClick, selectedColumns, setNodes]);
// 노드 전체 삭제
const clearNodes = useCallback(() => {
setNodes([]);
@@ -691,7 +657,7 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
(relationship: TableRelationship) => {
if (!pendingConnection) return;
// 테이블 간 번들 에지 생성 (새 관계 생성 시)
// 메모리 기반 관계 생성 (DB 저장 없이)
const fromTable = relationship.from_table_name;
const toTable = relationship.to_table_name;
const fromColumns = relationship.from_column_name
@@ -703,8 +669,25 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
.map((col) => col.trim())
.filter((col) => col);
// JSON 형태의 관계 객체 생성
const newRelationship: JsonRelationship = {
id: generateUniqueId("rel", Date.now()),
fromTable,
toTable,
fromColumns,
toColumns,
relationshipType: relationship.relationship_type,
connectionType: relationship.connection_type,
settings: relationship.settings || {},
};
// 메모리에 관계 추가
setTempRelationships((prev) => [...prev, newRelationship]);
setHasUnsavedChanges(true);
// 캔버스에 엣지 즉시 표시
const newEdge: Edge = {
id: generateUniqueId("edge", relationship.diagram_id),
id: generateUniqueId("edge", Date.now()),
source: pendingConnection.fromNode.id,
target: pendingConnection.toNode.id,
type: "smoothstep",
@@ -715,15 +698,14 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
strokeDasharray: "none",
},
data: {
relationshipId: relationship.relationship_id,
relationshipName: relationship.relationship_name,
relationshipId: newRelationship.id,
relationshipName: "임시 관계",
relationshipType: relationship.relationship_type,
connectionType: relationship.connection_type,
fromTable: fromTable,
toTable: toTable,
fromColumns: fromColumns,
toColumns: toColumns,
// 클릭 시 표시할 상세 정보
fromTable,
toTable,
fromColumns,
toColumns,
details: {
connectionInfo: `${fromTable}(${fromColumns.join(", ")}) → ${toTable}(${toColumns.join(", ")})`,
relationshipType: relationship.relationship_type,
@@ -733,26 +715,16 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
};
setEdges((eds) => [...eds, newEdge]);
setRelationships((prev) => [...prev, relationship]);
setPendingConnection(null);
// 첫 번째 관계 생성 시 currentDiagramId 설정 (새 관계도 생성 시)
if (!currentDiagramId && relationship.diagram_id) {
setCurrentDiagramId(relationship.diagram_id);
}
// 관계 생성 후 선택된 컬럼들 초기화
setSelectedColumns({});
setSelectionOrder([]);
console.log("관계 생성 완료:", relationship);
// 관계 생성 완료 후 자동으로 목록 새로고침을 위한 콜백 (선택적)
// 렌더링 중 상태 업데이트 방지를 위해 제거
// if (onSave) {
// onSave([...relationships, relationship]);
// }
console.log("메모리에 관계 생성 완료:", newRelationship);
toast.success("관계 생성되었습니다. 저장 버튼을 눌러 관계도를 저장하세요.");
},
[pendingConnection, setEdges, currentDiagramId],
[pendingConnection, setEdges],
);
// 연결 설정 취소
@@ -760,6 +732,84 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
setPendingConnection(null);
}, []);
// 관계도 저장 함수
const handleSaveDiagram = useCallback(
async (diagramName: string) => {
if (tempRelationships.length === 0) {
toast.error("저장할 관계가 없습니다.");
return;
}
setIsSaving(true);
try {
// 연결된 테이블 목록 추출
const connectedTables = Array.from(
new Set([...tempRelationships.map((rel) => rel.fromTable), ...tempRelationships.map((rel) => rel.toTable)]),
).sort();
// 저장 요청 데이터 생성
const createRequest: CreateDiagramRequest = {
diagram_name: diagramName,
relationships: {
relationships: tempRelationships,
tables: connectedTables,
},
};
let savedDiagram;
// 편집 모드 vs 신규 생성 모드 구분
if (diagramId && diagramId > 0) {
// 편집 모드: 기존 관계도 업데이트
savedDiagram = await DataFlowAPI.updateJsonDataFlowDiagram(
diagramId,
createRequest,
companyCode,
user?.userId || "SYSTEM",
);
toast.success(`관계도 "${diagramName}"가 성공적으로 수정되었습니다.`);
} else {
// 신규 생성 모드: 새로운 관계도 생성
savedDiagram = await DataFlowAPI.createJsonDataFlowDiagram(
createRequest,
companyCode,
user?.userId || "SYSTEM",
);
toast.success(`관계도 "${diagramName}"가 성공적으로 생성되었습니다.`);
}
// 성공 처리
setHasUnsavedChanges(false);
setShowSaveModal(false);
setCurrentDiagramId(savedDiagram.diagram_id);
console.log("관계도 저장 완료:", savedDiagram);
} catch (error) {
console.error("관계도 저장 실패:", error);
toast.error("관계도 저장 중 오류가 발생했습니다.");
} finally {
setIsSaving(false);
}
},
[tempRelationships, diagramId, companyCode, user?.userId],
);
// 저장 모달 열기
const handleOpenSaveModal = useCallback(() => {
if (tempRelationships.length === 0) {
toast.error("저장할 관계가 없습니다. 먼저 테이블을 연결해주세요.");
return;
}
setShowSaveModal(true);
}, [tempRelationships.length]);
// 저장 모달 닫기
const handleCloseSaveModal = useCallback(() => {
if (!isSaving) {
setShowSaveModal(false);
}
}, [isSaving]);
return (
<div className="data-flow-designer h-screen bg-gray-100">
<div className="flex h-full">
@@ -777,13 +827,6 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
{/* 컨트롤 버튼들 */}
<div className="space-y-3">
<button
onClick={addSampleNode}
className="w-full rounded-lg bg-gray-500 p-3 font-medium text-white transition-colors hover:bg-gray-600"
>
+ ()
</button>
<button
onClick={clearNodes}
className="w-full rounded-lg bg-red-500 p-3 font-medium text-white transition-colors hover:bg-red-600"
@@ -792,10 +835,13 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
</button>
<button
onClick={() => onSave && onSave([])}
className="w-full rounded-lg bg-green-500 p-3 font-medium text-white transition-colors hover:bg-green-600"
onClick={handleOpenSaveModal}
disabled={tempRelationships.length === 0}
className={`w-full rounded-lg p-3 font-medium text-white transition-colors ${
tempRelationships.length > 0 ? "bg-green-500 hover:bg-green-600" : "cursor-not-allowed bg-gray-400"
} ${hasUnsavedChanges ? "animate-pulse" : ""}`}
>
💾 {tempRelationships.length > 0 && `(${tempRelationships.length})`}
</button>
</div>
@@ -811,10 +857,17 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
<span>:</span>
<span className="font-medium">{edges.length}</span>
</div>
<div className="flex justify-between">
<span> :</span>
<span className="font-medium text-orange-600">{tempRelationships.length}</span>
</div>
<div className="flex justify-between">
<span> ID:</span>
<span className="font-medium">{currentDiagramId || "미설정"}</span>
</div>
{hasUnsavedChanges && (
<div className="mt-2 text-xs font-medium text-orange-600"> </div>
)}
</div>
</div>
@@ -979,6 +1032,20 @@ export const DataFlowDesigner: React.FC<DataFlowDesignerProps> = ({
onConfirm={handleConfirmConnection}
onCancel={handleCancelConnection}
/>
{/* 관계도 저장 모달 */}
<SaveDiagramModal
isOpen={showSaveModal}
onClose={handleCloseSaveModal}
onSave={handleSaveDiagram}
relationships={tempRelationships}
defaultName={
diagramId && diagramId > 0 && currentDiagramName
? currentDiagramName // 편집 모드: 기존 관계도 이름
: `관계도 ${new Date().toLocaleDateString()}` // 신규 생성 모드: 새로운 이름
}
isLoading={isSaving}
/>
</div>
);
};

View File

@@ -23,6 +23,7 @@ import {
import { MoreHorizontal, Trash2, Copy, Plus, Search, Network, Database, Calendar, User } from "lucide-react";
import { DataFlowAPI, DataFlowDiagram } from "@/lib/api/dataflow";
import { toast } from "sonner";
import { useAuth } from "@/hooks/useAuth";
interface DataFlowListProps {
onDiagramSelect: (diagram: DataFlowDiagram) => void;
@@ -31,6 +32,7 @@ interface DataFlowListProps {
}
export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesignDiagram }: DataFlowListProps) {
const { user } = useAuth();
const [diagrams, setDiagrams] = useState<DataFlowDiagram[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState("");
@@ -38,6 +40,9 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
// 사용자 회사 코드 가져오기 (기본값: "*")
const companyCode = user?.company_code || user?.companyCode || "*";
// 모달 상태
const [showCopyModal, setShowCopyModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
@@ -47,17 +52,35 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
const loadDiagrams = useCallback(async () => {
try {
setLoading(true);
const response = await DataFlowAPI.getDataFlowDiagrams(currentPage, 20, searchTerm);
setDiagrams(response.diagrams || []);
setTotal(response.total || 0);
setTotalPages(Math.max(1, Math.ceil((response.total || 0) / 20)));
const response = await DataFlowAPI.getJsonDataFlowDiagrams(currentPage, 20, searchTerm, companyCode);
// JSON API 응답을 기존 형식으로 변환
const convertedDiagrams = response.diagrams.map((diagram) => ({
diagramId: diagram.diagram_id,
relationshipId: diagram.diagram_id, // 호환성을 위해 추가
diagramName: diagram.diagram_name,
connectionType: "json-based", // 새로운 JSON 기반 타입
relationshipType: "multi-relationship", // 다중 관계 타입
relationshipCount: diagram.relationships?.relationships?.length || 0,
tableCount: diagram.relationships?.tables?.length || 0,
tables: diagram.relationships?.tables || [],
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);
setTotal(response.pagination.total || 0);
setTotalPages(Math.max(1, Math.ceil((response.pagination.total || 0) / 20)));
} catch (error) {
console.error("관계도 목록 조회 실패", error);
toast.error("관계도 목록을 불러오는데 실패했습니다.");
} finally {
setLoading(false);
}
}, [currentPage, searchTerm]);
}, [currentPage, searchTerm, companyCode]);
// 관계도 목록 로드
useEffect(() => {
@@ -84,8 +107,13 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
try {
setLoading(true);
const newDiagramName = await DataFlowAPI.copyDiagram(selectedDiagramForAction.diagramName);
toast.success(`관계도가 성공적으로 복사되었습니다: ${newDiagramName}`);
const copiedDiagram = await DataFlowAPI.copyJsonDataFlowDiagram(
selectedDiagramForAction.diagramId,
companyCode,
undefined,
user?.userId || "SYSTEM",
);
toast.success(`관계도가 성공적으로 복사되었습니다: ${copiedDiagram.diagram_name}`);
// 목록 새로고침
await loadDiagrams();
@@ -105,8 +133,8 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
try {
setLoading(true);
const deletedCount = await DataFlowAPI.deleteDiagram(selectedDiagramForAction.diagramName);
toast.success(`관계도가 삭제되었습니다 (${deletedCount}개 관계 삭제)`);
await DataFlowAPI.deleteJsonDataFlowDiagram(selectedDiagramForAction.diagramId, companyCode);
toast.success(`관계도가 삭제되었습니다: ${selectedDiagramForAction.diagramName}`);
// 목록 새로고침
await loadDiagrams();
@@ -141,6 +169,12 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
</Badge>
);
case "json-based":
return (
<Badge variant="outline" className="border-indigo-200 bg-indigo-50 text-indigo-700">
JSON
</Badge>
);
default:
return <Badge variant="outline">{connectionType}</Badge>;
}
@@ -173,6 +207,12 @@ export default function DataFlowList({ onDiagramSelect, selectedDiagram, onDesig
N:N
</Badge>
);
case "multi-relationship":
return (
<Badge variant="secondary" className="bg-purple-100 text-purple-700">
</Badge>
);
default:
return <Badge variant="secondary">{relationshipType}</Badge>;
}

View File

@@ -0,0 +1,212 @@
"use client";
import React, { useState, useEffect } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { JsonRelationship } from "@/lib/api/dataflow";
interface SaveDiagramModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (diagramName: string) => void;
relationships: JsonRelationship[];
defaultName?: string;
isLoading?: boolean;
}
const SaveDiagramModal: React.FC<SaveDiagramModalProps> = ({
isOpen,
onClose,
onSave,
relationships,
defaultName = "",
isLoading = false,
}) => {
const [diagramName, setDiagramName] = useState(defaultName);
const [nameError, setNameError] = useState("");
// defaultName이 변경될 때마다 diagramName 업데이트
useEffect(() => {
setDiagramName(defaultName);
}, [defaultName]);
const handleSave = () => {
const trimmedName = diagramName.trim();
if (!trimmedName) {
setNameError("관계도 이름을 입력해주세요.");
return;
}
if (trimmedName.length < 2) {
setNameError("관계도 이름은 2글자 이상이어야 합니다.");
return;
}
if (trimmedName.length > 100) {
setNameError("관계도 이름은 100글자를 초과할 수 없습니다.");
return;
}
setNameError("");
onSave(trimmedName);
};
const handleClose = () => {
if (!isLoading) {
setDiagramName(defaultName);
setNameError("");
onClose();
}
};
const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isLoading) {
handleSave();
}
};
// 관련된 테이블 목록 추출
const connectedTables = Array.from(
new Set([...relationships.map((rel) => rel.fromTable), ...relationships.map((rel) => rel.toTable)]),
).sort();
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-h-[80vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-lg font-semibold">📊 </DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* 관계도 이름 입력 */}
<div className="space-y-2">
<Label htmlFor="diagram-name" className="text-sm font-medium">
*
</Label>
<Input
id="diagram-name"
value={diagramName}
onChange={(e) => {
setDiagramName(e.target.value);
if (nameError) setNameError("");
}}
onKeyPress={handleKeyPress}
placeholder="예: 사용자-부서 관계도"
disabled={isLoading}
className={nameError ? "border-red-500 focus:border-red-500" : ""}
/>
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
</div>
{/* 관계 요약 정보 */}
<div className="grid grid-cols-3 gap-4 rounded-lg bg-gray-50 p-4">
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{relationships.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{connectedTables.length}</div>
<div className="text-sm text-gray-600"> </div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-purple-600">
{relationships.reduce((sum, rel) => sum + rel.fromColumns.length, 0)}
</div>
<div className="text-sm text-gray-600"> </div>
</div>
</div>
{/* 연결된 테이블 목록 */}
{connectedTables.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{connectedTables.map((table) => (
<Badge key={table} variant="outline" className="text-xs">
📋 {table}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* 관계 목록 미리보기 */}
{relationships.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-sm"> </CardTitle>
</CardHeader>
<CardContent>
<div className="max-h-60 space-y-3 overflow-y-auto">
{relationships.map((relationship, index) => (
<div
key={relationship.id || index}
className="flex items-center justify-between rounded-lg border bg-white p-3 hover:bg-gray-50"
>
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<Badge variant="secondary" className="text-xs">
{relationship.relationshipType}
</Badge>
<span className="font-medium">{relationship.fromTable}</span>
<span className="text-gray-500"></span>
<span className="font-medium">{relationship.toTable}</span>
</div>
<div className="mt-1 text-xs text-gray-600">
{relationship.fromColumns.join(", ")} {relationship.toColumns.join(", ")}
</div>
</div>
<Badge variant="outline" className="text-xs">
{relationship.connectionType}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* 관계가 없는 경우 안내 */}
{relationships.length === 0 && (
<div className="py-8 text-center text-gray-500">
<div className="mb-2 text-4xl">📭</div>
<div className="text-sm"> .</div>
<div className="mt-1 text-xs text-gray-400"> .</div>
</div>
)}
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleClose} disabled={isLoading}>
</Button>
<Button
onClick={handleSave}
disabled={isLoading || relationships.length === 0}
className="bg-blue-600 hover:bg-blue-700"
>
{isLoading ? (
<div className="flex items-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
...
</div>
) : (
"💾 저장하기"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default SaveDiagramModal;