Merge remote-tracking branch 'upstream/main'
This commit is contained in:
commit
69964c8abe
|
|
@ -236,11 +236,15 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
if (fieldMap[searchField as string]) {
|
if (fieldMap[searchField as string]) {
|
||||||
if (searchField === "tel") {
|
if (searchField === "tel") {
|
||||||
whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`);
|
whereConditions.push(
|
||||||
|
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
|
||||||
|
);
|
||||||
queryParams.push(`%${searchValue}%`);
|
queryParams.push(`%${searchValue}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
} else {
|
} else {
|
||||||
whereConditions.push(`${fieldMap[searchField as string]} ILIKE $${paramIndex}`);
|
whereConditions.push(
|
||||||
|
`${fieldMap[searchField as string]} ILIKE $${paramIndex}`
|
||||||
|
);
|
||||||
queryParams.push(`%${searchValue}%`);
|
queryParams.push(`%${searchValue}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
@ -271,7 +275,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
|
||||||
// 전화번호 검색
|
// 전화번호 검색
|
||||||
if (search_tel && typeof search_tel === "string" && search_tel.trim()) {
|
if (search_tel && typeof search_tel === "string" && search_tel.trim()) {
|
||||||
whereConditions.push(`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`);
|
whereConditions.push(
|
||||||
|
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
|
||||||
|
);
|
||||||
queryParams.push(`%${search_tel.trim()}%`);
|
queryParams.push(`%${search_tel.trim()}%`);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
hasAdvancedSearch = true;
|
hasAdvancedSearch = true;
|
||||||
|
|
@ -305,9 +311,10 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0
|
const whereClause =
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
whereConditions.length > 0
|
||||||
: "";
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
// 총 개수 조회
|
// 총 개수 조회
|
||||||
const countQuery = `
|
const countQuery = `
|
||||||
|
|
@ -345,7 +352,11 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const users = await query<any>(usersQuery, [...queryParams, Number(countPerPage), offset]);
|
const users = await query<any>(usersQuery, [
|
||||||
|
...queryParams,
|
||||||
|
Number(countPerPage),
|
||||||
|
offset,
|
||||||
|
]);
|
||||||
|
|
||||||
// 응답 데이터 가공
|
// 응답 데이터 가공
|
||||||
const processedUsers = users.map((user) => ({
|
const processedUsers = users.map((user) => ({
|
||||||
|
|
@ -365,7 +376,9 @@ export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
status: user.status || "active",
|
status: user.status || "active",
|
||||||
companyCode: user.company_code || null,
|
companyCode: user.company_code || null,
|
||||||
locale: user.locale || null,
|
locale: user.locale || null,
|
||||||
regDate: user.regdate ? new Date(user.regdate).toISOString().split("T")[0] : null,
|
regDate: user.regdate
|
||||||
|
? new Date(user.regdate).toISOString().split("T")[0]
|
||||||
|
: null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const response = {
|
const response = {
|
||||||
|
|
@ -498,10 +511,10 @@ export const setUserLocale = async (
|
||||||
}
|
}
|
||||||
|
|
||||||
// Raw Query로 사용자 로케일 저장
|
// Raw Query로 사용자 로케일 저장
|
||||||
await query(
|
await query("UPDATE user_info SET locale = $1 WHERE user_id = $2", [
|
||||||
"UPDATE user_info SET locale = $1 WHERE user_id = $2",
|
locale,
|
||||||
[locale, req.user.userId]
|
req.user.userId,
|
||||||
);
|
]);
|
||||||
|
|
||||||
logger.info("사용자 로케일을 데이터베이스에 저장 완료", {
|
logger.info("사용자 로케일을 데이터베이스에 저장 완료", {
|
||||||
locale,
|
locale,
|
||||||
|
|
@ -680,9 +693,13 @@ export async function getLangKeyList(
|
||||||
langKey: row.lang_key,
|
langKey: row.lang_key,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
isActive: row.is_active,
|
isActive: row.is_active,
|
||||||
createdDate: row.created_date ? new Date(row.created_date).toISOString() : null,
|
createdDate: row.created_date
|
||||||
|
? new Date(row.created_date).toISOString()
|
||||||
|
: null,
|
||||||
createdBy: row.created_by,
|
createdBy: row.created_by,
|
||||||
updatedDate: row.updated_date ? new Date(row.updated_date).toISOString() : null,
|
updatedDate: row.updated_date
|
||||||
|
? new Date(row.updated_date).toISOString()
|
||||||
|
: null,
|
||||||
updatedBy: row.updated_by,
|
updatedBy: row.updated_by,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -1008,8 +1025,20 @@ export async function saveMenu(
|
||||||
const menuData = req.body;
|
const menuData = req.body;
|
||||||
logger.info("메뉴 저장 요청", { menuData, user: req.user });
|
logger.info("메뉴 저장 요청", { menuData, user: req.user });
|
||||||
|
|
||||||
|
// 사용자의 company_code 확인
|
||||||
|
if (!req.user?.companyCode) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자의 회사 코드를 찾을 수 없습니다.",
|
||||||
|
error: "Missing company_code",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 저장
|
// Raw Query를 사용한 메뉴 저장
|
||||||
const objid = Date.now(); // 고유 ID 생성
|
const objid = Date.now(); // 고유 ID 생성
|
||||||
|
const companyCode = req.user.companyCode;
|
||||||
|
|
||||||
const [savedMenu] = await query<any>(
|
const [savedMenu] = await query<any>(
|
||||||
`INSERT INTO menu_info (
|
`INSERT INTO menu_info (
|
||||||
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
|
||||||
|
|
@ -1030,7 +1059,7 @@ export async function saveMenu(
|
||||||
new Date(),
|
new Date(),
|
||||||
menuData.status || "active",
|
menuData.status || "active",
|
||||||
menuData.systemName || null,
|
menuData.systemName || null,
|
||||||
menuData.companyCode || "*",
|
companyCode,
|
||||||
menuData.langKey || null,
|
menuData.langKey || null,
|
||||||
menuData.langKeyDesc || null,
|
menuData.langKeyDesc || null,
|
||||||
]
|
]
|
||||||
|
|
@ -1079,6 +1108,18 @@ export async function updateMenu(
|
||||||
user: req.user,
|
user: req.user,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 사용자의 company_code 확인
|
||||||
|
if (!req.user?.companyCode) {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: "사용자의 회사 코드를 찾을 수 없습니다.",
|
||||||
|
error: "Missing company_code",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyCode = req.user.companyCode;
|
||||||
|
|
||||||
// Raw Query를 사용한 메뉴 수정
|
// Raw Query를 사용한 메뉴 수정
|
||||||
const [updatedMenu] = await query<any>(
|
const [updatedMenu] = await query<any>(
|
||||||
`UPDATE menu_info SET
|
`UPDATE menu_info SET
|
||||||
|
|
@ -1106,7 +1147,7 @@ export async function updateMenu(
|
||||||
menuData.menuDesc || null,
|
menuData.menuDesc || null,
|
||||||
menuData.status || "active",
|
menuData.status || "active",
|
||||||
menuData.systemName || null,
|
menuData.systemName || null,
|
||||||
menuData.companyCode || "*",
|
companyCode,
|
||||||
menuData.langKey || null,
|
menuData.langKey || null,
|
||||||
menuData.langKeyDesc || null,
|
menuData.langKeyDesc || null,
|
||||||
Number(menuId),
|
Number(menuId),
|
||||||
|
|
@ -1356,9 +1397,10 @@ export const getDepartmentList = async (
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
const whereClause = whereConditions.length > 0
|
const whereClause =
|
||||||
? `WHERE ${whereConditions.join(" AND ")}`
|
whereConditions.length > 0
|
||||||
: "";
|
? `WHERE ${whereConditions.join(" AND ")}`
|
||||||
|
: "";
|
||||||
|
|
||||||
const departments = await query<any>(
|
const departments = await query<any>(
|
||||||
`SELECT
|
`SELECT
|
||||||
|
|
@ -1970,7 +2012,9 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
|
||||||
);
|
);
|
||||||
|
|
||||||
// 기존 사용자인지 새 사용자인지 확인 (regdate로 판단)
|
// 기존 사용자인지 새 사용자인지 확인 (regdate로 판단)
|
||||||
const isUpdate = savedUser.regdate && new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
const isUpdate =
|
||||||
|
savedUser.regdate &&
|
||||||
|
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
|
||||||
|
|
||||||
logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", {
|
logger.info(isUpdate ? "사용자 정보 수정 완료" : "새 사용자 등록 완료", {
|
||||||
userId: userData.userId,
|
userId: userData.userId,
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,42 @@ router.get(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/external-db-connections/pool-status
|
||||||
|
* 연결 풀 상태 조회
|
||||||
|
*/
|
||||||
|
router.get(
|
||||||
|
"/pool-status",
|
||||||
|
authenticateToken,
|
||||||
|
async (req: AuthenticatedRequest, res: Response) => {
|
||||||
|
try {
|
||||||
|
const { ExternalDbConnectionPoolService } = await import(
|
||||||
|
"../services/externalDbConnectionPoolService"
|
||||||
|
);
|
||||||
|
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||||
|
const poolsStatus = poolService.getPoolsStatus();
|
||||||
|
|
||||||
|
return res.status(200).json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalPools: poolsStatus.length,
|
||||||
|
activePools: poolsStatus.filter((p) => p.activeConnections > 0)
|
||||||
|
.length,
|
||||||
|
pools: poolsStatus,
|
||||||
|
},
|
||||||
|
message: `${poolsStatus.length}개의 연결 풀 상태를 조회했습니다.`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("연결 풀 상태 조회 오류:", error);
|
||||||
|
return res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "서버 내부 오류가 발생했습니다.",
|
||||||
|
error: error instanceof Error ? error.message : "알 수 없는 오류",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/external-db-connections/grouped
|
* GET /api/external-db-connections/grouped
|
||||||
* DB 타입별로 그룹화된 외부 DB 연결 목록 조회
|
* DB 타입별로 그룹화된 외부 DB 연결 목록 조회
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,436 @@
|
||||||
|
// 외부 DB 연결 풀 관리 서비스
|
||||||
|
// 작성일: 2025-01-13
|
||||||
|
// 연결 풀 고갈 방지를 위한 중앙 관리 시스템
|
||||||
|
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import mysql from "mysql2/promise";
|
||||||
|
import { ExternalDbConnection } from "../types/externalDbTypes";
|
||||||
|
import { ExternalDbConnectionService } from "./externalDbConnectionService";
|
||||||
|
import { PasswordEncryption } from "../utils/passwordEncryption";
|
||||||
|
import logger from "../utils/logger";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 래퍼 인터페이스
|
||||||
|
* 모든 DB 타입의 연결 풀을 통일된 방식으로 관리
|
||||||
|
*/
|
||||||
|
interface ConnectionPoolWrapper {
|
||||||
|
pool: any; // 실제 연결 풀 객체
|
||||||
|
dbType: string;
|
||||||
|
connectionId: number;
|
||||||
|
createdAt: Date;
|
||||||
|
lastUsedAt: Date;
|
||||||
|
activeConnections: number;
|
||||||
|
maxConnections: number;
|
||||||
|
|
||||||
|
// 통일된 쿼리 실행 인터페이스
|
||||||
|
query(sql: string, params?: any[]): Promise<any>;
|
||||||
|
|
||||||
|
// 연결 풀 종료
|
||||||
|
disconnect(): Promise<void>;
|
||||||
|
|
||||||
|
// 연결 풀 상태 확인
|
||||||
|
isHealthy(): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PostgreSQL 연결 풀 래퍼
|
||||||
|
*/
|
||||||
|
class PostgresPoolWrapper implements ConnectionPoolWrapper {
|
||||||
|
pool: Pool;
|
||||||
|
dbType = "postgresql";
|
||||||
|
connectionId: number;
|
||||||
|
createdAt: Date;
|
||||||
|
lastUsedAt: Date;
|
||||||
|
activeConnections = 0;
|
||||||
|
maxConnections: number;
|
||||||
|
|
||||||
|
constructor(config: ExternalDbConnection) {
|
||||||
|
this.connectionId = config.id!;
|
||||||
|
this.createdAt = new Date();
|
||||||
|
this.lastUsedAt = new Date();
|
||||||
|
this.maxConnections = config.max_connections || 10;
|
||||||
|
|
||||||
|
this.pool = new Pool({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
database: config.database_name,
|
||||||
|
user: config.username,
|
||||||
|
password: config.password,
|
||||||
|
max: this.maxConnections,
|
||||||
|
min: 2, // 최소 연결 수
|
||||||
|
idleTimeoutMillis: 30000, // 30초 동안 사용되지 않으면 연결 해제
|
||||||
|
connectionTimeoutMillis: (config.connection_timeout || 30) * 1000,
|
||||||
|
statement_timeout: (config.query_timeout || 60) * 1000,
|
||||||
|
ssl: config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결 풀 이벤트 리스너
|
||||||
|
this.pool.on("connect", () => {
|
||||||
|
this.activeConnections++;
|
||||||
|
logger.debug(
|
||||||
|
`[PostgreSQL] 새 연결 생성 (${this.activeConnections}/${this.maxConnections})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on("remove", () => {
|
||||||
|
this.activeConnections--;
|
||||||
|
logger.debug(
|
||||||
|
`[PostgreSQL] 연결 제거 (${this.activeConnections}/${this.maxConnections})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on("error", (err) => {
|
||||||
|
logger.error(`[PostgreSQL] 연결 풀 오류:`, err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql: string, params?: any[]): Promise<any> {
|
||||||
|
this.lastUsedAt = new Date();
|
||||||
|
const result = await this.pool.query(sql, params);
|
||||||
|
return result.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
logger.info(`[PostgreSQL] 연결 풀 종료 (ID: ${this.connectionId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy(): boolean {
|
||||||
|
return (
|
||||||
|
this.pool.totalCount > 0 && this.activeConnections < this.maxConnections
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MySQL/MariaDB 연결 풀 래퍼
|
||||||
|
*/
|
||||||
|
class MySQLPoolWrapper implements ConnectionPoolWrapper {
|
||||||
|
pool: mysql.Pool;
|
||||||
|
dbType: string;
|
||||||
|
connectionId: number;
|
||||||
|
createdAt: Date;
|
||||||
|
lastUsedAt: Date;
|
||||||
|
activeConnections = 0;
|
||||||
|
maxConnections: number;
|
||||||
|
|
||||||
|
constructor(config: ExternalDbConnection) {
|
||||||
|
this.connectionId = config.id!;
|
||||||
|
this.dbType = config.db_type;
|
||||||
|
this.createdAt = new Date();
|
||||||
|
this.lastUsedAt = new Date();
|
||||||
|
this.maxConnections = config.max_connections || 10;
|
||||||
|
|
||||||
|
this.pool = mysql.createPool({
|
||||||
|
host: config.host,
|
||||||
|
port: config.port,
|
||||||
|
database: config.database_name,
|
||||||
|
user: config.username,
|
||||||
|
password: config.password,
|
||||||
|
connectionLimit: this.maxConnections,
|
||||||
|
waitForConnections: true,
|
||||||
|
queueLimit: 0,
|
||||||
|
connectTimeout: (config.connection_timeout || 30) * 1000,
|
||||||
|
ssl:
|
||||||
|
config.ssl_enabled === "Y" ? { rejectUnauthorized: false } : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결 획득/해제 이벤트 추적
|
||||||
|
this.pool.on("acquire", () => {
|
||||||
|
this.activeConnections++;
|
||||||
|
logger.debug(
|
||||||
|
`[${this.dbType.toUpperCase()}] 연결 획득 (${this.activeConnections}/${this.maxConnections})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.pool.on("release", () => {
|
||||||
|
this.activeConnections--;
|
||||||
|
logger.debug(
|
||||||
|
`[${this.dbType.toUpperCase()}] 연결 반환 (${this.activeConnections}/${this.maxConnections})`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async query(sql: string, params?: any[]): Promise<any> {
|
||||||
|
this.lastUsedAt = new Date();
|
||||||
|
const [rows] = await this.pool.execute(sql, params);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
async disconnect(): Promise<void> {
|
||||||
|
await this.pool.end();
|
||||||
|
logger.info(
|
||||||
|
`[${this.dbType.toUpperCase()}] 연결 풀 종료 (ID: ${this.connectionId})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
isHealthy(): boolean {
|
||||||
|
return this.activeConnections < this.maxConnections;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 외부 DB 연결 풀 관리자
|
||||||
|
* 싱글톤 패턴으로 구현하여 전역적으로 연결 풀 관리
|
||||||
|
*/
|
||||||
|
export class ExternalDbConnectionPoolService {
|
||||||
|
private static instance: ExternalDbConnectionPoolService;
|
||||||
|
private pools: Map<number, ConnectionPoolWrapper> = new Map();
|
||||||
|
private readonly IDLE_TIMEOUT = 10 * 60 * 1000; // 10분 동안 사용되지 않으면 풀 제거
|
||||||
|
private readonly HEALTH_CHECK_INTERVAL = 60 * 1000; // 1분마다 헬스 체크
|
||||||
|
private healthCheckTimer?: NodeJS.Timeout;
|
||||||
|
|
||||||
|
private constructor() {
|
||||||
|
this.startHealthCheck();
|
||||||
|
logger.info("🔌 외부 DB 연결 풀 서비스 초기화 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 싱글톤 인스턴스 반환
|
||||||
|
*/
|
||||||
|
static getInstance(): ExternalDbConnectionPoolService {
|
||||||
|
if (!ExternalDbConnectionPoolService.instance) {
|
||||||
|
ExternalDbConnectionPoolService.instance =
|
||||||
|
new ExternalDbConnectionPoolService();
|
||||||
|
}
|
||||||
|
return ExternalDbConnectionPoolService.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 가져오기 (없으면 생성)
|
||||||
|
*/
|
||||||
|
async getPool(connectionId: number): Promise<ConnectionPoolWrapper> {
|
||||||
|
// 기존 풀이 있으면 반환
|
||||||
|
if (this.pools.has(connectionId)) {
|
||||||
|
const pool = this.pools.get(connectionId)!;
|
||||||
|
pool.lastUsedAt = new Date();
|
||||||
|
|
||||||
|
// 헬스 체크
|
||||||
|
if (!pool.isHealthy()) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 연결 풀 비정상 감지 (ID: ${connectionId}), 재생성 중...`
|
||||||
|
);
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
return this.createPool(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug(`✅ 기존 연결 풀 재사용 (ID: ${connectionId})`);
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 풀 생성
|
||||||
|
return this.createPool(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 새로운 연결 풀 생성
|
||||||
|
*/
|
||||||
|
private async createPool(
|
||||||
|
connectionId: number
|
||||||
|
): Promise<ConnectionPoolWrapper> {
|
||||||
|
logger.info(`🔧 새 연결 풀 생성 중 (ID: ${connectionId})...`);
|
||||||
|
|
||||||
|
// DB 연결 정보 조회
|
||||||
|
const connectionResult =
|
||||||
|
await ExternalDbConnectionService.getConnectionById(connectionId);
|
||||||
|
|
||||||
|
if (!connectionResult.success || !connectionResult.data) {
|
||||||
|
throw new Error(`연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = connectionResult.data;
|
||||||
|
|
||||||
|
// 비활성화된 연결은 사용 불가
|
||||||
|
if (config.is_active !== "Y") {
|
||||||
|
throw new Error(`비활성화된 연결입니다 (ID: ${connectionId})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 비밀번호 복호화
|
||||||
|
try {
|
||||||
|
config.password = PasswordEncryption.decrypt(config.password);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`비밀번호 복호화 실패 (ID: ${connectionId}):`, error);
|
||||||
|
throw new Error("비밀번호 복호화에 실패했습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB 타입에 따라 적절한 풀 생성
|
||||||
|
let pool: ConnectionPoolWrapper;
|
||||||
|
|
||||||
|
switch (config.db_type.toLowerCase()) {
|
||||||
|
case "postgresql":
|
||||||
|
pool = new PostgresPoolWrapper(config);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "mysql":
|
||||||
|
case "mariadb":
|
||||||
|
pool = new MySQLPoolWrapper(config);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "oracle":
|
||||||
|
case "mssql":
|
||||||
|
// TODO: Oracle과 MSSQL 지원 추가
|
||||||
|
throw new Error(`${config.db_type}는 아직 지원되지 않습니다`);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`지원하지 않는 DB 타입: ${config.db_type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.pools.set(connectionId, pool);
|
||||||
|
logger.info(
|
||||||
|
`✅ 연결 풀 생성 완료 (ID: ${connectionId}, 타입: ${config.db_type}, 최대: ${pool.maxConnections})`
|
||||||
|
);
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 풀 제거
|
||||||
|
*/
|
||||||
|
async removePool(connectionId: number): Promise<void> {
|
||||||
|
const pool = this.pools.get(connectionId);
|
||||||
|
if (pool) {
|
||||||
|
await pool.disconnect();
|
||||||
|
this.pools.delete(connectionId);
|
||||||
|
logger.info(`🗑️ 연결 풀 제거됨 (ID: ${connectionId})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 실행 (자동으로 연결 풀 관리)
|
||||||
|
*/
|
||||||
|
async executeQuery(
|
||||||
|
connectionId: number,
|
||||||
|
sql: string,
|
||||||
|
params?: any[]
|
||||||
|
): Promise<any> {
|
||||||
|
const pool = await this.getPool(connectionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.debug(
|
||||||
|
`📊 쿼리 실행 (ID: ${connectionId}): ${sql.substring(0, 100)}...`
|
||||||
|
);
|
||||||
|
const result = await pool.query(sql, params);
|
||||||
|
logger.debug(
|
||||||
|
`✅ 쿼리 완료 (ID: ${connectionId}), 결과: ${result.length}건`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 쿼리 실행 실패 (ID: ${connectionId}):`, error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 연결 테스트 (풀을 생성하지 않고 단순 연결만 테스트)
|
||||||
|
*/
|
||||||
|
async testConnection(connectionId: number): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const pool = await this.getPool(connectionId);
|
||||||
|
|
||||||
|
// 간단한 쿼리로 연결 테스트
|
||||||
|
const testQuery =
|
||||||
|
pool.dbType === "postgresql" ? "SELECT 1 as test" : "SELECT 1 as test";
|
||||||
|
|
||||||
|
await pool.query(testQuery);
|
||||||
|
logger.info(`✅ 연결 테스트 성공 (ID: ${connectionId})`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`❌ 연결 테스트 실패 (ID: ${connectionId}):`, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 주기적인 헬스 체크 및 유휴 풀 정리
|
||||||
|
*/
|
||||||
|
private startHealthCheck(): void {
|
||||||
|
this.healthCheckTimer = setInterval(() => {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
this.pools.forEach(async (pool, connectionId) => {
|
||||||
|
const idleTime = now - pool.lastUsedAt.getTime();
|
||||||
|
|
||||||
|
// 유휴 시간 초과 시 풀 제거
|
||||||
|
if (idleTime > this.IDLE_TIMEOUT) {
|
||||||
|
logger.info(
|
||||||
|
`🧹 유휴 연결 풀 정리 (ID: ${connectionId}, 유휴: ${Math.round(idleTime / 1000)}초)`
|
||||||
|
);
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 헬스 체크
|
||||||
|
if (!pool.isHealthy()) {
|
||||||
|
logger.warn(
|
||||||
|
`⚠️ 비정상 연결 풀 감지 (ID: ${connectionId}), 재생성 예약`
|
||||||
|
);
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 상태 로깅
|
||||||
|
if (this.pools.size > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`📊 연결 풀 상태: 총 ${this.pools.size}개, 활성: ${Array.from(this.pools.values()).filter((p) => p.activeConnections > 0).length}개`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, this.HEALTH_CHECK_INTERVAL);
|
||||||
|
|
||||||
|
logger.info("🔍 헬스 체크 타이머 시작 (간격: 1분)");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 연결 풀 종료 (애플리케이션 종료 시 호출)
|
||||||
|
*/
|
||||||
|
async closeAll(): Promise<void> {
|
||||||
|
logger.info(`🛑 모든 연결 풀 종료 중... (총 ${this.pools.size}개)`);
|
||||||
|
|
||||||
|
if (this.healthCheckTimer) {
|
||||||
|
clearInterval(this.healthCheckTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const closePromises = Array.from(this.pools.keys()).map((connectionId) =>
|
||||||
|
this.removePool(connectionId)
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(closePromises);
|
||||||
|
logger.info("✅ 모든 연결 풀 종료 완료");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 연결 풀 상태 조회
|
||||||
|
*/
|
||||||
|
getPoolsStatus(): Array<{
|
||||||
|
connectionId: number;
|
||||||
|
dbType: string;
|
||||||
|
activeConnections: number;
|
||||||
|
maxConnections: number;
|
||||||
|
createdAt: Date;
|
||||||
|
lastUsedAt: Date;
|
||||||
|
idleSeconds: number;
|
||||||
|
}> {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
return Array.from(this.pools.entries()).map(([connectionId, pool]) => ({
|
||||||
|
connectionId,
|
||||||
|
dbType: pool.dbType,
|
||||||
|
activeConnections: pool.activeConnections,
|
||||||
|
maxConnections: pool.maxConnections,
|
||||||
|
createdAt: pool.createdAt,
|
||||||
|
lastUsedAt: pool.lastUsedAt,
|
||||||
|
idleSeconds: Math.round((now - pool.lastUsedAt.getTime()) / 1000),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 애플리케이션 종료 시 연결 풀 정리
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
logger.info("🛑 SIGINT 신호 수신, 연결 풀 정리 중...");
|
||||||
|
await ExternalDbConnectionPoolService.getInstance().closeAll();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on("SIGTERM", async () => {
|
||||||
|
logger.info("🛑 SIGTERM 신호 수신, 연결 풀 정리 중...");
|
||||||
|
await ExternalDbConnectionPoolService.getInstance().closeAll();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,491 @@
|
||||||
|
# 외부 DB 연결 풀 관리 가이드
|
||||||
|
|
||||||
|
## 📋 개요
|
||||||
|
|
||||||
|
외부 DB 연결 풀 서비스는 여러 외부 데이터베이스와의 연결을 효율적으로 관리하여 **연결 풀 고갈을 방지**하고 성능을 최적화합니다.
|
||||||
|
|
||||||
|
### 주요 기능
|
||||||
|
|
||||||
|
- ✅ **자동 연결 풀 관리**: 연결 생성, 재사용, 정리 자동화
|
||||||
|
- ✅ **연결 풀 고갈 방지**: 최대 연결 수 제한 및 모니터링
|
||||||
|
- ✅ **유휴 연결 정리**: 10분 이상 사용되지 않은 풀 자동 제거
|
||||||
|
- ✅ **헬스 체크**: 1분마다 모든 풀 상태 검사
|
||||||
|
- ✅ **다중 DB 지원**: PostgreSQL, MySQL, MariaDB
|
||||||
|
- ✅ **싱글톤 패턴**: 전역적으로 단일 인스턴스 사용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ 아키텍처
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ NodeFlowExecutionService │
|
||||||
|
│ (외부 DB 소스/액션 노드) │
|
||||||
|
└──────────────┬──────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ExternalDbConnectionPoolService │
|
||||||
|
│ (싱글톤 인스턴스) │
|
||||||
|
│ │
|
||||||
|
│ ┌─────────────────────────────────┐ │
|
||||||
|
│ │ Connection Pool Map │ │
|
||||||
|
│ │ ┌──────────────────────────┐ │ │
|
||||||
|
│ │ │ ID: 1 → PostgresPool │ │ │
|
||||||
|
│ │ │ ID: 2 → MySQLPool │ │ │
|
||||||
|
│ │ │ ID: 3 → MariaDBPool │ │ │
|
||||||
|
│ │ └──────────────────────────┘ │ │
|
||||||
|
│ └─────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ - 자동 풀 생성/제거 │
|
||||||
|
│ - 헬스 체크 (1분마다) │
|
||||||
|
│ - 유휴 풀 정리 (10분) │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ External Databases │
|
||||||
|
│ - PostgreSQL │
|
||||||
|
│ - MySQL │
|
||||||
|
│ - MariaDB │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 연결 풀 설정
|
||||||
|
|
||||||
|
### PostgreSQL 연결 풀
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
max: 10, // 최대 연결 수
|
||||||
|
min: 2, // 최소 연결 수
|
||||||
|
idleTimeoutMillis: 30000, // 30초 유휴 시 연결 해제
|
||||||
|
connectionTimeoutMillis: 30000, // 연결 타임아웃 30초
|
||||||
|
statement_timeout: 60000, // 쿼리 타임아웃 60초
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MySQL/MariaDB 연결 풀
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
connectionLimit: 10, // 최대 연결 수
|
||||||
|
waitForConnections: true,
|
||||||
|
queueLimit: 0, // 대기열 무제한
|
||||||
|
connectTimeout: 30000, // 연결 타임아웃 30초
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 연결 풀 라이프사이클
|
||||||
|
|
||||||
|
### 1. 풀 생성
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 첫 요청 시 자동 생성
|
||||||
|
const pool = await poolService.getPool(connectionId);
|
||||||
|
```
|
||||||
|
|
||||||
|
**생성 시점**:
|
||||||
|
|
||||||
|
- 외부 DB 소스 노드 첫 실행 시
|
||||||
|
- 외부 DB 액션 노드 첫 실행 시
|
||||||
|
|
||||||
|
**생성 과정**:
|
||||||
|
|
||||||
|
1. DB 연결 정보 조회 (`external_db_connections` 테이블)
|
||||||
|
2. 비밀번호 복호화
|
||||||
|
3. DB 타입에 맞는 연결 풀 생성 (PostgreSQL, MySQL, MariaDB)
|
||||||
|
4. 이벤트 리스너 등록 (연결 획득/해제 추적)
|
||||||
|
|
||||||
|
### 2. 풀 재사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 풀이 있으면 재사용
|
||||||
|
if (this.pools.has(connectionId)) {
|
||||||
|
const pool = this.pools.get(connectionId)!;
|
||||||
|
pool.lastUsedAt = new Date(); // 사용 시간 갱신
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**재사용 조건**:
|
||||||
|
|
||||||
|
- 동일한 `connectionId`로 요청
|
||||||
|
- 풀이 정상 상태 (`isHealthy()` 통과)
|
||||||
|
|
||||||
|
### 3. 자동 정리
|
||||||
|
|
||||||
|
**유휴 시간 초과 (10분)**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const IDLE_TIMEOUT = 10 * 60 * 1000; // 10분
|
||||||
|
|
||||||
|
if (now - pool.lastUsedAt.getTime() > IDLE_TIMEOUT) {
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**헬스 체크 실패**:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (!pool.isHealthy()) {
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 헬스 체크 시스템
|
||||||
|
|
||||||
|
### 주기적 헬스 체크
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const HEALTH_CHECK_INTERVAL = 60 * 1000; // 1분마다
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
this.pools.forEach(async (pool, connectionId) => {
|
||||||
|
// 유휴 시간 체크
|
||||||
|
const idleTime = now - pool.lastUsedAt.getTime();
|
||||||
|
if (idleTime > IDLE_TIMEOUT) {
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 헬스 체크
|
||||||
|
if (!pool.isHealthy()) {
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, HEALTH_CHECK_INTERVAL);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 헬스 체크 조건
|
||||||
|
|
||||||
|
#### PostgreSQL
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
isHealthy(): boolean {
|
||||||
|
return this.pool.totalCount > 0
|
||||||
|
&& this.activeConnections < this.maxConnections;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MySQL/MariaDB
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
isHealthy(): boolean {
|
||||||
|
return this.activeConnections < this.maxConnections;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💻 사용 방법
|
||||||
|
|
||||||
|
### 1. 외부 DB 소스 노드에서 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// nodeFlowExecutionService.ts
|
||||||
|
private static async executeExternalDBSource(
|
||||||
|
node: FlowNode,
|
||||||
|
context: ExecutionContext
|
||||||
|
): Promise<any[]> {
|
||||||
|
const { connectionId, tableName } = node.data;
|
||||||
|
|
||||||
|
// 연결 풀 서비스 사용
|
||||||
|
const { ExternalDbConnectionPoolService } = await import(
|
||||||
|
"./externalDbConnectionPoolService"
|
||||||
|
);
|
||||||
|
const poolService = ExternalDbConnectionPoolService.getInstance();
|
||||||
|
|
||||||
|
const sql = `SELECT * FROM ${tableName}`;
|
||||||
|
const result = await poolService.executeQuery(connectionId, sql);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 외부 DB 액션 노드에서 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 기존 createExternalConnector가 자동으로 연결 풀 사용
|
||||||
|
const connector = await this.createExternalConnector(connectionId, dbType);
|
||||||
|
|
||||||
|
// executeQuery 호출 시 내부적으로 연결 풀 사용
|
||||||
|
const result = await connector.executeQuery(sql, params);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 연결 풀 상태 조회
|
||||||
|
|
||||||
|
**API 엔드포인트**:
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/external-db-connections/pool-status
|
||||||
|
```
|
||||||
|
|
||||||
|
**응답 예시**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"totalPools": 3,
|
||||||
|
"activePools": 2,
|
||||||
|
"pools": [
|
||||||
|
{
|
||||||
|
"connectionId": 1,
|
||||||
|
"dbType": "postgresql",
|
||||||
|
"activeConnections": 2,
|
||||||
|
"maxConnections": 10,
|
||||||
|
"createdAt": "2025-01-13T10:00:00.000Z",
|
||||||
|
"lastUsedAt": "2025-01-13T10:05:00.000Z",
|
||||||
|
"idleSeconds": 45
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"connectionId": 2,
|
||||||
|
"dbType": "mysql",
|
||||||
|
"activeConnections": 0,
|
||||||
|
"maxConnections": 10,
|
||||||
|
"createdAt": "2025-01-13T09:50:00.000Z",
|
||||||
|
"lastUsedAt": "2025-01-13T09:55:00.000Z",
|
||||||
|
"idleSeconds": 600
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": "3개의 연결 풀 상태를 조회했습니다."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚨 연결 풀 고갈 방지 메커니즘
|
||||||
|
|
||||||
|
### 1. 최대 연결 수 제한
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 데이터베이스 설정 기준
|
||||||
|
max_connections: config.max_connections || 10;
|
||||||
|
```
|
||||||
|
|
||||||
|
각 외부 DB 연결마다 최대 연결 수를 설정하여 무제한 연결 방지.
|
||||||
|
|
||||||
|
### 2. 연결 재사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 동일한 connectionId 요청 시 기존 풀 재사용
|
||||||
|
const pool = await poolService.getPool(connectionId);
|
||||||
|
```
|
||||||
|
|
||||||
|
매번 새 연결을 생성하지 않고 기존 풀 재사용.
|
||||||
|
|
||||||
|
### 3. 자동 연결 해제
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// PostgreSQL: 30초 유휴 시 자동 해제
|
||||||
|
idleTimeoutMillis: 30000;
|
||||||
|
```
|
||||||
|
|
||||||
|
사용되지 않는 연결은 자동으로 해제하여 리소스 절약.
|
||||||
|
|
||||||
|
### 4. 전역 풀 정리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 10분 이상 미사용 풀 제거
|
||||||
|
if (idleTime > IDLE_TIMEOUT) {
|
||||||
|
await this.removePool(connectionId);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
장시간 사용되지 않는 풀 자체를 제거.
|
||||||
|
|
||||||
|
### 5. 애플리케이션 종료 시 정리
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
process.on("SIGINT", async () => {
|
||||||
|
await ExternalDbConnectionPoolService.getInstance().closeAll();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
프로세스 종료 시 모든 연결 정상 종료.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 모니터링 및 로깅
|
||||||
|
|
||||||
|
### 연결 이벤트 로깅
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 연결 획득
|
||||||
|
pool.on("acquire", () => {
|
||||||
|
logger.debug(`[PostgreSQL] 연결 획득 (2/10)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 연결 반환
|
||||||
|
pool.on("release", () => {
|
||||||
|
logger.debug(`[PostgreSQL] 연결 반환 (1/10)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 에러 발생
|
||||||
|
pool.on("error", (err) => {
|
||||||
|
logger.error(`[PostgreSQL] 연결 풀 오류:`, err);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 정기 상태 로깅
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 1분마다 상태 출력
|
||||||
|
logger.debug(`📊 연결 풀 상태: 총 3개, 활성: 2개`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 주요 로그 메시지
|
||||||
|
|
||||||
|
| 레벨 | 메시지 | 의미 |
|
||||||
|
| ------- | ---------------------------------------------------------- | --------------- |
|
||||||
|
| `info` | `🔧 새 연결 풀 생성 중 (ID: 1)...` | 새 풀 생성 시작 |
|
||||||
|
| `info` | `✅ 연결 풀 생성 완료 (ID: 1, 타입: postgresql, 최대: 10)` | 풀 생성 완료 |
|
||||||
|
| `debug` | `✅ 기존 연결 풀 재사용 (ID: 1)` | 기존 풀 재사용 |
|
||||||
|
| `info` | `🧹 유휴 연결 풀 정리 (ID: 2, 유휴: 620초)` | 유휴 풀 제거 |
|
||||||
|
| `warn` | `⚠️ 연결 풀 비정상 감지 (ID: 3), 재생성 중...` | 헬스 체크 실패 |
|
||||||
|
| `error` | `❌ 쿼리 실행 실패 (ID: 1)` | 쿼리 오류 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔒 보안 고려사항
|
||||||
|
|
||||||
|
### 1. 비밀번호 보호
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 비밀번호 복호화는 풀 생성 시에만 수행
|
||||||
|
config.password = PasswordEncryption.decrypt(config.password);
|
||||||
|
```
|
||||||
|
|
||||||
|
메모리에 평문 비밀번호를 최소한으로 유지.
|
||||||
|
|
||||||
|
### 2. 연결 정보 검증
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (config.is_active !== "Y") {
|
||||||
|
throw new Error(`비활성화된 연결입니다 (ID: ${connectionId})`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
비활성화된 연결은 사용 불가.
|
||||||
|
|
||||||
|
### 3. 타임아웃 설정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
connectionTimeoutMillis: 30000, // 30초
|
||||||
|
statement_timeout: 60000, // 60초
|
||||||
|
```
|
||||||
|
|
||||||
|
무한 대기 방지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 트러블슈팅
|
||||||
|
|
||||||
|
### 문제 1: 연결 풀 고갈
|
||||||
|
|
||||||
|
**증상**: "Connection pool exhausted" 오류
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
|
||||||
|
- 동시 요청이 최대 연결 수 초과
|
||||||
|
- 쿼리가 너무 오래 실행되어 연결 점유
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
|
||||||
|
1. `max_connections` 값 증가 (`external_db_connections` 테이블)
|
||||||
|
2. 쿼리 최적화 (인덱스, LIMIT 추가)
|
||||||
|
3. `query_timeout` 값 조정
|
||||||
|
|
||||||
|
### 문제 2: 메모리 누수
|
||||||
|
|
||||||
|
**증상**: 메모리 사용량 지속 증가
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
|
||||||
|
- 연결 풀이 정리되지 않음
|
||||||
|
- 헬스 체크 실패
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
|
||||||
|
1. 연결 풀 상태 확인: `GET /api/external-db-connections/pool-status`
|
||||||
|
2. 수동 재시작으로 모든 풀 정리
|
||||||
|
3. 로그에서 `🧹 유휴 연결 풀 정리` 메시지 확인
|
||||||
|
|
||||||
|
### 문제 3: 연결 시간 초과
|
||||||
|
|
||||||
|
**증상**: "Connection timeout" 오류
|
||||||
|
|
||||||
|
**원인**:
|
||||||
|
|
||||||
|
- DB 서버 응답 없음
|
||||||
|
- 네트워크 문제
|
||||||
|
- 방화벽 차단
|
||||||
|
|
||||||
|
**해결**:
|
||||||
|
|
||||||
|
1. DB 서버 상태 확인
|
||||||
|
2. 네트워크 연결 확인
|
||||||
|
3. `connection_timeout` 값 증가
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 설정 권장사항
|
||||||
|
|
||||||
|
### 소규모 시스템 (동시 사용자 < 50)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
max_connections: 5,
|
||||||
|
connection_timeout: 30,
|
||||||
|
query_timeout: 60,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 중규모 시스템 (동시 사용자 50-200)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
max_connections: 10,
|
||||||
|
connection_timeout: 30,
|
||||||
|
query_timeout: 90,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 대규모 시스템 (동시 사용자 > 200)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
max_connections: 20,
|
||||||
|
connection_timeout: 60,
|
||||||
|
query_timeout: 120,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 참고 자료
|
||||||
|
|
||||||
|
- [PostgreSQL Connection Pooling](https://node-postgres.com/features/pooling)
|
||||||
|
- [MySQL Connection Pool](https://github.com/mysqljs/mysql#pooling-connections)
|
||||||
|
- [Node.js Best Practices - Database Connection Management](https://github.com/goldbergyoni/nodebestpractices)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 결론
|
||||||
|
|
||||||
|
외부 DB 연결 풀 서비스는 다음을 보장합니다:
|
||||||
|
|
||||||
|
✅ **효율성**: 연결 재사용으로 성능 향상
|
||||||
|
✅ **안정성**: 연결 풀 고갈 방지
|
||||||
|
✅ **자동화**: 생성/정리/모니터링 자동화
|
||||||
|
✅ **확장성**: 다중 DB 및 대규모 트래픽 지원
|
||||||
|
|
||||||
|
**최소한의 설정**으로 **최대한의 안정성**을 제공합니다! 🚀
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 노드 기반 제어 시스템 페이지
|
* 제어 시스템 페이지
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
import { FlowEditor } from "@/components/dataflow/node-editor/FlowEditor";
|
||||||
|
|
@ -12,7 +12,7 @@ export default function NodeEditorPage() {
|
||||||
{/* 페이지 헤더 */}
|
{/* 페이지 헤더 */}
|
||||||
<div className="border-b bg-white p-4">
|
<div className="border-b bg-white p-4">
|
||||||
<div className="mx-auto">
|
<div className="mx-auto">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">노드 기반 제어 시스템</h1>
|
<h1 className="text-2xl font-bold text-gray-900">제어 시스템</h1>
|
||||||
<p className="mt-1 text-sm text-gray-600">
|
<p className="mt-1 text-sm text-gray-600">
|
||||||
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다
|
드래그 앤 드롭으로 데이터 제어 플로우를 시각적으로 설계하고 관리합니다
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { DashboardElement, QueryResult } from './types';
|
import { DashboardElement, QueryResult } from "./types";
|
||||||
import { ChartRenderer } from './charts/ChartRenderer';
|
import { ChartRenderer } from "./charts/ChartRenderer";
|
||||||
|
import { snapToGrid, snapSizeToGrid, GRID_CONFIG } from "./gridUtils";
|
||||||
|
|
||||||
interface CanvasElementProps {
|
interface CanvasElementProps {
|
||||||
element: DashboardElement;
|
element: DashboardElement;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
|
cellSize: number;
|
||||||
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
onUpdate: (id: string, updates: Partial<DashboardElement>) => void;
|
||||||
onRemove: (id: string) => void;
|
onRemove: (id: string) => void;
|
||||||
onSelect: (id: string | null) => void;
|
onSelect: (id: string | null) => void;
|
||||||
|
|
@ -15,127 +17,182 @@ interface CanvasElementProps {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 캔버스에 배치된 개별 요소 컴포넌트
|
* 캔버스에 배치된 개별 요소 컴포넌트
|
||||||
* - 드래그로 이동 가능
|
* - 드래그로 이동 가능 (그리드 스냅)
|
||||||
* - 크기 조절 핸들
|
* - 크기 조절 핸들 (그리드 스냅)
|
||||||
* - 삭제 버튼
|
* - 삭제 버튼
|
||||||
*/
|
*/
|
||||||
export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelect, onConfigure }: CanvasElementProps) {
|
export function CanvasElement({
|
||||||
|
element,
|
||||||
|
isSelected,
|
||||||
|
cellSize,
|
||||||
|
onUpdate,
|
||||||
|
onRemove,
|
||||||
|
onSelect,
|
||||||
|
onConfigure,
|
||||||
|
}: CanvasElementProps) {
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [isResizing, setIsResizing] = useState(false);
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0, elementX: 0, elementY: 0 });
|
||||||
const [resizeStart, setResizeStart] = useState({
|
const [resizeStart, setResizeStart] = useState({
|
||||||
x: 0, y: 0, width: 0, height: 0, elementX: 0, elementY: 0, handle: ''
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
elementX: 0,
|
||||||
|
elementY: 0,
|
||||||
|
handle: "",
|
||||||
});
|
});
|
||||||
const [chartData, setChartData] = useState<QueryResult | null>(null);
|
const [chartData, setChartData] = useState<QueryResult | null>(null);
|
||||||
const [isLoadingData, setIsLoadingData] = useState(false);
|
const [isLoadingData, setIsLoadingData] = useState(false);
|
||||||
const elementRef = useRef<HTMLDivElement>(null);
|
const elementRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// 드래그/리사이즈 중 임시 위치/크기 (스냅 전)
|
||||||
|
const [tempPosition, setTempPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
const [tempSize, setTempSize] = useState<{ width: number; height: number } | null>(null);
|
||||||
|
|
||||||
// 요소 선택 처리
|
// 요소 선택 처리
|
||||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
const handleMouseDown = useCallback(
|
||||||
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
(e: React.MouseEvent) => {
|
||||||
if ((e.target as HTMLElement).closest('.element-close, .resize-handle')) {
|
// 닫기 버튼이나 리사이즈 핸들 클릭 시 무시
|
||||||
return;
|
if ((e.target as HTMLElement).closest(".element-close, .resize-handle")) {
|
||||||
}
|
return;
|
||||||
|
|
||||||
onSelect(element.id);
|
|
||||||
setIsDragging(true);
|
|
||||||
setDragStart({
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
elementX: element.position.x,
|
|
||||||
elementY: element.position.y
|
|
||||||
});
|
|
||||||
e.preventDefault();
|
|
||||||
}, [element.id, element.position.x, element.position.y, onSelect]);
|
|
||||||
|
|
||||||
// 리사이즈 핸들 마우스다운
|
|
||||||
const handleResizeMouseDown = useCallback((e: React.MouseEvent, handle: string) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setIsResizing(true);
|
|
||||||
setResizeStart({
|
|
||||||
x: e.clientX,
|
|
||||||
y: e.clientY,
|
|
||||||
width: element.size.width,
|
|
||||||
height: element.size.height,
|
|
||||||
elementX: element.position.x,
|
|
||||||
elementY: element.position.y,
|
|
||||||
handle
|
|
||||||
});
|
|
||||||
}, [element.size.width, element.size.height, element.position.x, element.position.y]);
|
|
||||||
|
|
||||||
// 마우스 이동 처리
|
|
||||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
|
||||||
if (isDragging) {
|
|
||||||
const deltaX = e.clientX - dragStart.x;
|
|
||||||
const deltaY = e.clientY - dragStart.y;
|
|
||||||
|
|
||||||
onUpdate(element.id, {
|
|
||||||
position: {
|
|
||||||
x: Math.max(0, dragStart.elementX + deltaX),
|
|
||||||
y: Math.max(0, dragStart.elementY + deltaY)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else if (isResizing) {
|
|
||||||
const deltaX = e.clientX - resizeStart.x;
|
|
||||||
const deltaY = e.clientY - resizeStart.y;
|
|
||||||
|
|
||||||
let newWidth = resizeStart.width;
|
|
||||||
let newHeight = resizeStart.height;
|
|
||||||
let newX = resizeStart.elementX;
|
|
||||||
let newY = resizeStart.elementY;
|
|
||||||
|
|
||||||
switch (resizeStart.handle) {
|
|
||||||
case 'se': // 오른쪽 아래
|
|
||||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
|
||||||
break;
|
|
||||||
case 'sw': // 왼쪽 아래
|
|
||||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height + deltaY);
|
|
||||||
newX = resizeStart.elementX + deltaX;
|
|
||||||
break;
|
|
||||||
case 'ne': // 오른쪽 위
|
|
||||||
newWidth = Math.max(150, resizeStart.width + deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
|
||||||
newY = resizeStart.elementY + deltaY;
|
|
||||||
break;
|
|
||||||
case 'nw': // 왼쪽 위
|
|
||||||
newWidth = Math.max(150, resizeStart.width - deltaX);
|
|
||||||
newHeight = Math.max(150, resizeStart.height - deltaY);
|
|
||||||
newX = resizeStart.elementX + deltaX;
|
|
||||||
newY = resizeStart.elementY + deltaY;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onUpdate(element.id, {
|
onSelect(element.id);
|
||||||
position: { x: Math.max(0, newX), y: Math.max(0, newY) },
|
setIsDragging(true);
|
||||||
size: { width: newWidth, height: newHeight }
|
setDragStart({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
elementX: element.position.x,
|
||||||
|
elementY: element.position.y,
|
||||||
});
|
});
|
||||||
}
|
e.preventDefault();
|
||||||
}, [isDragging, isResizing, dragStart, resizeStart, element.id, onUpdate]);
|
},
|
||||||
|
[element.id, element.position.x, element.position.y, onSelect],
|
||||||
|
);
|
||||||
|
|
||||||
// 마우스 업 처리
|
// 리사이즈 핸들 마우스다운
|
||||||
|
const handleResizeMouseDown = useCallback(
|
||||||
|
(e: React.MouseEvent, handle: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setIsResizing(true);
|
||||||
|
setResizeStart({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
width: element.size.width,
|
||||||
|
height: element.size.height,
|
||||||
|
elementX: element.position.x,
|
||||||
|
elementY: element.position.y,
|
||||||
|
handle,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[element.size.width, element.size.height, element.position.x, element.position.y],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 마우스 이동 처리 (그리드 스냅 적용)
|
||||||
|
const handleMouseMove = useCallback(
|
||||||
|
(e: MouseEvent) => {
|
||||||
|
if (isDragging) {
|
||||||
|
const deltaX = e.clientX - dragStart.x;
|
||||||
|
const deltaY = e.clientY - dragStart.y;
|
||||||
|
|
||||||
|
// 임시 위치 계산 (스냅 안 됨)
|
||||||
|
const rawX = Math.max(0, dragStart.elementX + deltaX);
|
||||||
|
const rawY = Math.max(0, dragStart.elementY + deltaY);
|
||||||
|
|
||||||
|
setTempPosition({ x: rawX, y: rawY });
|
||||||
|
} else if (isResizing) {
|
||||||
|
const deltaX = e.clientX - resizeStart.x;
|
||||||
|
const deltaY = e.clientY - resizeStart.y;
|
||||||
|
|
||||||
|
let newWidth = resizeStart.width;
|
||||||
|
let newHeight = resizeStart.height;
|
||||||
|
let newX = resizeStart.elementX;
|
||||||
|
let newY = resizeStart.elementY;
|
||||||
|
|
||||||
|
const minSize = GRID_CONFIG.CELL_SIZE * 2; // 최소 2셀
|
||||||
|
|
||||||
|
switch (resizeStart.handle) {
|
||||||
|
case "se": // 오른쪽 아래
|
||||||
|
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||||
|
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||||
|
break;
|
||||||
|
case "sw": // 왼쪽 아래
|
||||||
|
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||||
|
newHeight = Math.max(minSize, resizeStart.height + deltaY);
|
||||||
|
newX = resizeStart.elementX + deltaX;
|
||||||
|
break;
|
||||||
|
case "ne": // 오른쪽 위
|
||||||
|
newWidth = Math.max(minSize, resizeStart.width + deltaX);
|
||||||
|
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||||
|
newY = resizeStart.elementY + deltaY;
|
||||||
|
break;
|
||||||
|
case "nw": // 왼쪽 위
|
||||||
|
newWidth = Math.max(minSize, resizeStart.width - deltaX);
|
||||||
|
newHeight = Math.max(minSize, resizeStart.height - deltaY);
|
||||||
|
newX = resizeStart.elementX + deltaX;
|
||||||
|
newY = resizeStart.elementY + deltaY;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 임시 크기/위치 저장 (스냅 안 됨)
|
||||||
|
setTempPosition({ x: Math.max(0, newX), y: Math.max(0, newY) });
|
||||||
|
setTempSize({ width: newWidth, height: newHeight });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[isDragging, isResizing, dragStart, resizeStart],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 마우스 업 처리 (그리드 스냅 적용)
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
|
if (isDragging && tempPosition) {
|
||||||
|
// 드래그 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||||
|
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||||
|
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||||
|
|
||||||
|
onUpdate(element.id, {
|
||||||
|
position: { x: snappedX, y: snappedY },
|
||||||
|
});
|
||||||
|
|
||||||
|
setTempPosition(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isResizing && tempPosition && tempSize) {
|
||||||
|
// 리사이즈 종료 시 그리드에 스냅 (동적 셀 크기 사용)
|
||||||
|
const snappedX = snapToGrid(tempPosition.x, cellSize);
|
||||||
|
const snappedY = snapToGrid(tempPosition.y, cellSize);
|
||||||
|
const snappedWidth = snapSizeToGrid(tempSize.width, 2, cellSize);
|
||||||
|
const snappedHeight = snapSizeToGrid(tempSize.height, 2, cellSize);
|
||||||
|
|
||||||
|
onUpdate(element.id, {
|
||||||
|
position: { x: snappedX, y: snappedY },
|
||||||
|
size: { width: snappedWidth, height: snappedHeight },
|
||||||
|
});
|
||||||
|
|
||||||
|
setTempPosition(null);
|
||||||
|
setTempSize(null);
|
||||||
|
}
|
||||||
|
|
||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
setIsResizing(false);
|
setIsResizing(false);
|
||||||
}, []);
|
}, [isDragging, isResizing, tempPosition, tempSize, element.id, onUpdate, cellSize]);
|
||||||
|
|
||||||
// 전역 마우스 이벤트 등록
|
// 전역 마우스 이벤트 등록
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (isDragging || isResizing) {
|
if (isDragging || isResizing) {
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
document.addEventListener("mousemove", handleMouseMove);
|
||||||
document.addEventListener('mouseup', handleMouseUp);
|
document.addEventListener("mouseup", handleMouseUp);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
document.removeEventListener("mousemove", handleMouseMove);
|
||||||
document.removeEventListener('mouseup', handleMouseUp);
|
document.removeEventListener("mouseup", handleMouseUp);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
}, [isDragging, isResizing, handleMouseMove, handleMouseUp]);
|
||||||
|
|
||||||
// 데이터 로딩
|
// 데이터 로딩
|
||||||
const loadChartData = useCallback(async () => {
|
const loadChartData = useCallback(async () => {
|
||||||
if (!element.dataSource?.query || element.type !== 'chart') {
|
if (!element.dataSource?.query || element.type !== "chart") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,7 +201,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
||||||
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
// console.log('🔄 쿼리 실행 시작:', element.dataSource.query);
|
||||||
|
|
||||||
// 실제 API 호출
|
// 실제 API 호출
|
||||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
const result = await dashboardApi.executeQuery(element.dataSource.query);
|
||||||
|
|
||||||
// console.log('✅ 쿼리 실행 결과:', result);
|
// console.log('✅ 쿼리 실행 결과:', result);
|
||||||
|
|
@ -153,7 +210,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
||||||
columns: result.columns || [],
|
columns: result.columns || [],
|
||||||
rows: result.rows || [],
|
rows: result.rows || [],
|
||||||
totalRows: result.rowCount || 0,
|
totalRows: result.rowCount || 0,
|
||||||
executionTime: 0
|
executionTime: 0,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('❌ 데이터 로딩 오류:', error);
|
// console.error('❌ 데이터 로딩 오류:', error);
|
||||||
|
|
@ -185,51 +242,56 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
||||||
|
|
||||||
// 스타일 클래스 생성
|
// 스타일 클래스 생성
|
||||||
const getContentClass = () => {
|
const getContentClass = () => {
|
||||||
if (element.type === 'chart') {
|
if (element.type === "chart") {
|
||||||
switch (element.subtype) {
|
switch (element.subtype) {
|
||||||
case 'bar': return 'bg-gradient-to-br from-indigo-400 to-purple-600';
|
case "bar":
|
||||||
case 'pie': return 'bg-gradient-to-br from-pink-400 to-red-500';
|
return "bg-gradient-to-br from-indigo-400 to-purple-600";
|
||||||
case 'line': return 'bg-gradient-to-br from-blue-400 to-cyan-400';
|
case "pie":
|
||||||
default: return 'bg-gray-200';
|
return "bg-gradient-to-br from-pink-400 to-red-500";
|
||||||
|
case "line":
|
||||||
|
return "bg-gradient-to-br from-blue-400 to-cyan-400";
|
||||||
|
default:
|
||||||
|
return "bg-gray-200";
|
||||||
}
|
}
|
||||||
} else if (element.type === 'widget') {
|
} else if (element.type === "widget") {
|
||||||
switch (element.subtype) {
|
switch (element.subtype) {
|
||||||
case 'exchange': return 'bg-gradient-to-br from-pink-400 to-yellow-400';
|
case "exchange":
|
||||||
case 'weather': return 'bg-gradient-to-br from-cyan-400 to-indigo-800';
|
return "bg-gradient-to-br from-pink-400 to-yellow-400";
|
||||||
default: return 'bg-gray-200';
|
case "weather":
|
||||||
|
return "bg-gradient-to-br from-cyan-400 to-indigo-800";
|
||||||
|
default:
|
||||||
|
return "bg-gray-200";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return 'bg-gray-200';
|
return "bg-gray-200";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그/리사이즈 중일 때는 임시 위치/크기 사용, 아니면 실제 값 사용
|
||||||
|
const displayPosition = tempPosition || element.position;
|
||||||
|
const displaySize = tempSize || element.size;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={elementRef}
|
ref={elementRef}
|
||||||
className={`
|
className={`absolute min-h-[120px] min-w-[120px] cursor-move rounded-lg border-2 bg-white shadow-lg ${isSelected ? "border-blue-500 shadow-blue-200" : "border-gray-400"} ${isDragging || isResizing ? "transition-none" : "transition-all duration-150"} `}
|
||||||
absolute bg-white border-2 rounded-lg shadow-lg
|
|
||||||
min-w-[150px] min-h-[150px] cursor-move
|
|
||||||
${isSelected ? 'border-green-500 shadow-green-200' : 'border-gray-600'}
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
left: element.position.x,
|
left: displayPosition.x,
|
||||||
top: element.position.y,
|
top: displayPosition.y,
|
||||||
width: element.size.width,
|
width: displaySize.width,
|
||||||
height: element.size.height
|
height: displaySize.height,
|
||||||
|
padding: `${GRID_CONFIG.ELEMENT_PADDING}px`,
|
||||||
|
boxSizing: "border-box",
|
||||||
}}
|
}}
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="bg-gray-50 p-3 border-b border-gray-200 flex justify-between items-center cursor-move">
|
<div className="flex cursor-move items-center justify-between border-b border-gray-200 bg-gray-50 p-3">
|
||||||
<span className="font-bold text-sm text-gray-800">{element.title}</span>
|
<span className="text-sm font-bold text-gray-800">{element.title}</span>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
{/* 설정 버튼 */}
|
{/* 설정 버튼 */}
|
||||||
{onConfigure && (
|
{onConfigure && (
|
||||||
<button
|
<button
|
||||||
className="
|
className="hover:bg-accent0 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||||
w-6 h-6 flex items-center justify-center
|
|
||||||
text-gray-400 hover:bg-accent0 hover:text-white
|
|
||||||
rounded transition-colors duration-200
|
|
||||||
"
|
|
||||||
onClick={() => onConfigure(element)}
|
onClick={() => onConfigure(element)}
|
||||||
title="설정"
|
title="설정"
|
||||||
>
|
>
|
||||||
|
|
@ -238,11 +300,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
||||||
)}
|
)}
|
||||||
{/* 삭제 버튼 */}
|
{/* 삭제 버튼 */}
|
||||||
<button
|
<button
|
||||||
className="
|
className="element-close hover:bg-destructive/100 flex h-6 w-6 items-center justify-center rounded text-gray-400 transition-colors duration-200 hover:text-white"
|
||||||
element-close w-6 h-6 flex items-center justify-center
|
|
||||||
text-gray-400 hover:bg-destructive/100 hover:text-white
|
|
||||||
rounded transition-colors duration-200
|
|
||||||
"
|
|
||||||
onClick={handleRemove}
|
onClick={handleRemove}
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
|
|
@ -252,14 +310,14 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 내용 */}
|
{/* 내용 */}
|
||||||
<div className="h-[calc(100%-45px)] relative">
|
<div className="relative h-[calc(100%-45px)]">
|
||||||
{element.type === 'chart' ? (
|
{element.type === "chart" ? (
|
||||||
// 차트 렌더링
|
// 차트 렌더링
|
||||||
<div className="w-full h-full bg-white">
|
<div className="h-full w-full bg-white">
|
||||||
{isLoadingData ? (
|
{isLoadingData ? (
|
||||||
<div className="w-full h-full flex items-center justify-center text-gray-500">
|
<div className="flex h-full w-full items-center justify-center text-gray-500">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-6 h-6 border-2 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-2" />
|
<div className="border-primary mx-auto mb-2 h-6 w-6 animate-spin rounded-full border-2 border-t-transparent" />
|
||||||
<div className="text-sm">데이터 로딩 중...</div>
|
<div className="text-sm">데이터 로딩 중...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -274,15 +332,13 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
// 위젯 렌더링 (기존 방식)
|
// 위젯 렌더링 (기존 방식)
|
||||||
<div className={`
|
<div
|
||||||
w-full h-full p-5 flex items-center justify-center
|
className={`flex h-full w-full items-center justify-center p-5 text-center text-sm font-medium text-white ${getContentClass()} `}
|
||||||
text-sm text-white font-medium text-center
|
>
|
||||||
${getContentClass()}
|
|
||||||
`}>
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-4xl mb-2">
|
<div className="mb-2 text-4xl">
|
||||||
{element.type === 'widget' && element.subtype === 'exchange' && '💱'}
|
{element.type === "widget" && element.subtype === "exchange" && "💱"}
|
||||||
{element.type === 'widget' && element.subtype === 'weather' && '☁️'}
|
{element.type === "widget" && element.subtype === "weather" && "☁️"}
|
||||||
</div>
|
</div>
|
||||||
<div className="whitespace-pre-line">{element.content}</div>
|
<div className="whitespace-pre-line">{element.content}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -304,7 +360,7 @@ export function CanvasElement({ element, isSelected, onUpdate, onRemove, onSelec
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResizeHandleProps {
|
interface ResizeHandleProps {
|
||||||
position: 'nw' | 'ne' | 'sw' | 'se';
|
position: "nw" | "ne" | "sw" | "se";
|
||||||
onMouseDown: (e: React.MouseEvent, handle: string) => void;
|
onMouseDown: (e: React.MouseEvent, handle: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -314,19 +370,20 @@ interface ResizeHandleProps {
|
||||||
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
||||||
const getPositionClass = () => {
|
const getPositionClass = () => {
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case 'nw': return 'top-[-5px] left-[-5px] cursor-nw-resize';
|
case "nw":
|
||||||
case 'ne': return 'top-[-5px] right-[-5px] cursor-ne-resize';
|
return "top-[-5px] left-[-5px] cursor-nw-resize";
|
||||||
case 'sw': return 'bottom-[-5px] left-[-5px] cursor-sw-resize';
|
case "ne":
|
||||||
case 'se': return 'bottom-[-5px] right-[-5px] cursor-se-resize';
|
return "top-[-5px] right-[-5px] cursor-ne-resize";
|
||||||
|
case "sw":
|
||||||
|
return "bottom-[-5px] left-[-5px] cursor-sw-resize";
|
||||||
|
case "se":
|
||||||
|
return "bottom-[-5px] right-[-5px] cursor-se-resize";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`resize-handle absolute h-3 w-3 border border-white bg-green-500 ${getPositionClass()} `}
|
||||||
resize-handle absolute w-3 h-3 bg-green-500 border border-white
|
|
||||||
${getPositionClass()}
|
|
||||||
`}
|
|
||||||
onMouseDown={(e) => onMouseDown(e, position)}
|
onMouseDown={(e) => onMouseDown(e, position)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -337,55 +394,55 @@ function ResizeHandle({ position, onMouseDown }: ResizeHandleProps) {
|
||||||
*/
|
*/
|
||||||
function generateSampleData(query: string, chartType: string): QueryResult {
|
function generateSampleData(query: string, chartType: string): QueryResult {
|
||||||
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
// 쿼리에서 키워드 추출하여 적절한 샘플 데이터 생성
|
||||||
const isMonthly = query.toLowerCase().includes('month');
|
const isMonthly = query.toLowerCase().includes("month");
|
||||||
const isSales = query.toLowerCase().includes('sales') || query.toLowerCase().includes('매출');
|
const isSales = query.toLowerCase().includes("sales") || query.toLowerCase().includes("매출");
|
||||||
const isUsers = query.toLowerCase().includes('users') || query.toLowerCase().includes('사용자');
|
const isUsers = query.toLowerCase().includes("users") || query.toLowerCase().includes("사용자");
|
||||||
const isProducts = query.toLowerCase().includes('product') || query.toLowerCase().includes('상품');
|
const isProducts = query.toLowerCase().includes("product") || query.toLowerCase().includes("상품");
|
||||||
|
|
||||||
let columns: string[];
|
let columns: string[];
|
||||||
let rows: Record<string, any>[];
|
let rows: Record<string, any>[];
|
||||||
|
|
||||||
if (isMonthly && isSales) {
|
if (isMonthly && isSales) {
|
||||||
// 월별 매출 데이터
|
// 월별 매출 데이터
|
||||||
columns = ['month', 'sales', 'order_count'];
|
columns = ["month", "sales", "order_count"];
|
||||||
rows = [
|
rows = [
|
||||||
{ month: '2024-01', sales: 1200000, order_count: 45 },
|
{ month: "2024-01", sales: 1200000, order_count: 45 },
|
||||||
{ month: '2024-02', sales: 1350000, order_count: 52 },
|
{ month: "2024-02", sales: 1350000, order_count: 52 },
|
||||||
{ month: '2024-03', sales: 1180000, order_count: 41 },
|
{ month: "2024-03", sales: 1180000, order_count: 41 },
|
||||||
{ month: '2024-04', sales: 1420000, order_count: 58 },
|
{ month: "2024-04", sales: 1420000, order_count: 58 },
|
||||||
{ month: '2024-05', sales: 1680000, order_count: 67 },
|
{ month: "2024-05", sales: 1680000, order_count: 67 },
|
||||||
{ month: '2024-06', sales: 1540000, order_count: 61 },
|
{ month: "2024-06", sales: 1540000, order_count: 61 },
|
||||||
];
|
];
|
||||||
} else if (isUsers) {
|
} else if (isUsers) {
|
||||||
// 사용자 가입 추이
|
// 사용자 가입 추이
|
||||||
columns = ['week', 'new_users'];
|
columns = ["week", "new_users"];
|
||||||
rows = [
|
rows = [
|
||||||
{ week: '2024-W10', new_users: 23 },
|
{ week: "2024-W10", new_users: 23 },
|
||||||
{ week: '2024-W11', new_users: 31 },
|
{ week: "2024-W11", new_users: 31 },
|
||||||
{ week: '2024-W12', new_users: 28 },
|
{ week: "2024-W12", new_users: 28 },
|
||||||
{ week: '2024-W13', new_users: 35 },
|
{ week: "2024-W13", new_users: 35 },
|
||||||
{ week: '2024-W14', new_users: 42 },
|
{ week: "2024-W14", new_users: 42 },
|
||||||
{ week: '2024-W15', new_users: 38 },
|
{ week: "2024-W15", new_users: 38 },
|
||||||
];
|
];
|
||||||
} else if (isProducts) {
|
} else if (isProducts) {
|
||||||
// 상품별 판매량
|
// 상품별 판매량
|
||||||
columns = ['product_name', 'total_sold', 'revenue'];
|
columns = ["product_name", "total_sold", "revenue"];
|
||||||
rows = [
|
rows = [
|
||||||
{ product_name: '스마트폰', total_sold: 156, revenue: 234000000 },
|
{ product_name: "스마트폰", total_sold: 156, revenue: 234000000 },
|
||||||
{ product_name: '노트북', total_sold: 89, revenue: 178000000 },
|
{ product_name: "노트북", total_sold: 89, revenue: 178000000 },
|
||||||
{ product_name: '태블릿', total_sold: 134, revenue: 67000000 },
|
{ product_name: "태블릿", total_sold: 134, revenue: 67000000 },
|
||||||
{ product_name: '이어폰', total_sold: 267, revenue: 26700000 },
|
{ product_name: "이어폰", total_sold: 267, revenue: 26700000 },
|
||||||
{ product_name: '스마트워치', total_sold: 98, revenue: 49000000 },
|
{ product_name: "스마트워치", total_sold: 98, revenue: 49000000 },
|
||||||
];
|
];
|
||||||
} else {
|
} else {
|
||||||
// 기본 샘플 데이터
|
// 기본 샘플 데이터
|
||||||
columns = ['category', 'value', 'count'];
|
columns = ["category", "value", "count"];
|
||||||
rows = [
|
rows = [
|
||||||
{ category: 'A', value: 100, count: 10 },
|
{ category: "A", value: 100, count: 10 },
|
||||||
{ category: 'B', value: 150, count: 15 },
|
{ category: "B", value: 150, count: 15 },
|
||||||
{ category: 'C', value: 120, count: 12 },
|
{ category: "C", value: 120, count: 12 },
|
||||||
{ category: 'D', value: 180, count: 18 },
|
{ category: "D", value: 180, count: 18 },
|
||||||
{ category: 'E', value: 90, count: 9 },
|
{ category: "E", value: 90, count: 9 },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { forwardRef, useState, useCallback } from 'react';
|
import React, { forwardRef, useState, useCallback, useEffect } from "react";
|
||||||
import { DashboardElement, ElementType, ElementSubtype, DragData } from './types';
|
import { DashboardElement, ElementType, ElementSubtype, DragData } from "./types";
|
||||||
import { CanvasElement } from './CanvasElement';
|
import { CanvasElement } from "./CanvasElement";
|
||||||
|
import { GRID_CONFIG, snapToGrid } from "./gridUtils";
|
||||||
|
|
||||||
interface DashboardCanvasProps {
|
interface DashboardCanvasProps {
|
||||||
elements: DashboardElement[];
|
elements: DashboardElement[];
|
||||||
|
|
@ -17,17 +18,29 @@ interface DashboardCanvasProps {
|
||||||
/**
|
/**
|
||||||
* 대시보드 캔버스 컴포넌트
|
* 대시보드 캔버스 컴포넌트
|
||||||
* - 드래그 앤 드롭 영역
|
* - 드래그 앤 드롭 영역
|
||||||
* - 그리드 배경
|
* - 12 컬럼 그리드 배경
|
||||||
|
* - 스냅 기능
|
||||||
* - 요소 배치 및 관리
|
* - 요소 배치 및 관리
|
||||||
*/
|
*/
|
||||||
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
({ elements, selectedElement, onCreateElement, onUpdateElement, onRemoveElement, onSelectElement, onConfigureElement }, ref) => {
|
(
|
||||||
|
{
|
||||||
|
elements,
|
||||||
|
selectedElement,
|
||||||
|
onCreateElement,
|
||||||
|
onUpdateElement,
|
||||||
|
onRemoveElement,
|
||||||
|
onSelectElement,
|
||||||
|
onConfigureElement,
|
||||||
|
},
|
||||||
|
ref,
|
||||||
|
) => {
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
// 드래그 오버 처리
|
// 드래그 오버 처리
|
||||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.dataTransfer.dropEffect = 'copy';
|
e.dataTransfer.dropEffect = "copy";
|
||||||
setIsDragOver(true);
|
setIsDragOver(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -38,51 +51,71 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 드롭 처리
|
// 드롭 처리 (그리드 스냅 적용)
|
||||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
const handleDrop = useCallback(
|
||||||
e.preventDefault();
|
(e: React.DragEvent) => {
|
||||||
setIsDragOver(false);
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dragData: DragData = JSON.parse(e.dataTransfer.getData('application/json'));
|
const dragData: DragData = JSON.parse(e.dataTransfer.getData("application/json"));
|
||||||
|
|
||||||
if (!ref || typeof ref === 'function') return;
|
if (!ref || typeof ref === "function") return;
|
||||||
|
|
||||||
const rect = ref.current?.getBoundingClientRect();
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
if (!rect) return;
|
if (!rect) return;
|
||||||
|
|
||||||
// 캔버스 스크롤을 고려한 정확한 위치 계산
|
// 캔버스 스크롤을 고려한 정확한 위치 계산
|
||||||
const x = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
const rawX = e.clientX - rect.left + (ref.current?.scrollLeft || 0);
|
||||||
const y = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
const rawY = e.clientY - rect.top + (ref.current?.scrollTop || 0);
|
||||||
|
|
||||||
onCreateElement(dragData.type, dragData.subtype, x, y);
|
// 그리드에 스냅 (고정 셀 크기 사용)
|
||||||
} catch (error) {
|
const snappedX = snapToGrid(rawX, GRID_CONFIG.CELL_SIZE);
|
||||||
// console.error('드롭 데이터 파싱 오류:', error);
|
const snappedY = snapToGrid(rawY, GRID_CONFIG.CELL_SIZE);
|
||||||
}
|
|
||||||
}, [ref, onCreateElement]);
|
onCreateElement(dragData.type, dragData.subtype, snappedX, snappedY);
|
||||||
|
} catch (error) {
|
||||||
|
// console.error('드롭 데이터 파싱 오류:', error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[ref, onCreateElement],
|
||||||
|
);
|
||||||
|
|
||||||
// 캔버스 클릭 시 선택 해제
|
// 캔버스 클릭 시 선택 해제
|
||||||
const handleCanvasClick = useCallback((e: React.MouseEvent) => {
|
const handleCanvasClick = useCallback(
|
||||||
if (e.target === e.currentTarget) {
|
(e: React.MouseEvent) => {
|
||||||
onSelectElement(null);
|
if (e.target === e.currentTarget) {
|
||||||
}
|
onSelectElement(null);
|
||||||
}, [onSelectElement]);
|
}
|
||||||
|
},
|
||||||
|
[onSelectElement],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 고정 그리드 크기
|
||||||
|
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||||
|
const gridSize = `${cellWithGap}px ${cellWithGap}px`;
|
||||||
|
|
||||||
|
// 캔버스 높이를 요소들의 최대 y + height 기준으로 계산 (최소 화면 높이 보장)
|
||||||
|
const minCanvasHeight = Math.max(
|
||||||
|
typeof window !== "undefined" ? window.innerHeight : 800,
|
||||||
|
...elements.map((el) => el.position.y + el.size.height + 100), // 하단 여백 100px
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`
|
className={`relative rounded-lg bg-gray-50 shadow-inner ${isDragOver ? "bg-blue-50/50" : ""} `}
|
||||||
w-full min-h-full relative
|
|
||||||
bg-gray-100
|
|
||||||
bg-grid-pattern
|
|
||||||
${isDragOver ? 'bg-accent' : ''}
|
|
||||||
`}
|
|
||||||
style={{
|
style={{
|
||||||
|
width: `${GRID_CONFIG.CANVAS_WIDTH}px`,
|
||||||
|
minHeight: `${minCanvasHeight}px`,
|
||||||
|
// 12 컬럼 그리드 배경
|
||||||
backgroundImage: `
|
backgroundImage: `
|
||||||
linear-gradient(rgba(200, 200, 200, 0.3) 1px, transparent 1px),
|
linear-gradient(rgba(59, 130, 246, 0.15) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgba(200, 200, 200, 0.3) 1px, transparent 1px)
|
linear-gradient(90deg, rgba(59, 130, 246, 0.15) 1px, transparent 1px)
|
||||||
`,
|
`,
|
||||||
backgroundSize: '20px 20px'
|
backgroundSize: gridSize,
|
||||||
|
backgroundPosition: "0 0",
|
||||||
|
backgroundRepeat: "repeat",
|
||||||
}}
|
}}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragLeave={handleDragLeave}
|
onDragLeave={handleDragLeave}
|
||||||
|
|
@ -95,6 +128,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
key={element.id}
|
key={element.id}
|
||||||
element={element}
|
element={element}
|
||||||
isSelected={selectedElement === element.id}
|
isSelected={selectedElement === element.id}
|
||||||
|
cellSize={GRID_CONFIG.CELL_SIZE}
|
||||||
onUpdate={onUpdateElement}
|
onUpdate={onUpdateElement}
|
||||||
onRemove={onRemoveElement}
|
onRemove={onRemoveElement}
|
||||||
onSelect={onSelectElement}
|
onSelect={onSelectElement}
|
||||||
|
|
@ -103,7 +137,7 @@ export const DashboardCanvas = forwardRef<HTMLDivElement, DashboardCanvasProps>(
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
DashboardCanvas.displayName = 'DashboardCanvas';
|
DashboardCanvas.displayName = "DashboardCanvas";
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useRef, useCallback } from 'react';
|
import React, { useState, useRef, useCallback, useEffect } from "react";
|
||||||
import { DashboardCanvas } from './DashboardCanvas';
|
import { DashboardCanvas } from "./DashboardCanvas";
|
||||||
import { DashboardSidebar } from './DashboardSidebar';
|
import { DashboardSidebar } from "./DashboardSidebar";
|
||||||
import { DashboardToolbar } from './DashboardToolbar';
|
import { DashboardToolbar } from "./DashboardToolbar";
|
||||||
import { ElementConfigModal } from './ElementConfigModal';
|
import { ElementConfigModal } from "./ElementConfigModal";
|
||||||
import { DashboardElement, ElementType, ElementSubtype } from './types';
|
import { DashboardElement, ElementType, ElementSubtype } from "./types";
|
||||||
|
import { GRID_CONFIG } from "./gridUtils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 설계 도구 메인 컴포넌트
|
* 대시보드 설계 도구 메인 컴포넌트
|
||||||
* - 드래그 앤 드롭으로 차트/위젯 배치
|
* - 드래그 앤 드롭으로 차트/위젯 배치
|
||||||
|
* - 그리드 기반 레이아웃 (12 컬럼)
|
||||||
* - 요소 이동, 크기 조절, 삭제 기능
|
* - 요소 이동, 크기 조절, 삭제 기능
|
||||||
* - 레이아웃 저장/불러오기 기능
|
* - 레이아웃 저장/불러오기 기능
|
||||||
*/
|
*/
|
||||||
|
|
@ -19,14 +21,14 @@ export default function DashboardDesigner() {
|
||||||
const [elementCounter, setElementCounter] = useState(0);
|
const [elementCounter, setElementCounter] = useState(0);
|
||||||
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
const [configModalElement, setConfigModalElement] = useState<DashboardElement | null>(null);
|
||||||
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
const [dashboardId, setDashboardId] = useState<string | null>(null);
|
||||||
const [dashboardTitle, setDashboardTitle] = useState<string>('');
|
const [dashboardTitle, setDashboardTitle] = useState<string>("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const canvasRef = useRef<HTMLDivElement>(null);
|
const canvasRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
// URL 파라미터에서 대시보드 ID 읽기 및 데이터 로드
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
const loadId = params.get('load');
|
const loadId = params.get("load");
|
||||||
|
|
||||||
if (loadId) {
|
if (loadId) {
|
||||||
loadDashboard(loadId);
|
loadDashboard(loadId);
|
||||||
|
|
@ -39,7 +41,7 @@ export default function DashboardDesigner() {
|
||||||
try {
|
try {
|
||||||
// console.log('🔄 대시보드 로딩:', id);
|
// console.log('🔄 대시보드 로딩:', id);
|
||||||
|
|
||||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const dashboard = await dashboardApi.getDashboard(id);
|
const dashboard = await dashboardApi.getDashboard(id);
|
||||||
|
|
||||||
// console.log('✅ 대시보드 로딩 완료:', dashboard);
|
// console.log('✅ 대시보드 로딩 완료:', dashboard);
|
||||||
|
|
@ -63,55 +65,63 @@ export default function DashboardDesigner() {
|
||||||
}, 0);
|
}, 0);
|
||||||
setElementCounter(maxId);
|
setElementCounter(maxId);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('❌ 대시보드 로딩 오류:', error);
|
// console.error('❌ 대시보드 로딩 오류:', error);
|
||||||
alert('대시보드를 불러오는 중 오류가 발생했습니다.\n\n' + (error instanceof Error ? error.message : '알 수 없는 오류'));
|
alert(
|
||||||
|
"대시보드를 불러오는 중 오류가 발생했습니다.\n\n" +
|
||||||
|
(error instanceof Error ? error.message : "알 수 없는 오류"),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 새로운 요소 생성
|
// 새로운 요소 생성 (고정 그리드 기반 기본 크기)
|
||||||
const createElement = useCallback((
|
const createElement = useCallback(
|
||||||
type: ElementType,
|
(type: ElementType, subtype: ElementSubtype, x: number, y: number) => {
|
||||||
subtype: ElementSubtype,
|
// 기본 크기: 차트는 4x3 셀, 위젯은 2x2 셀
|
||||||
x: number,
|
const defaultCells = type === "chart" ? { width: 4, height: 3 } : { width: 2, height: 2 };
|
||||||
y: number
|
const cellWithGap = GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||||
) => {
|
|
||||||
const newElement: DashboardElement = {
|
|
||||||
id: `element-${elementCounter + 1}`,
|
|
||||||
type,
|
|
||||||
subtype,
|
|
||||||
position: { x, y },
|
|
||||||
size: { width: 250, height: 200 },
|
|
||||||
title: getElementTitle(type, subtype),
|
|
||||||
content: getElementContent(type, subtype)
|
|
||||||
};
|
|
||||||
|
|
||||||
setElements(prev => [...prev, newElement]);
|
const defaultWidth = defaultCells.width * cellWithGap - GRID_CONFIG.GAP;
|
||||||
setElementCounter(prev => prev + 1);
|
const defaultHeight = defaultCells.height * cellWithGap - GRID_CONFIG.GAP;
|
||||||
setSelectedElement(newElement.id);
|
|
||||||
}, [elementCounter]);
|
const newElement: DashboardElement = {
|
||||||
|
id: `element-${elementCounter + 1}`,
|
||||||
|
type,
|
||||||
|
subtype,
|
||||||
|
position: { x, y },
|
||||||
|
size: { width: defaultWidth, height: defaultHeight },
|
||||||
|
title: getElementTitle(type, subtype),
|
||||||
|
content: getElementContent(type, subtype),
|
||||||
|
};
|
||||||
|
|
||||||
|
setElements((prev) => [...prev, newElement]);
|
||||||
|
setElementCounter((prev) => prev + 1);
|
||||||
|
setSelectedElement(newElement.id);
|
||||||
|
},
|
||||||
|
[elementCounter],
|
||||||
|
);
|
||||||
|
|
||||||
// 요소 업데이트
|
// 요소 업데이트
|
||||||
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
const updateElement = useCallback((id: string, updates: Partial<DashboardElement>) => {
|
||||||
setElements(prev => prev.map(el =>
|
setElements((prev) => prev.map((el) => (el.id === id ? { ...el, ...updates } : el)));
|
||||||
el.id === id ? { ...el, ...updates } : el
|
|
||||||
));
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 요소 삭제
|
// 요소 삭제
|
||||||
const removeElement = useCallback((id: string) => {
|
const removeElement = useCallback(
|
||||||
setElements(prev => prev.filter(el => el.id !== id));
|
(id: string) => {
|
||||||
if (selectedElement === id) {
|
setElements((prev) => prev.filter((el) => el.id !== id));
|
||||||
setSelectedElement(null);
|
if (selectedElement === id) {
|
||||||
}
|
setSelectedElement(null);
|
||||||
}, [selectedElement]);
|
}
|
||||||
|
},
|
||||||
|
[selectedElement],
|
||||||
|
);
|
||||||
|
|
||||||
// 전체 삭제
|
// 전체 삭제
|
||||||
const clearCanvas = useCallback(() => {
|
const clearCanvas = useCallback(() => {
|
||||||
if (window.confirm('모든 요소를 삭제하시겠습니까?')) {
|
if (window.confirm("모든 요소를 삭제하시겠습니까?")) {
|
||||||
setElements([]);
|
setElements([]);
|
||||||
setSelectedElement(null);
|
setSelectedElement(null);
|
||||||
setElementCounter(0);
|
setElementCounter(0);
|
||||||
|
|
@ -129,22 +139,25 @@ export default function DashboardDesigner() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 요소 설정 저장
|
// 요소 설정 저장
|
||||||
const saveElementConfig = useCallback((updatedElement: DashboardElement) => {
|
const saveElementConfig = useCallback(
|
||||||
updateElement(updatedElement.id, updatedElement);
|
(updatedElement: DashboardElement) => {
|
||||||
}, [updateElement]);
|
updateElement(updatedElement.id, updatedElement);
|
||||||
|
},
|
||||||
|
[updateElement],
|
||||||
|
);
|
||||||
|
|
||||||
// 레이아웃 저장
|
// 레이아웃 저장
|
||||||
const saveLayout = useCallback(async () => {
|
const saveLayout = useCallback(async () => {
|
||||||
if (elements.length === 0) {
|
if (elements.length === 0) {
|
||||||
alert('저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.');
|
alert("저장할 요소가 없습니다. 차트나 위젯을 추가해주세요.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 실제 API 호출
|
// 실제 API 호출
|
||||||
const { dashboardApi } = await import('@/lib/api/dashboard');
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
|
|
||||||
const elementsData = elements.map(el => ({
|
const elementsData = elements.map((el) => ({
|
||||||
id: el.id,
|
id: el.id,
|
||||||
type: el.type,
|
type: el.type,
|
||||||
subtype: el.subtype,
|
subtype: el.subtype,
|
||||||
|
|
@ -153,7 +166,7 @@ export default function DashboardDesigner() {
|
||||||
title: el.title,
|
title: el.title,
|
||||||
content: el.content,
|
content: el.content,
|
||||||
dataSource: el.dataSource,
|
dataSource: el.dataSource,
|
||||||
chartConfig: el.chartConfig
|
chartConfig: el.chartConfig,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
let savedDashboard;
|
let savedDashboard;
|
||||||
|
|
@ -162,26 +175,25 @@ export default function DashboardDesigner() {
|
||||||
// 기존 대시보드 업데이트
|
// 기존 대시보드 업데이트
|
||||||
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
// console.log('🔄 대시보드 업데이트:', dashboardId);
|
||||||
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
savedDashboard = await dashboardApi.updateDashboard(dashboardId, {
|
||||||
elements: elementsData
|
elements: elementsData,
|
||||||
});
|
});
|
||||||
|
|
||||||
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
alert(`대시보드 "${savedDashboard.title}"이 업데이트되었습니다!`);
|
||||||
|
|
||||||
// 뷰어 페이지로 이동
|
// 뷰어 페이지로 이동
|
||||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// 새 대시보드 생성
|
// 새 대시보드 생성
|
||||||
const title = prompt('대시보드 제목을 입력하세요:', '새 대시보드');
|
const title = prompt("대시보드 제목을 입력하세요:", "새 대시보드");
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
const description = prompt('대시보드 설명을 입력하세요 (선택사항):', '');
|
const description = prompt("대시보드 설명을 입력하세요 (선택사항):", "");
|
||||||
|
|
||||||
const dashboardData = {
|
const dashboardData = {
|
||||||
title,
|
title,
|
||||||
description: description || undefined,
|
description: description || undefined,
|
||||||
isPublic: false,
|
isPublic: false,
|
||||||
elements: elementsData
|
elements: elementsData,
|
||||||
};
|
};
|
||||||
|
|
||||||
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
savedDashboard = await dashboardApi.createDashboard(dashboardData);
|
||||||
|
|
@ -193,11 +205,10 @@ export default function DashboardDesigner() {
|
||||||
window.location.href = `/dashboard/${savedDashboard.id}`;
|
window.location.href = `/dashboard/${savedDashboard.id}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.error('❌ 저장 오류:', error);
|
// console.error('❌ 저장 오류:', error);
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류';
|
const errorMessage = error instanceof Error ? error.message : "알 수 없는 오류";
|
||||||
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
alert(`대시보드 저장 중 오류가 발생했습니다.\n\n오류: ${errorMessage}\n\n관리자에게 문의하세요.`);
|
||||||
}
|
}
|
||||||
}, [elements, dashboardId]);
|
}, [elements, dashboardId]);
|
||||||
|
|
@ -207,9 +218,9 @@ export default function DashboardDesigner() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full items-center justify-center bg-gray-50">
|
<div className="flex h-full items-center justify-center bg-gray-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="w-12 h-12 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
<div className="border-primary mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-4 border-t-transparent" />
|
||||||
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
<div className="text-lg font-medium text-gray-700">대시보드 로딩 중...</div>
|
||||||
<div className="text-sm text-gray-500 mt-1">잠시만 기다려주세요</div>
|
<div className="mt-1 text-sm text-gray-500">잠시만 기다려주세요</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -218,28 +229,29 @@ export default function DashboardDesigner() {
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full bg-gray-50">
|
<div className="flex h-full bg-gray-50">
|
||||||
{/* 캔버스 영역 */}
|
{/* 캔버스 영역 */}
|
||||||
<div className="flex-1 relative overflow-auto border-r-2 border-gray-300">
|
<div className="relative flex-1 overflow-auto border-r-2 border-gray-300 bg-gray-100">
|
||||||
{/* 편집 중인 대시보드 표시 */}
|
{/* 편집 중인 대시보드 표시 */}
|
||||||
{dashboardTitle && (
|
{dashboardTitle && (
|
||||||
<div className="absolute top-2 left-2 z-10 bg-accent0 text-white px-3 py-1 rounded-lg text-sm font-medium shadow-lg">
|
<div className="bg-accent0 absolute top-6 left-6 z-10 rounded-lg px-3 py-1 text-sm font-medium text-white shadow-lg">
|
||||||
📝 편집 중: {dashboardTitle}
|
📝 편집 중: {dashboardTitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<DashboardToolbar
|
<DashboardToolbar onClearCanvas={clearCanvas} onSaveLayout={saveLayout} />
|
||||||
onClearCanvas={clearCanvas}
|
|
||||||
onSaveLayout={saveLayout}
|
{/* 캔버스 중앙 정렬 컨테이너 */}
|
||||||
/>
|
<div className="flex justify-center p-4">
|
||||||
<DashboardCanvas
|
<DashboardCanvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
elements={elements}
|
elements={elements}
|
||||||
selectedElement={selectedElement}
|
selectedElement={selectedElement}
|
||||||
onCreateElement={createElement}
|
onCreateElement={createElement}
|
||||||
onUpdateElement={updateElement}
|
onUpdateElement={updateElement}
|
||||||
onRemoveElement={removeElement}
|
onRemoveElement={removeElement}
|
||||||
onSelectElement={setSelectedElement}
|
onSelectElement={setSelectedElement}
|
||||||
onConfigureElement={openConfigModal}
|
onConfigureElement={openConfigModal}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 사이드바 */}
|
{/* 사이드바 */}
|
||||||
|
|
@ -260,38 +272,52 @@ export default function DashboardDesigner() {
|
||||||
|
|
||||||
// 요소 제목 생성 헬퍼 함수
|
// 요소 제목 생성 헬퍼 함수
|
||||||
function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
function getElementTitle(type: ElementType, subtype: ElementSubtype): string {
|
||||||
if (type === 'chart') {
|
if (type === "chart") {
|
||||||
switch (subtype) {
|
switch (subtype) {
|
||||||
case 'bar': return '📊 바 차트';
|
case "bar":
|
||||||
case 'pie': return '🥧 원형 차트';
|
return "📊 바 차트";
|
||||||
case 'line': return '📈 꺾은선 차트';
|
case "pie":
|
||||||
default: return '📊 차트';
|
return "🥧 원형 차트";
|
||||||
|
case "line":
|
||||||
|
return "📈 꺾은선 차트";
|
||||||
|
default:
|
||||||
|
return "📊 차트";
|
||||||
}
|
}
|
||||||
} else if (type === 'widget') {
|
} else if (type === "widget") {
|
||||||
switch (subtype) {
|
switch (subtype) {
|
||||||
case 'exchange': return '💱 환율 위젯';
|
case "exchange":
|
||||||
case 'weather': return '☁️ 날씨 위젯';
|
return "💱 환율 위젯";
|
||||||
default: return '🔧 위젯';
|
case "weather":
|
||||||
|
return "☁️ 날씨 위젯";
|
||||||
|
default:
|
||||||
|
return "🔧 위젯";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '요소';
|
return "요소";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 요소 내용 생성 헬퍼 함수
|
// 요소 내용 생성 헬퍼 함수
|
||||||
function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
function getElementContent(type: ElementType, subtype: ElementSubtype): string {
|
||||||
if (type === 'chart') {
|
if (type === "chart") {
|
||||||
switch (subtype) {
|
switch (subtype) {
|
||||||
case 'bar': return '바 차트가 여기에 표시됩니다';
|
case "bar":
|
||||||
case 'pie': return '원형 차트가 여기에 표시됩니다';
|
return "바 차트가 여기에 표시됩니다";
|
||||||
case 'line': return '꺾은선 차트가 여기에 표시됩니다';
|
case "pie":
|
||||||
default: return '차트가 여기에 표시됩니다';
|
return "원형 차트가 여기에 표시됩니다";
|
||||||
|
case "line":
|
||||||
|
return "꺾은선 차트가 여기에 표시됩니다";
|
||||||
|
default:
|
||||||
|
return "차트가 여기에 표시됩니다";
|
||||||
}
|
}
|
||||||
} else if (type === 'widget') {
|
} else if (type === "widget") {
|
||||||
switch (subtype) {
|
switch (subtype) {
|
||||||
case 'exchange': return 'USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450';
|
case "exchange":
|
||||||
case 'weather': return '서울\n23°C\n구름 많음';
|
return "USD: ₩1,320\nJPY: ₩900\nEUR: ₩1,450";
|
||||||
default: return '위젯 내용이 여기에 표시됩니다';
|
case "weather":
|
||||||
|
return "서울\n23°C\n구름 많음";
|
||||||
|
default:
|
||||||
|
return "위젯 내용이 여기에 표시됩니다";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return '내용이 여기에 표시됩니다';
|
return "내용이 여기에 표시됩니다";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
'use client';
|
"use client";
|
||||||
|
|
||||||
import React from 'react';
|
import React from "react";
|
||||||
import { DragData, ElementType, ElementSubtype } from './types';
|
import { DragData, ElementType, ElementSubtype } from "./types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 대시보드 사이드바 컴포넌트
|
* 대시보드 사이드바 컴포넌트
|
||||||
|
|
@ -12,17 +12,15 @@ export function DashboardSidebar() {
|
||||||
// 드래그 시작 처리
|
// 드래그 시작 처리
|
||||||
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
|
const handleDragStart = (e: React.DragEvent, type: ElementType, subtype: ElementSubtype) => {
|
||||||
const dragData: DragData = { type, subtype };
|
const dragData: DragData = { type, subtype };
|
||||||
e.dataTransfer.setData('application/json', JSON.stringify(dragData));
|
e.dataTransfer.setData("application/json", JSON.stringify(dragData));
|
||||||
e.dataTransfer.effectAllowed = 'copy';
|
e.dataTransfer.effectAllowed = "copy";
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-white border-l border-gray-200 overflow-y-auto p-5">
|
<div className="w-[370px] overflow-y-auto border-l border-gray-200 bg-white p-6">
|
||||||
{/* 차트 섹션 */}
|
{/* 차트 섹션 */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">📊 차트 종류</h3>
|
||||||
📊 차트 종류
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<DraggableItem
|
<DraggableItem
|
||||||
|
|
@ -31,7 +29,7 @@ export function DashboardSidebar() {
|
||||||
type="chart"
|
type="chart"
|
||||||
subtype="bar"
|
subtype="bar"
|
||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
className="border-l-4 border-primary"
|
className="border-primary border-l-4"
|
||||||
/>
|
/>
|
||||||
<DraggableItem
|
<DraggableItem
|
||||||
icon="📚"
|
icon="📚"
|
||||||
|
|
@ -86,9 +84,7 @@ export function DashboardSidebar() {
|
||||||
|
|
||||||
{/* 위젯 섹션 */}
|
{/* 위젯 섹션 */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<h3 className="text-gray-800 mb-4 pb-3 border-b-2 border-green-500 font-semibold text-lg">
|
<h3 className="mb-4 border-b-2 border-green-500 pb-3 text-lg font-semibold text-gray-800">🔧 위젯 종류</h3>
|
||||||
🔧 위젯 종류
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<DraggableItem
|
<DraggableItem
|
||||||
|
|
@ -125,20 +121,14 @@ interface DraggableItemProps {
|
||||||
/**
|
/**
|
||||||
* 드래그 가능한 아이템 컴포넌트
|
* 드래그 가능한 아이템 컴포넌트
|
||||||
*/
|
*/
|
||||||
function DraggableItem({ icon, title, type, subtype, className = '', onDragStart }: DraggableItemProps) {
|
function DraggableItem({ icon, title, type, subtype, className = "", onDragStart }: DraggableItemProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable
|
draggable
|
||||||
className={`
|
className={`cursor-move rounded-lg border-2 border-gray-200 bg-white p-4 text-center text-sm font-medium transition-all duration-200 hover:translate-x-1 hover:border-green-500 hover:bg-gray-50 ${className} `}
|
||||||
p-4 bg-white border-2 border-gray-200 rounded-lg
|
|
||||||
cursor-move transition-all duration-200
|
|
||||||
hover:bg-gray-50 hover:border-green-500 hover:translate-x-1
|
|
||||||
text-center text-sm font-medium
|
|
||||||
${className}
|
|
||||||
`}
|
|
||||||
onDragStart={(e) => onDragStart(e, type, subtype)}
|
onDragStart={(e) => onDragStart(e, type, subtype)}
|
||||||
>
|
>
|
||||||
<span className="text-lg mr-2">{icon}</span>
|
<span className="mr-2 text-lg">{icon}</span>
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
# 대시보드 그리드 시스템
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
대시보드 캔버스는 **12 컬럼 그리드 시스템**을 사용하여 요소를 정렬하고 배치합니다.
|
||||||
|
모든 요소는 드래그 또는 리사이즈 종료 시 자동으로 그리드에 스냅됩니다.
|
||||||
|
|
||||||
|
## 그리드 설정
|
||||||
|
|
||||||
|
### 기본 설정 (`gridUtils.ts`)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
GRID_CONFIG = {
|
||||||
|
COLUMNS: 12, // 12 컬럼
|
||||||
|
CELL_SIZE: 60, // 60px x 60px 정사각형 셀
|
||||||
|
GAP: 8, // 셀 간격 8px
|
||||||
|
SNAP_THRESHOLD: 15, // 스냅 임계값 15px
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 실제 그리드 크기
|
||||||
|
|
||||||
|
- **셀 크기 (gap 포함)**: 68px (60px + 8px)
|
||||||
|
- **전체 캔버스 너비**: 808px (12 \* 68px - 8px)
|
||||||
|
- **셀 비율**: 1:1 (정사각형)
|
||||||
|
|
||||||
|
## 스냅 기능
|
||||||
|
|
||||||
|
### 1. 위치 스냅
|
||||||
|
|
||||||
|
요소를 드래그하여 이동할 때:
|
||||||
|
|
||||||
|
- **드래그 중**: 자유롭게 이동 (그리드 무시)
|
||||||
|
- **드래그 종료**: 가장 가까운 그리드 포인트에 자동 스냅
|
||||||
|
- **스냅 계산**: `Math.round(value / 68) * 68`
|
||||||
|
|
||||||
|
### 2. 크기 스냅
|
||||||
|
|
||||||
|
요소의 크기를 조절할 때:
|
||||||
|
|
||||||
|
- **리사이즈 중**: 자유롭게 크기 조절
|
||||||
|
- **리사이즈 종료**: 그리드 단위로 스냅
|
||||||
|
- **최소 크기**: 2셀 x 2셀 (136px x 136px)
|
||||||
|
|
||||||
|
### 3. 드롭 스냅
|
||||||
|
|
||||||
|
사이드바에서 새 요소를 드래그 앤 드롭할 때:
|
||||||
|
|
||||||
|
- 드롭 위치가 자동으로 가장 가까운 그리드 포인트에 스냅
|
||||||
|
- 기본 크기:
|
||||||
|
- 차트: 4 x 3 셀 (264px x 196px)
|
||||||
|
- 위젯: 2 x 2 셀 (136px x 136px)
|
||||||
|
|
||||||
|
## 시각적 피드백
|
||||||
|
|
||||||
|
### 그리드 배경
|
||||||
|
|
||||||
|
캔버스 배경에 그리드 라인이 표시됩니다:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
backgroundImage: `
|
||||||
|
linear-gradient(rgba(59, 130, 246, 0.1) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(59, 130, 246, 0.1) 1px, transparent 1px)
|
||||||
|
`,
|
||||||
|
backgroundSize: '68px 68px'
|
||||||
|
```
|
||||||
|
|
||||||
|
### 요소 테두리
|
||||||
|
|
||||||
|
- **선택 안 됨**: 회색 테두리
|
||||||
|
- **선택됨**: 파란색 테두리 + 리사이즈 핸들 표시
|
||||||
|
- **드래그/리사이즈 중**: 트랜지션 비활성화 (부드러운 움직임)
|
||||||
|
|
||||||
|
## 사용 예시
|
||||||
|
|
||||||
|
### 기본 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { snapToGrid, snapSizeToGrid } from "./gridUtils";
|
||||||
|
|
||||||
|
// 위치 스냅
|
||||||
|
const snappedX = snapToGrid(123); // 136 (가장 가까운 그리드)
|
||||||
|
const snappedY = snapToGrid(45); // 68
|
||||||
|
|
||||||
|
// 크기 스냅
|
||||||
|
const snappedWidth = snapSizeToGrid(250); // 264 (4셀)
|
||||||
|
const snappedHeight = snapSizeToGrid(180); // 196 (3셀)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 경계 체크와 함께
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { snapBoundsToGrid } from "./gridUtils";
|
||||||
|
|
||||||
|
const snapped = snapBoundsToGrid(
|
||||||
|
{
|
||||||
|
position: { x: 123, y: 45 },
|
||||||
|
size: { width: 250, height: 180 },
|
||||||
|
},
|
||||||
|
canvasWidth,
|
||||||
|
canvasHeight,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과:
|
||||||
|
// {
|
||||||
|
// position: { x: 136, y: 68 },
|
||||||
|
// size: { width: 264, height: 196 }
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 그리드 인덱스
|
||||||
|
|
||||||
|
### 좌표 → 인덱스
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getGridIndex } from "./gridUtils";
|
||||||
|
|
||||||
|
const colIndex = getGridIndex(150); // 2 (3번째 컬럼)
|
||||||
|
const rowIndex = getGridIndex(100); // 1 (2번째 행)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 인덱스 → 좌표
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { gridIndexToCoordinate } from "./gridUtils";
|
||||||
|
|
||||||
|
const x = gridIndexToCoordinate(0); // 0 (1번째 컬럼)
|
||||||
|
const y = gridIndexToCoordinate(1); // 68 (2번째 행)
|
||||||
|
const z = gridIndexToCoordinate(11); // 748 (12번째 컬럼)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 레이아웃 권장사항
|
||||||
|
|
||||||
|
### 일반 차트
|
||||||
|
|
||||||
|
- **권장 크기**: 4 x 3 셀 (264px x 196px)
|
||||||
|
- **최소 크기**: 2 x 2 셀 (136px x 136px)
|
||||||
|
- **최대 크기**: 12 x 8 셀 (808px x 536px)
|
||||||
|
|
||||||
|
### 작은 위젯
|
||||||
|
|
||||||
|
- **권장 크기**: 2 x 2 셀 (136px x 136px)
|
||||||
|
- **최소 크기**: 2 x 2 셀
|
||||||
|
- **최대 크기**: 4 x 4 셀 (264px x 264px)
|
||||||
|
|
||||||
|
### 큰 차트/대시보드
|
||||||
|
|
||||||
|
- **권장 크기**: 6 x 4 셀 (400px x 264px)
|
||||||
|
- **풀 너비**: 12 셀 (808px)
|
||||||
|
|
||||||
|
## 커스터마이징
|
||||||
|
|
||||||
|
### 그리드 크기 변경
|
||||||
|
|
||||||
|
`gridUtils.ts`의 `GRID_CONFIG`를 수정:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export const GRID_CONFIG = {
|
||||||
|
COLUMNS: 12,
|
||||||
|
CELL_SIZE: 80, // 60 → 80 (셀 크기 증가)
|
||||||
|
GAP: 16, // 8 → 16 (간격 증가)
|
||||||
|
SNAP_THRESHOLD: 20, // 15 → 20 (스냅 범위 증가)
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 스냅 비활성화
|
||||||
|
|
||||||
|
특정 요소에서 스냅을 비활성화하려면:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 드래그 종료 시 스냅하지 않고 그냥 업데이트
|
||||||
|
onUpdate(element.id, {
|
||||||
|
position: { x: rawX, y: rawY }, // snapToGrid 호출 안 함
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 성능 최적화
|
||||||
|
|
||||||
|
### 트랜지션 제어
|
||||||
|
|
||||||
|
드래그/리사이즈 중에는 CSS 트랜지션을 비활성화:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
className={`
|
||||||
|
${(isDragging || isResizing) ? 'transition-none' : 'transition-all duration-150'}
|
||||||
|
`}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 임시 상태 사용
|
||||||
|
|
||||||
|
마우스 이동 중에는 임시 위치/크기만 업데이트하고,
|
||||||
|
마우스 업 시에만 실제 스냅된 값으로 업데이트:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 드래그 중
|
||||||
|
setTempPosition({ x: rawX, y: rawY });
|
||||||
|
|
||||||
|
// 드래그 종료
|
||||||
|
const snapped = snapToGrid(tempPosition.x);
|
||||||
|
onUpdate(element.id, { position: { x: snapped, y: snapped } });
|
||||||
|
```
|
||||||
|
|
||||||
|
## 문제 해결
|
||||||
|
|
||||||
|
### 요소가 스냅되지 않는 경우
|
||||||
|
|
||||||
|
1. `snapToGrid` 함수가 호출되는지 확인
|
||||||
|
2. `SNAP_THRESHOLD` 값 확인 (너무 작으면 스냅 안 됨)
|
||||||
|
3. 임시 상태가 제대로 초기화되는지 확인
|
||||||
|
|
||||||
|
### 그리드가 보이지 않는 경우
|
||||||
|
|
||||||
|
1. 캔버스의 `backgroundImage` 스타일 확인
|
||||||
|
2. `getCellWithGap()` 반환값 확인
|
||||||
|
3. 브라우저 개발자 도구에서 배경 스타일 검사
|
||||||
|
|
||||||
|
### 성능 문제
|
||||||
|
|
||||||
|
1. 트랜지션이 비활성화되었는지 확인
|
||||||
|
2. 불필요한 리렌더링 방지 (React.memo 사용)
|
||||||
|
3. 마우스 이벤트 리스너가 제대로 제거되는지 확인
|
||||||
|
|
||||||
|
## 참고 자료
|
||||||
|
|
||||||
|
- **그리드 유틸리티**: `gridUtils.ts`
|
||||||
|
- **캔버스 컴포넌트**: `DashboardCanvas.tsx`
|
||||||
|
- **요소 컴포넌트**: `CanvasElement.tsx`
|
||||||
|
- **디자이너**: `DashboardDesigner.tsx`
|
||||||
|
|
@ -0,0 +1,169 @@
|
||||||
|
/**
|
||||||
|
* 대시보드 그리드 시스템 유틸리티
|
||||||
|
* - 12 컬럼 그리드 시스템
|
||||||
|
* - 정사각형 셀 (가로 = 세로)
|
||||||
|
* - 스냅 기능
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 그리드 설정 (고정 크기)
|
||||||
|
export const GRID_CONFIG = {
|
||||||
|
COLUMNS: 12,
|
||||||
|
CELL_SIZE: 132, // 고정 셀 크기
|
||||||
|
GAP: 8, // 셀 간격
|
||||||
|
SNAP_THRESHOLD: 15, // 스냅 임계값 (px)
|
||||||
|
ELEMENT_PADDING: 4, // 요소 주위 여백 (px)
|
||||||
|
CANVAS_WIDTH: 1682, // 고정 캔버스 너비 (실제 측정값)
|
||||||
|
// 계산식: (132 + 8) × 12 - 8 = 1672px (그리드)
|
||||||
|
// 추가 여백 10px 포함 = 1682px
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실제 그리드 셀 크기 계산 (gap 포함)
|
||||||
|
*/
|
||||||
|
export const getCellWithGap = () => {
|
||||||
|
return GRID_CONFIG.CELL_SIZE + GRID_CONFIG.GAP;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 캔버스 너비 계산
|
||||||
|
*/
|
||||||
|
export const getCanvasWidth = () => {
|
||||||
|
const cellWithGap = getCellWithGap();
|
||||||
|
return GRID_CONFIG.COLUMNS * cellWithGap - GRID_CONFIG.GAP;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표를 가장 가까운 그리드 포인트로 스냅 (여백 포함)
|
||||||
|
* @param value - 스냅할 좌표값
|
||||||
|
* @param cellSize - 셀 크기 (선택사항, 기본값은 GRID_CONFIG.CELL_SIZE)
|
||||||
|
* @returns 스냅된 좌표값 (여백 포함)
|
||||||
|
*/
|
||||||
|
export const snapToGrid = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||||
|
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||||
|
const gridIndex = Math.round(value / cellWithGap);
|
||||||
|
return gridIndex * cellWithGap + GRID_CONFIG.ELEMENT_PADDING;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표를 그리드에 스냅 (임계값 적용)
|
||||||
|
* @param value - 현재 좌표값
|
||||||
|
* @param cellSize - 셀 크기 (선택사항)
|
||||||
|
* @returns 스냅된 좌표값 (임계값 내에 있으면 스냅, 아니면 원래 값)
|
||||||
|
*/
|
||||||
|
export const snapToGridWithThreshold = (value: number, cellSize: number = GRID_CONFIG.CELL_SIZE): number => {
|
||||||
|
const snapped = snapToGrid(value, cellSize);
|
||||||
|
const distance = Math.abs(value - snapped);
|
||||||
|
|
||||||
|
return distance <= GRID_CONFIG.SNAP_THRESHOLD ? snapped : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 크기를 그리드 단위로 스냅
|
||||||
|
* @param size - 스냅할 크기
|
||||||
|
* @param minCells - 최소 셀 개수 (기본값: 2)
|
||||||
|
* @param cellSize - 셀 크기 (선택사항)
|
||||||
|
* @returns 스냅된 크기
|
||||||
|
*/
|
||||||
|
export const snapSizeToGrid = (
|
||||||
|
size: number,
|
||||||
|
minCells: number = 2,
|
||||||
|
cellSize: number = GRID_CONFIG.CELL_SIZE,
|
||||||
|
): number => {
|
||||||
|
const cellWithGap = cellSize + GRID_CONFIG.GAP;
|
||||||
|
const cells = Math.max(minCells, Math.round(size / cellWithGap));
|
||||||
|
return cells * cellWithGap - GRID_CONFIG.GAP;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치와 크기를 모두 그리드에 스냅
|
||||||
|
*/
|
||||||
|
export interface GridPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridSize {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GridBounds {
|
||||||
|
position: GridPosition;
|
||||||
|
size: GridSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 요소의 위치와 크기를 그리드에 맞춰 조정
|
||||||
|
* @param bounds - 현재 위치와 크기
|
||||||
|
* @param canvasWidth - 캔버스 너비 (경계 체크용)
|
||||||
|
* @param canvasHeight - 캔버스 높이 (경계 체크용)
|
||||||
|
* @returns 그리드에 스냅된 위치와 크기
|
||||||
|
*/
|
||||||
|
export const snapBoundsToGrid = (bounds: GridBounds, canvasWidth?: number, canvasHeight?: number): GridBounds => {
|
||||||
|
// 위치 스냅
|
||||||
|
let snappedX = snapToGrid(bounds.position.x);
|
||||||
|
let snappedY = snapToGrid(bounds.position.y);
|
||||||
|
|
||||||
|
// 크기 스냅
|
||||||
|
const snappedWidth = snapSizeToGrid(bounds.size.width);
|
||||||
|
const snappedHeight = snapSizeToGrid(bounds.size.height);
|
||||||
|
|
||||||
|
// 캔버스 경계 체크
|
||||||
|
if (canvasWidth) {
|
||||||
|
snappedX = Math.min(snappedX, canvasWidth - snappedWidth);
|
||||||
|
}
|
||||||
|
if (canvasHeight) {
|
||||||
|
snappedY = Math.min(snappedY, canvasHeight - snappedHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 음수 방지
|
||||||
|
snappedX = Math.max(0, snappedX);
|
||||||
|
snappedY = Math.max(0, snappedY);
|
||||||
|
|
||||||
|
return {
|
||||||
|
position: { x: snappedX, y: snappedY },
|
||||||
|
size: { width: snappedWidth, height: snappedHeight },
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표가 어느 그리드 셀에 속하는지 계산
|
||||||
|
* @param value - 좌표값
|
||||||
|
* @returns 그리드 인덱스 (0부터 시작)
|
||||||
|
*/
|
||||||
|
export const getGridIndex = (value: number): number => {
|
||||||
|
const cellWithGap = getCellWithGap();
|
||||||
|
return Math.floor(value / cellWithGap);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그리드 인덱스를 좌표로 변환
|
||||||
|
* @param index - 그리드 인덱스
|
||||||
|
* @returns 좌표값
|
||||||
|
*/
|
||||||
|
export const gridIndexToCoordinate = (index: number): number => {
|
||||||
|
const cellWithGap = getCellWithGap();
|
||||||
|
return index * cellWithGap;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 스냅 가이드라인 표시용 좌표 계산
|
||||||
|
* @param value - 현재 좌표
|
||||||
|
* @returns 가장 가까운 그리드 라인들의 좌표 배열
|
||||||
|
*/
|
||||||
|
export const getNearbyGridLines = (value: number): number[] => {
|
||||||
|
const snapped = snapToGrid(value);
|
||||||
|
const cellWithGap = getCellWithGap();
|
||||||
|
|
||||||
|
return [snapped - cellWithGap, snapped, snapped + cellWithGap].filter((line) => line >= 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 위치가 스냅 임계값 내에 있는지 확인
|
||||||
|
* @param value - 현재 값
|
||||||
|
* @param snapValue - 스냅할 값
|
||||||
|
* @returns 임계값 내에 있으면 true
|
||||||
|
*/
|
||||||
|
export const isWithinSnapThreshold = (value: number, snapValue: number): boolean => {
|
||||||
|
return Math.abs(value - snapValue) <= GRID_CONFIG.SNAP_THRESHOLD;
|
||||||
|
};
|
||||||
|
|
@ -16,7 +16,6 @@ import { TableSourceNode } from "./nodes/TableSourceNode";
|
||||||
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
import { ExternalDBSourceNode } from "./nodes/ExternalDBSourceNode";
|
||||||
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
|
import { ReferenceLookupNode } from "./nodes/ReferenceLookupNode";
|
||||||
import { ConditionNode } from "./nodes/ConditionNode";
|
import { ConditionNode } from "./nodes/ConditionNode";
|
||||||
import { FieldMappingNode } from "./nodes/FieldMappingNode";
|
|
||||||
import { InsertActionNode } from "./nodes/InsertActionNode";
|
import { InsertActionNode } from "./nodes/InsertActionNode";
|
||||||
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
import { UpdateActionNode } from "./nodes/UpdateActionNode";
|
||||||
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
import { DeleteActionNode } from "./nodes/DeleteActionNode";
|
||||||
|
|
@ -35,7 +34,6 @@ const nodeTypes = {
|
||||||
referenceLookup: ReferenceLookupNode,
|
referenceLookup: ReferenceLookupNode,
|
||||||
// 변환/조건
|
// 변환/조건
|
||||||
condition: ConditionNode,
|
condition: ConditionNode,
|
||||||
fieldMapping: FieldMappingNode,
|
|
||||||
dataTransform: DataTransformNode,
|
dataTransform: DataTransformNode,
|
||||||
// 액션
|
// 액션
|
||||||
insertAction: InsertActionNode,
|
insertAction: InsertActionNode,
|
||||||
|
|
@ -60,11 +58,14 @@ function FlowEditorInner() {
|
||||||
onNodesChange,
|
onNodesChange,
|
||||||
onEdgesChange,
|
onEdgesChange,
|
||||||
onConnect,
|
onConnect,
|
||||||
|
onNodeDragStart,
|
||||||
addNode,
|
addNode,
|
||||||
showPropertiesPanel,
|
showPropertiesPanel,
|
||||||
selectNodes,
|
selectNodes,
|
||||||
selectedNodes,
|
selectedNodes,
|
||||||
removeNodes,
|
removeNodes,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
} = useFlowEditorStore();
|
} = useFlowEditorStore();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -80,17 +81,37 @@ function FlowEditorInner() {
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제)
|
* 키보드 이벤트 핸들러 (Delete/Backspace 키로 노드 삭제, Ctrl+Z/Y로 Undo/Redo)
|
||||||
*/
|
*/
|
||||||
const onKeyDown = useCallback(
|
const onKeyDown = useCallback(
|
||||||
(event: React.KeyboardEvent) => {
|
(event: React.KeyboardEvent) => {
|
||||||
|
// Undo: Ctrl+Z (Windows/Linux) or Cmd+Z (Mac)
|
||||||
|
if ((event.ctrlKey || event.metaKey) && event.key === "z" && !event.shiftKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("⏪ Undo");
|
||||||
|
undo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Redo: Ctrl+Y (Windows/Linux) or Cmd+Shift+Z (Mac) or Ctrl+Shift+Z
|
||||||
|
if (
|
||||||
|
((event.ctrlKey || event.metaKey) && event.key === "y") ||
|
||||||
|
((event.ctrlKey || event.metaKey) && event.shiftKey && event.key === "z")
|
||||||
|
) {
|
||||||
|
event.preventDefault();
|
||||||
|
console.log("⏩ Redo");
|
||||||
|
redo();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete: Delete/Backspace 키로 노드 삭제
|
||||||
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
if ((event.key === "Delete" || event.key === "Backspace") && selectedNodes.length > 0) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
console.log("🗑️ 선택된 노드 삭제:", selectedNodes);
|
||||||
removeNodes(selectedNodes);
|
removeNodes(selectedNodes);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[selectedNodes, removeNodes],
|
[selectedNodes, removeNodes, undo, redo],
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -170,6 +191,7 @@ function FlowEditorInner() {
|
||||||
onNodesChange={onNodesChange}
|
onNodesChange={onNodesChange}
|
||||||
onEdgesChange={onEdgesChange}
|
onEdgesChange={onEdgesChange}
|
||||||
onConnect={onConnect}
|
onConnect={onConnect}
|
||||||
|
onNodeDragStart={onNodeDragStart}
|
||||||
onSelectionChange={onSelectionChange}
|
onSelectionChange={onSelectionChange}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
onDrop={onDrop}
|
onDrop={onDrop}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,10 @@ export function FlowToolbar() {
|
||||||
isSaving,
|
isSaving,
|
||||||
selectedNodes,
|
selectedNodes,
|
||||||
removeNodes,
|
removeNodes,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
} = useFlowEditorStore();
|
} = useFlowEditorStore();
|
||||||
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
const [showLoadDialog, setShowLoadDialog] = useState(false);
|
||||||
|
|
||||||
|
|
@ -108,10 +112,10 @@ export function FlowToolbar() {
|
||||||
<div className="h-6 w-px bg-gray-200" />
|
<div className="h-6 w-px bg-gray-200" />
|
||||||
|
|
||||||
{/* 실행 취소/다시 실행 */}
|
{/* 실행 취소/다시 실행 */}
|
||||||
<Button variant="ghost" size="sm" title="실행 취소" disabled>
|
<Button variant="ghost" size="sm" title="실행 취소 (Ctrl+Z)" disabled={!canUndo()} onClick={undo}>
|
||||||
<Undo2 className="h-4 w-4" />
|
<Undo2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="ghost" size="sm" title="다시 실행" disabled>
|
<Button variant="ghost" size="sm" title="다시 실행 (Ctrl+Y)" disabled={!canRedo()} onClick={redo}>
|
||||||
<Redo2 className="h-4 w-4" />
|
<Redo2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,66 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필드 매핑 노드
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { memo } from "react";
|
|
||||||
import { Handle, Position, NodeProps } from "reactflow";
|
|
||||||
import { ArrowLeftRight } from "lucide-react";
|
|
||||||
import type { FieldMappingNodeData } from "@/types/node-editor";
|
|
||||||
|
|
||||||
export const FieldMappingNode = memo(({ data, selected }: NodeProps<FieldMappingNodeData>) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`min-w-[250px] rounded-lg border-2 bg-white shadow-md transition-all ${
|
|
||||||
selected ? "border-purple-500 shadow-lg" : "border-gray-200"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* 입력 핸들 */}
|
|
||||||
<Handle type="target" position={Position.Left} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
|
|
||||||
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="flex items-center gap-2 rounded-t-lg bg-purple-500 px-3 py-2 text-white">
|
|
||||||
<ArrowLeftRight className="h-4 w-4" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="text-sm font-semibold">필드 매핑</div>
|
|
||||||
<div className="text-xs opacity-80">{data.displayName || "데이터 매핑"}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 본문 */}
|
|
||||||
<div className="p-3">
|
|
||||||
{data.mappings && data.mappings.length > 0 ? (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<div className="text-xs font-medium text-gray-700">매핑 규칙: ({data.mappings.length}개)</div>
|
|
||||||
<div className="max-h-[150px] space-y-1 overflow-y-auto">
|
|
||||||
{data.mappings.slice(0, 5).map((mapping) => (
|
|
||||||
<div key={mapping.id} className="rounded bg-gray-50 px-2 py-1 text-xs">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-mono text-gray-600">{mapping.sourceField || "정적값"}</span>
|
|
||||||
<span className="text-purple-500">→</span>
|
|
||||||
<span className="font-mono text-gray-700">{mapping.targetField}</span>
|
|
||||||
</div>
|
|
||||||
{mapping.transform && <div className="mt-0.5 text-xs text-gray-400">변환: {mapping.transform}</div>}
|
|
||||||
{mapping.staticValue !== undefined && (
|
|
||||||
<div className="mt-0.5 text-xs text-gray-400">값: {String(mapping.staticValue)}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{data.mappings.length > 5 && (
|
|
||||||
<div className="text-xs text-gray-400">... 외 {data.mappings.length - 5}개</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center text-xs text-gray-400">매핑 규칙 없음</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 출력 핸들 */}
|
|
||||||
<Handle type="source" position={Position.Right} className="!h-3 !w-3 !border-2 !border-purple-500 !bg-white" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
FieldMappingNode.displayName = "FieldMappingNode";
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
||||||
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
import { TableSourceProperties } from "./properties/TableSourceProperties";
|
||||||
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
|
import { ReferenceLookupProperties } from "./properties/ReferenceLookupProperties";
|
||||||
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
import { InsertActionProperties } from "./properties/InsertActionProperties";
|
||||||
import { FieldMappingProperties } from "./properties/FieldMappingProperties";
|
|
||||||
import { ConditionProperties } from "./properties/ConditionProperties";
|
import { ConditionProperties } from "./properties/ConditionProperties";
|
||||||
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
|
import { UpdateActionProperties } from "./properties/UpdateActionProperties";
|
||||||
import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
import { DeleteActionProperties } from "./properties/DeleteActionProperties";
|
||||||
|
|
@ -84,9 +83,6 @@ function NodePropertiesRenderer({ node }: { node: any }) {
|
||||||
case "insertAction":
|
case "insertAction":
|
||||||
return <InsertActionProperties nodeId={node.id} data={node.data} />;
|
return <InsertActionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
case "fieldMapping":
|
|
||||||
return <FieldMappingProperties nodeId={node.id} data={node.data} />;
|
|
||||||
|
|
||||||
case "condition":
|
case "condition":
|
||||||
return <ConditionProperties nodeId={node.id} data={node.data} />;
|
return <ConditionProperties nodeId={node.id} data={node.data} />;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,24 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
} else {
|
} else {
|
||||||
fields.push(...upperFields);
|
fields.push(...upperFields);
|
||||||
}
|
}
|
||||||
|
} else if (sourceNode.type === "restAPISource") {
|
||||||
|
// REST API Source: responseFields 사용
|
||||||
|
if (sourceData.responseFields && Array.isArray(sourceData.responseFields)) {
|
||||||
|
console.log("🔍 [ConditionProperties] REST API 필드:", sourceData.responseFields);
|
||||||
|
fields.push(
|
||||||
|
...sourceData.responseFields.map((f: any) => ({
|
||||||
|
name: f.name || f.fieldName,
|
||||||
|
label: f.label || f.displayName || f.name,
|
||||||
|
type: f.dataType || f.type,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ [ConditionProperties] REST API에 필드 없음:", sourceData);
|
||||||
|
}
|
||||||
|
} else if (sourceNode.type === "condition") {
|
||||||
|
// 조건 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드)
|
||||||
|
console.log("✅ [ConditionProperties] 조건 노드 통과 → 상위 탐색");
|
||||||
|
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
||||||
} else if (
|
} else if (
|
||||||
sourceNode.type === "insertAction" ||
|
sourceNode.type === "insertAction" ||
|
||||||
sourceNode.type === "updateAction" ||
|
sourceNode.type === "updateAction" ||
|
||||||
|
|
@ -130,6 +148,10 @@ export function ConditionProperties({ nodeId, data }: ConditionPropertiesProps)
|
||||||
) {
|
) {
|
||||||
// Action 노드: 재귀적으로 상위 노드 필드 수집
|
// Action 노드: 재귀적으로 상위 노드 필드 수집
|
||||||
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
||||||
|
} else {
|
||||||
|
// 기타 모든 노드: 재귀적으로 상위 노드 필드 수집 (통과 노드로 처리)
|
||||||
|
console.log(`✅ [ConditionProperties] 통과 노드 (${sourceNode.type}) → 상위 탐색`);
|
||||||
|
fields.push(...getAllSourceFields(sourceNode.id, visited));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,191 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 필드 매핑 노드 속성 편집
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { Plus, Trash2, ArrowRight } from "lucide-react";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
||||||
import { useFlowEditorStore } from "@/lib/stores/flowEditorStore";
|
|
||||||
import type { FieldMappingNodeData } from "@/types/node-editor";
|
|
||||||
|
|
||||||
interface FieldMappingPropertiesProps {
|
|
||||||
nodeId: string;
|
|
||||||
data: FieldMappingNodeData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FieldMappingProperties({ nodeId, data }: FieldMappingPropertiesProps) {
|
|
||||||
const { updateNode } = useFlowEditorStore();
|
|
||||||
|
|
||||||
const [displayName, setDisplayName] = useState(data.displayName || "데이터 매핑");
|
|
||||||
const [mappings, setMappings] = useState(data.mappings || []);
|
|
||||||
|
|
||||||
// 데이터 변경 시 로컬 상태 업데이트
|
|
||||||
useEffect(() => {
|
|
||||||
setDisplayName(data.displayName || "데이터 매핑");
|
|
||||||
setMappings(data.mappings || []);
|
|
||||||
}, [data]);
|
|
||||||
|
|
||||||
const handleAddMapping = () => {
|
|
||||||
setMappings([
|
|
||||||
...mappings,
|
|
||||||
{
|
|
||||||
id: `mapping_${Date.now()}`,
|
|
||||||
sourceField: "",
|
|
||||||
targetField: "",
|
|
||||||
transform: undefined,
|
|
||||||
staticValue: undefined,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveMapping = (id: string) => {
|
|
||||||
setMappings(mappings.filter((m) => m.id !== id));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMappingChange = (id: string, field: string, value: any) => {
|
|
||||||
const newMappings = mappings.map((m) => (m.id === id ? { ...m, [field]: value } : m));
|
|
||||||
setMappings(newMappings);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
updateNode(nodeId, {
|
|
||||||
displayName,
|
|
||||||
mappings,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="space-y-4 p-4">
|
|
||||||
{/* 기본 정보 */}
|
|
||||||
<div>
|
|
||||||
<h3 className="mb-3 text-sm font-semibold">기본 정보</h3>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="displayName" className="text-xs">
|
|
||||||
표시 이름
|
|
||||||
</Label>
|
|
||||||
<Input
|
|
||||||
id="displayName"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
className="mt-1"
|
|
||||||
placeholder="노드 표시 이름"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 매핑 규칙 */}
|
|
||||||
<div>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<h3 className="text-sm font-semibold">매핑 규칙</h3>
|
|
||||||
<Button size="sm" variant="outline" onClick={handleAddMapping} className="h-7">
|
|
||||||
<Plus className="mr-1 h-3 w-3" />
|
|
||||||
추가
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{mappings.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{mappings.map((mapping, index) => (
|
|
||||||
<div key={mapping.id} className="rounded border bg-purple-50 p-3">
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-xs font-medium text-purple-700">규칙 #{index + 1}</span>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => handleRemoveMapping(mapping.id)}
|
|
||||||
className="h-6 w-6 p-0"
|
|
||||||
>
|
|
||||||
<Trash2 className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 소스 → 타겟 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-xs text-gray-600">소스 필드</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.sourceField || ""}
|
|
||||||
onChange={(e) => handleMappingChange(mapping.id, "sourceField", e.target.value)}
|
|
||||||
placeholder="입력 필드"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-5">
|
|
||||||
<ArrowRight className="h-4 w-4 text-purple-500" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1">
|
|
||||||
<Label className="text-xs text-gray-600">타겟 필드</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.targetField}
|
|
||||||
onChange={(e) => handleMappingChange(mapping.id, "targetField", e.target.value)}
|
|
||||||
placeholder="출력 필드"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 변환 함수 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">변환 함수 (선택)</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.transform || ""}
|
|
||||||
onChange={(e) => handleMappingChange(mapping.id, "transform", e.target.value)}
|
|
||||||
placeholder="예: UPPER(), TRIM(), CONCAT()"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 정적 값 */}
|
|
||||||
<div>
|
|
||||||
<Label className="text-xs text-gray-600">정적 값 (선택)</Label>
|
|
||||||
<Input
|
|
||||||
value={mapping.staticValue || ""}
|
|
||||||
onChange={(e) => handleMappingChange(mapping.id, "staticValue", e.target.value)}
|
|
||||||
placeholder="고정 값 (소스 필드 대신 사용)"
|
|
||||||
className="mt-1 h-8 text-xs"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="rounded border border-dashed p-4 text-center text-xs text-gray-400">
|
|
||||||
매핑 규칙이 없습니다. "추가" 버튼을 클릭하세요.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 저장 버튼 */}
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={handleSave} className="flex-1" size="sm">
|
|
||||||
적용
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 안내 */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
|
||||||
💡 <strong>소스 필드</strong>: 입력 데이터의 필드명
|
|
||||||
</div>
|
|
||||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
|
||||||
💡 <strong>타겟 필드</strong>: 출력 데이터의 필드명
|
|
||||||
</div>
|
|
||||||
<div className="rounded bg-purple-50 p-3 text-xs text-purple-700">
|
|
||||||
💡 <strong>변환 함수</strong>: 데이터 변환 로직 (SQL 함수 형식)
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -137,8 +137,10 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
const getAllSourceFields = (
|
const getAllSourceFields = (
|
||||||
targetNodeId: string,
|
targetNodeId: string,
|
||||||
visitedNodes = new Set<string>(),
|
visitedNodes = new Set<string>(),
|
||||||
): { fields: Array<{ name: string; label?: string }>; hasRestAPI: boolean } => {
|
sourcePath: string[] = [], // 🔥 소스 경로 추적
|
||||||
|
): { fields: Array<{ name: string; label?: string; sourcePath?: string[] }>; hasRestAPI: boolean } => {
|
||||||
if (visitedNodes.has(targetNodeId)) {
|
if (visitedNodes.has(targetNodeId)) {
|
||||||
|
console.log(`⚠️ 순환 참조 감지: ${targetNodeId} (이미 방문함)`);
|
||||||
return { fields: [], hasRestAPI: false };
|
return { fields: [], hasRestAPI: false };
|
||||||
}
|
}
|
||||||
visitedNodes.add(targetNodeId);
|
visitedNodes.add(targetNodeId);
|
||||||
|
|
@ -147,19 +149,27 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
const sourceNodeIds = inputEdges.map((edge) => edge.source);
|
||||||
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
const sourceNodes = nodes.filter((node) => sourceNodeIds.includes(node.id));
|
||||||
|
|
||||||
const fields: Array<{ name: string; label?: string }> = [];
|
// 🔥 다중 소스 감지
|
||||||
|
if (sourceNodes.length > 1) {
|
||||||
|
console.log(`⚠️ 다중 소스 감지: ${sourceNodes.length}개 노드 연결됨`);
|
||||||
|
console.log(" 소스 노드들:", sourceNodes.map((n) => `${n.id}(${n.type})`).join(", "));
|
||||||
|
}
|
||||||
|
|
||||||
|
const fields: Array<{ name: string; label?: string; sourcePath?: string[] }> = [];
|
||||||
let foundRestAPI = false;
|
let foundRestAPI = false;
|
||||||
|
|
||||||
sourceNodes.forEach((node) => {
|
sourceNodes.forEach((node) => {
|
||||||
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
|
console.log(`🔍 노드 ${node.id} 타입: ${node.type}`);
|
||||||
console.log(`🔍 노드 ${node.id} 데이터:`, node.data);
|
|
||||||
|
|
||||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
// 🔥 현재 노드를 경로에 추가
|
||||||
|
const currentPath = [...sourcePath, `${node.id}(${node.type})`];
|
||||||
|
|
||||||
|
// 1️⃣ 데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
|
||||||
if (node.type === "dataTransform") {
|
if (node.type === "dataTransform") {
|
||||||
console.log("✅ 데이터 변환 노드 발견");
|
console.log("✅ 데이터 변환 노드 발견");
|
||||||
|
|
||||||
// 상위 노드의 원본 필드 먼저 수집
|
// 상위 노드의 원본 필드 먼저 수집
|
||||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||||
const upperFields = upperResult.fields;
|
const upperFields = upperResult.fields;
|
||||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`);
|
console.log(` 📤 상위 노드에서 ${upperFields.length}개 필드 가져옴`);
|
||||||
|
|
@ -167,7 +177,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
// 변환된 필드 추가 (in-place 변환 고려)
|
// 변환된 필드 추가 (in-place 변환 고려)
|
||||||
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
||||||
console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`);
|
console.log(` 📊 ${(node.data as any).transformations.length}개 변환 발견`);
|
||||||
const inPlaceFields = new Set<string>(); // in-place 변환된 필드 추적
|
const inPlaceFields = new Set<string>();
|
||||||
|
|
||||||
(node.data as any).transformations.forEach((transform: any) => {
|
(node.data as any).transformations.forEach((transform: any) => {
|
||||||
const targetField = transform.targetField || transform.sourceField;
|
const targetField = transform.targetField || transform.sourceField;
|
||||||
|
|
@ -176,32 +186,29 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
console.log(` 🔹 변환: ${transform.sourceField} → ${targetField} ${isInPlace ? "(in-place)" : ""}`);
|
console.log(` 🔹 변환: ${transform.sourceField} → ${targetField} ${isInPlace ? "(in-place)" : ""}`);
|
||||||
|
|
||||||
if (isInPlace) {
|
if (isInPlace) {
|
||||||
// in-place: 원본 필드를 덮어쓰므로, 원본 필드는 이미 upperFields에 있음
|
|
||||||
inPlaceFields.add(transform.sourceField);
|
inPlaceFields.add(transform.sourceField);
|
||||||
} else if (targetField) {
|
} else if (targetField) {
|
||||||
// 새 필드 생성
|
|
||||||
fields.push({
|
fields.push({
|
||||||
name: targetField,
|
name: targetField,
|
||||||
label: transform.targetFieldLabel || targetField,
|
label: transform.targetFieldLabel || targetField,
|
||||||
|
sourcePath: currentPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 상위 필드 중 in-place 변환되지 않은 것만 추가
|
// 상위 필드 추가
|
||||||
upperFields.forEach((field) => {
|
upperFields.forEach((field) => {
|
||||||
if (!inPlaceFields.has(field.name)) {
|
if (!inPlaceFields.has(field.name)) {
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
} else {
|
} else {
|
||||||
// in-place 변환된 필드도 추가 (변환 후 값)
|
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 변환이 없으면 상위 필드만 추가
|
|
||||||
fields.push(...upperFields);
|
fields.push(...upperFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// REST API 소스 노드인 경우
|
// 2️⃣ REST API 소스 노드
|
||||||
else if (node.type === "restAPISource") {
|
else if (node.type === "restAPISource") {
|
||||||
console.log("✅ REST API 소스 노드 발견");
|
console.log("✅ REST API 소스 노드 발견");
|
||||||
foundRestAPI = true;
|
foundRestAPI = true;
|
||||||
|
|
@ -216,6 +223,7 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
fields.push({
|
fields.push({
|
||||||
name: fieldName,
|
name: fieldName,
|
||||||
label: fieldLabel,
|
label: fieldLabel,
|
||||||
|
sourcePath: currentPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -223,26 +231,44 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
console.log("⚠️ REST API 노드에 responseFields 없음");
|
console.log("⚠️ REST API 노드에 responseFields 없음");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 일반 소스 노드인 경우 (테이블 소스 등)
|
// 3️⃣ 테이블/외부DB 소스 노드
|
||||||
else {
|
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||||
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||||
|
const displayName = (node.data as any).displayName || (node.data as any).tableName || node.id;
|
||||||
|
|
||||||
if (nodeFields && Array.isArray(nodeFields)) {
|
if (nodeFields && Array.isArray(nodeFields)) {
|
||||||
console.log(`✅ 노드 ${node.id}에서 ${nodeFields.length}개 필드 발견`);
|
console.log(`✅ ${node.type}[${displayName}] 노드에서 ${nodeFields.length}개 필드 발견`);
|
||||||
nodeFields.forEach((field: any) => {
|
nodeFields.forEach((field: any) => {
|
||||||
const fieldName = field.name || field.fieldName || field.column_name;
|
const fieldName = field.name || field.fieldName || field.column_name;
|
||||||
const fieldLabel = field.label || field.displayName || field.label_ko;
|
const fieldLabel = field.label || field.displayName || field.label_ko;
|
||||||
if (fieldName) {
|
if (fieldName) {
|
||||||
|
// 🔥 다중 소스인 경우 필드명에 소스 표시
|
||||||
|
const displayLabel =
|
||||||
|
sourceNodes.length > 1 ? `${fieldLabel || fieldName} [${displayName}]` : fieldLabel || fieldName;
|
||||||
|
|
||||||
fields.push({
|
fields.push({
|
||||||
name: fieldName,
|
name: fieldName,
|
||||||
label: fieldLabel,
|
label: displayLabel,
|
||||||
|
sourcePath: currentPath,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
console.log(`❌ 노드 ${node.id}에 fields 없음`);
|
console.log(`⚠️ ${node.type} 노드에 필드 정의 없음 → 상위 노드 탐색`);
|
||||||
|
// 필드가 없으면 상위 노드로 계속 탐색
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||||
|
else {
|
||||||
|
console.log(`✅ 통과 노드 (${node.type}) → 상위 노드로 계속 탐색`);
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes, currentPath);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
console.log(` 📤 상위 노드에서 ${upperResult.fields.length}개 필드 가져옴`);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return { fields, hasRestAPI: foundRestAPI };
|
return { fields, hasRestAPI: foundRestAPI };
|
||||||
|
|
@ -251,8 +277,30 @@ export function InsertActionProperties({ nodeId, data }: InsertActionPropertiesP
|
||||||
console.log("🔍 INSERT 노드 ID:", nodeId);
|
console.log("🔍 INSERT 노드 ID:", nodeId);
|
||||||
const result = getAllSourceFields(nodeId);
|
const result = getAllSourceFields(nodeId);
|
||||||
|
|
||||||
// 중복 제거
|
console.log("📊 필드 수집 완료:");
|
||||||
const uniqueFields = Array.from(new Map(result.fields.map((field) => [field.name, field])).values());
|
console.log(` - 총 필드 수: ${result.fields.length}개`);
|
||||||
|
console.log(` - REST API 포함: ${result.hasRestAPI}`);
|
||||||
|
|
||||||
|
// 🔥 중복 제거 개선: 필드명이 같아도 소스가 다르면 모두 표시
|
||||||
|
const fieldMap = new Map<string, (typeof result.fields)[number]>();
|
||||||
|
const duplicateFields = new Set<string>();
|
||||||
|
|
||||||
|
result.fields.forEach((field) => {
|
||||||
|
const key = `${field.name}`;
|
||||||
|
if (fieldMap.has(key)) {
|
||||||
|
duplicateFields.add(field.name);
|
||||||
|
}
|
||||||
|
// 중복이면 마지막 값으로 덮어씀 (기존 동작 유지)
|
||||||
|
fieldMap.set(key, field);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (duplicateFields.size > 0) {
|
||||||
|
console.warn(`⚠️ 중복 필드명 감지: ${Array.from(duplicateFields).join(", ")}`);
|
||||||
|
console.warn(" → 마지막으로 발견된 필드만 표시됩니다.");
|
||||||
|
console.warn(" → 다중 소스 사용 시 필드명이 겹치지 않도록 주의하세요!");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueFields = Array.from(fieldMap.values());
|
||||||
|
|
||||||
setSourceFields(uniqueFields);
|
setSourceFields(uniqueFields);
|
||||||
setHasRestAPISource(result.hasRestAPI);
|
setHasRestAPISource(result.hasRestAPI);
|
||||||
|
|
|
||||||
|
|
@ -166,14 +166,12 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
let foundRestAPI = false;
|
let foundRestAPI = false;
|
||||||
|
|
||||||
sourceNodes.forEach((node) => {
|
sourceNodes.forEach((node) => {
|
||||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
// 1️⃣ 데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
|
||||||
if (node.type === "dataTransform") {
|
if (node.type === "dataTransform") {
|
||||||
// 상위 노드의 원본 필드 먼저 수집
|
|
||||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
const upperFields = upperResult.fields;
|
const upperFields = upperResult.fields;
|
||||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
// 변환된 필드 추가 (in-place 변환 고려)
|
|
||||||
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
||||||
const inPlaceFields = new Set<string>();
|
const inPlaceFields = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -191,7 +189,6 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 상위 필드 추가 (모두 포함, in-place는 변환 후 값)
|
|
||||||
upperFields.forEach((field) => {
|
upperFields.forEach((field) => {
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
});
|
});
|
||||||
|
|
@ -199,7 +196,7 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
fields.push(...upperFields);
|
fields.push(...upperFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// REST API 소스 노드인 경우
|
// 2️⃣ REST API 소스 노드
|
||||||
else if (node.type === "restAPISource") {
|
else if (node.type === "restAPISource") {
|
||||||
foundRestAPI = true;
|
foundRestAPI = true;
|
||||||
const responseFields = (node.data as any).responseFields;
|
const responseFields = (node.data as any).responseFields;
|
||||||
|
|
@ -216,21 +213,33 @@ export function UpdateActionProperties({ nodeId, data }: UpdateActionPropertiesP
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 일반 소스 노드인 경우
|
// 3️⃣ 테이블/외부DB 소스 노드
|
||||||
else if (node.type === "tableSource" && (node.data as any).fields) {
|
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||||
(node.data as any).fields.forEach((field: any) => {
|
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||||
fields.push({
|
|
||||||
name: field.name,
|
if (nodeFields && Array.isArray(nodeFields)) {
|
||||||
label: field.label || field.displayName,
|
nodeFields.forEach((field: any) => {
|
||||||
|
const fieldName = field.name || field.fieldName || field.column_name;
|
||||||
|
const fieldLabel = field.label || field.displayName || field.label_ko;
|
||||||
|
if (fieldName) {
|
||||||
|
fields.push({
|
||||||
|
name: fieldName,
|
||||||
|
label: fieldLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
|
// 필드가 없으면 상위 노드로 계속 탐색
|
||||||
(node.data as any).fields.forEach((field: any) => {
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
fields.push({
|
fields.push(...upperResult.fields);
|
||||||
name: field.name,
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
label: field.label || field.displayName,
|
}
|
||||||
});
|
}
|
||||||
});
|
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||||
|
else {
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -153,14 +153,12 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
let foundRestAPI = false;
|
let foundRestAPI = false;
|
||||||
|
|
||||||
sourceNodes.forEach((node) => {
|
sourceNodes.forEach((node) => {
|
||||||
// 데이터 변환 노드인 경우: 변환된 필드 + 상위 노드의 원본 필드
|
// 1️⃣ 데이터 변환 노드: 변환된 필드 + 상위 노드의 원본 필드
|
||||||
if (node.type === "dataTransform") {
|
if (node.type === "dataTransform") {
|
||||||
// 상위 노드의 원본 필드 먼저 수집
|
|
||||||
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
const upperFields = upperResult.fields;
|
const upperFields = upperResult.fields;
|
||||||
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
|
|
||||||
// 변환된 필드 추가 (in-place 변환 고려)
|
|
||||||
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
if ((node.data as any).transformations && Array.isArray((node.data as any).transformations)) {
|
||||||
const inPlaceFields = new Set<string>();
|
const inPlaceFields = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -178,7 +176,6 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 상위 필드 추가 (모두 포함, in-place는 변환 후 값)
|
|
||||||
upperFields.forEach((field) => {
|
upperFields.forEach((field) => {
|
||||||
fields.push(field);
|
fields.push(field);
|
||||||
});
|
});
|
||||||
|
|
@ -186,7 +183,7 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
fields.push(...upperFields);
|
fields.push(...upperFields);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// REST API 소스 노드인 경우
|
// 2️⃣ REST API 소스 노드
|
||||||
else if (node.type === "restAPISource") {
|
else if (node.type === "restAPISource") {
|
||||||
foundRestAPI = true;
|
foundRestAPI = true;
|
||||||
const responseFields = (node.data as any).responseFields;
|
const responseFields = (node.data as any).responseFields;
|
||||||
|
|
@ -203,21 +200,33 @@ export function UpsertActionProperties({ nodeId, data }: UpsertActionPropertiesP
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 일반 소스 노드인 경우
|
// 3️⃣ 테이블/외부DB 소스 노드
|
||||||
else if (node.type === "tableSource" && (node.data as any).fields) {
|
else if (node.type === "tableSource" || node.type === "externalDBSource") {
|
||||||
(node.data as any).fields.forEach((field: any) => {
|
const nodeFields = (node.data as any).fields || (node.data as any).outputFields;
|
||||||
fields.push({
|
|
||||||
name: field.name,
|
if (nodeFields && Array.isArray(nodeFields)) {
|
||||||
label: field.label || field.displayName,
|
nodeFields.forEach((field: any) => {
|
||||||
|
const fieldName = field.name || field.fieldName || field.column_name;
|
||||||
|
const fieldLabel = field.label || field.displayName || field.label_ko;
|
||||||
|
if (fieldName) {
|
||||||
|
fields.push({
|
||||||
|
name: fieldName,
|
||||||
|
label: fieldLabel,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
} else {
|
||||||
} else if (node.type === "externalDBSource" && (node.data as any).fields) {
|
// 필드가 없으면 상위 노드로 계속 탐색
|
||||||
(node.data as any).fields.forEach((field: any) => {
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
fields.push({
|
fields.push(...upperResult.fields);
|
||||||
name: field.name,
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
label: field.label || field.displayName,
|
}
|
||||||
});
|
}
|
||||||
});
|
// 4️⃣ 통과 노드 (조건, 기타 모든 노드): 상위 노드로 계속 탐색
|
||||||
|
else {
|
||||||
|
const upperResult = getAllSourceFields(node.id, visitedNodes);
|
||||||
|
fields.push(...upperResult.fields);
|
||||||
|
foundRestAPI = foundRestAPI || upperResult.hasRestAPI;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,14 +52,6 @@ export const NODE_PALETTE: NodePaletteItem[] = [
|
||||||
category: "transform",
|
category: "transform",
|
||||||
color: "#EAB308", // 노란색
|
color: "#EAB308", // 노란색
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "fieldMapping",
|
|
||||||
label: "필드 매핑",
|
|
||||||
icon: "🔀",
|
|
||||||
description: "소스 필드를 타겟 필드로 매핑합니다",
|
|
||||||
category: "transform",
|
|
||||||
color: "#8B5CF6", // 보라색
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
type: "dataTransform",
|
type: "dataTransform",
|
||||||
label: "데이터 변환",
|
label: "데이터 변환",
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,38 @@ import { Connection, Edge, EdgeChange, Node, NodeChange, addEdge, applyNodeChang
|
||||||
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
|
import type { FlowNode, FlowEdge, NodeType, ValidationResult } from "@/types/node-editor";
|
||||||
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
|
import { createNodeFlow, updateNodeFlow } from "../api/nodeFlows";
|
||||||
|
|
||||||
|
// 🔥 Debounce 유틸리티
|
||||||
|
function debounce<T extends (...args: any[]) => any>(func: T, wait: number): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
return function (...args: Parameters<T>) {
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(() => func(...args), wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 🔥 외부 커넥션 캐시 타입
|
// 🔥 외부 커넥션 캐시 타입
|
||||||
interface ExternalConnectionCache {
|
interface ExternalConnectionCache {
|
||||||
data: any[];
|
data: any[];
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔥 히스토리 스냅샷 타입
|
||||||
|
interface HistorySnapshot {
|
||||||
|
nodes: FlowNode[];
|
||||||
|
edges: FlowEdge[];
|
||||||
|
}
|
||||||
|
|
||||||
interface FlowEditorState {
|
interface FlowEditorState {
|
||||||
// 노드 및 엣지
|
// 노드 및 엣지
|
||||||
nodes: FlowNode[];
|
nodes: FlowNode[];
|
||||||
edges: FlowEdge[];
|
edges: FlowEdge[];
|
||||||
|
|
||||||
|
// 🔥 히스토리 관리
|
||||||
|
history: HistorySnapshot[];
|
||||||
|
historyIndex: number;
|
||||||
|
maxHistorySize: number;
|
||||||
|
isRestoringHistory: boolean; // 🔥 히스토리 복원 중 플래그
|
||||||
|
|
||||||
// 선택 상태
|
// 선택 상태
|
||||||
selectedNodes: string[];
|
selectedNodes: string[];
|
||||||
selectedEdges: string[];
|
selectedEdges: string[];
|
||||||
|
|
@ -39,12 +60,23 @@ interface FlowEditorState {
|
||||||
// 🔥 외부 커넥션 캐시 (전역 캐싱)
|
// 🔥 외부 커넥션 캐시 (전역 캐싱)
|
||||||
externalConnectionsCache: ExternalConnectionCache | null;
|
externalConnectionsCache: ExternalConnectionCache | null;
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 🔥 히스토리 관리 (Undo/Redo)
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
saveToHistory: () => void;
|
||||||
|
undo: () => void;
|
||||||
|
redo: () => void;
|
||||||
|
canUndo: () => boolean;
|
||||||
|
canRedo: () => boolean;
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// 노드 관리
|
// 노드 관리
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
setNodes: (nodes: FlowNode[]) => void;
|
setNodes: (nodes: FlowNode[]) => void;
|
||||||
onNodesChange: (changes: NodeChange[]) => void;
|
onNodesChange: (changes: NodeChange[]) => void;
|
||||||
|
onNodeDragStart: () => void; // 노드 드래그 시작 시 히스토리 저장 (변경 전 상태)
|
||||||
addNode: (node: FlowNode) => void;
|
addNode: (node: FlowNode) => void;
|
||||||
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
|
updateNode: (id: string, data: Partial<FlowNode["data"]>) => void;
|
||||||
removeNode: (id: string) => void;
|
removeNode: (id: string) => void;
|
||||||
|
|
@ -113,325 +145,527 @@ interface FlowEditorState {
|
||||||
getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] };
|
getConnectedNodes: (nodeId: string) => { incoming: FlowNode[]; outgoing: FlowNode[] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFlowEditorStore = create<FlowEditorState>((set, get) => ({
|
// 🔥 Debounced 히스토리 저장 함수 (스토어 외부에 생성)
|
||||||
// 초기 상태
|
let debouncedSaveToHistory: (() => void) | null = null;
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
selectedNodes: [],
|
|
||||||
selectedEdges: [],
|
|
||||||
flowId: null,
|
|
||||||
flowName: "새 제어 플로우",
|
|
||||||
flowDescription: "",
|
|
||||||
isExecuting: false,
|
|
||||||
isSaving: false,
|
|
||||||
showValidationPanel: false,
|
|
||||||
showPropertiesPanel: true,
|
|
||||||
validationResult: null,
|
|
||||||
externalConnectionsCache: null, // 🔥 캐시 초기화
|
|
||||||
|
|
||||||
// ========================================================================
|
export const useFlowEditorStore = create<FlowEditorState>((set, get) => {
|
||||||
// 노드 관리
|
// 🔥 Debounced 히스토리 저장 함수 초기화
|
||||||
// ========================================================================
|
if (!debouncedSaveToHistory) {
|
||||||
|
debouncedSaveToHistory = debounce(() => {
|
||||||
|
get().saveToHistory();
|
||||||
|
}, 500); // 500ms 지연
|
||||||
|
}
|
||||||
|
|
||||||
setNodes: (nodes) => set({ nodes }),
|
return {
|
||||||
|
// 초기 상태
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장
|
||||||
|
historyIndex: 0,
|
||||||
|
maxHistorySize: 50,
|
||||||
|
isRestoringHistory: false, // 🔥 히스토리 복원 중 플래그 초기화
|
||||||
|
selectedNodes: [],
|
||||||
|
selectedEdges: [],
|
||||||
|
flowId: null,
|
||||||
|
flowName: "새 제어 플로우",
|
||||||
|
flowDescription: "",
|
||||||
|
isExecuting: false,
|
||||||
|
isSaving: false,
|
||||||
|
showValidationPanel: false,
|
||||||
|
showPropertiesPanel: true,
|
||||||
|
validationResult: null,
|
||||||
|
externalConnectionsCache: null, // 🔥 캐시 초기화
|
||||||
|
|
||||||
onNodesChange: (changes) => {
|
// ========================================================================
|
||||||
set({
|
// 🔥 히스토리 관리 (Undo/Redo)
|
||||||
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
|
// ========================================================================
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
addNode: (node) => {
|
saveToHistory: () => {
|
||||||
set((state) => ({
|
const { nodes, edges, history, historyIndex, maxHistorySize } = get();
|
||||||
nodes: [...state.nodes, node],
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
updateNode: (id, data) => {
|
// 현재 상태를 스냅샷으로 저장
|
||||||
set((state) => ({
|
const snapshot: HistorySnapshot = {
|
||||||
nodes: state.nodes.map((node) =>
|
nodes: JSON.parse(JSON.stringify(nodes)),
|
||||||
node.id === id
|
edges: JSON.parse(JSON.stringify(edges)),
|
||||||
? {
|
|
||||||
...node,
|
|
||||||
data: { ...node.data, ...data },
|
|
||||||
}
|
|
||||||
: node,
|
|
||||||
),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
removeNode: (id) => {
|
|
||||||
set((state) => ({
|
|
||||||
nodes: state.nodes.filter((node) => node.id !== id),
|
|
||||||
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
removeNodes: (ids) => {
|
|
||||||
set((state) => ({
|
|
||||||
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
|
|
||||||
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 엣지 관리
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
setEdges: (edges) => set({ edges }),
|
|
||||||
|
|
||||||
onEdgesChange: (changes) => {
|
|
||||||
set({
|
|
||||||
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
onConnect: (connection) => {
|
|
||||||
// 연결 검증
|
|
||||||
const validation = validateConnection(connection, get().nodes);
|
|
||||||
if (!validation.valid) {
|
|
||||||
console.warn("연결 검증 실패:", validation.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
set((state) => ({
|
|
||||||
edges: addEdge(
|
|
||||||
{
|
|
||||||
...connection,
|
|
||||||
type: "smoothstep",
|
|
||||||
animated: false,
|
|
||||||
data: {
|
|
||||||
validation: { valid: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
state.edges,
|
|
||||||
) as FlowEdge[],
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
removeEdge: (id) => {
|
|
||||||
set((state) => ({
|
|
||||||
edges: state.edges.filter((edge) => edge.id !== id),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
removeEdges: (ids) => {
|
|
||||||
set((state) => ({
|
|
||||||
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 선택 관리
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
selectNode: (id, multi = false) => {
|
|
||||||
set((state) => ({
|
|
||||||
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
selectNodes: (ids) => {
|
|
||||||
set({
|
|
||||||
selectedNodes: ids,
|
|
||||||
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
selectEdge: (id, multi = false) => {
|
|
||||||
set((state) => ({
|
|
||||||
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
clearSelection: () => {
|
|
||||||
set({ selectedNodes: [], selectedEdges: [] });
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// 플로우 관리
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
loadFlow: (id, name, description, nodes, edges) => {
|
|
||||||
set({
|
|
||||||
flowId: id,
|
|
||||||
flowName: name,
|
|
||||||
flowDescription: description,
|
|
||||||
nodes,
|
|
||||||
edges,
|
|
||||||
selectedNodes: [],
|
|
||||||
selectedEdges: [],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearFlow: () => {
|
|
||||||
set({
|
|
||||||
flowId: null,
|
|
||||||
flowName: "새 제어 플로우",
|
|
||||||
flowDescription: "",
|
|
||||||
nodes: [],
|
|
||||||
edges: [],
|
|
||||||
selectedNodes: [],
|
|
||||||
selectedEdges: [],
|
|
||||||
validationResult: null,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setFlowName: (name) => set({ flowName: name }),
|
|
||||||
setFlowDescription: (description) => set({ flowDescription: description }),
|
|
||||||
|
|
||||||
saveFlow: async () => {
|
|
||||||
const { flowId, flowName, flowDescription, nodes, edges } = get();
|
|
||||||
|
|
||||||
if (!flowName || flowName.trim() === "") {
|
|
||||||
return { success: false, message: "플로우 이름을 입력해주세요." };
|
|
||||||
}
|
|
||||||
|
|
||||||
// 검증
|
|
||||||
const validation = get().validateFlow();
|
|
||||||
if (!validation.valid) {
|
|
||||||
return { success: false, message: `검증 실패: ${validation.errors[0]?.message || "오류가 있습니다."}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
set({ isSaving: true });
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 플로우 데이터 직렬화
|
|
||||||
const flowData = {
|
|
||||||
nodes: nodes.map((node) => ({
|
|
||||||
id: node.id,
|
|
||||||
type: node.type,
|
|
||||||
position: node.position,
|
|
||||||
data: node.data,
|
|
||||||
})),
|
|
||||||
edges: edges.map((edge) => ({
|
|
||||||
id: edge.id,
|
|
||||||
source: edge.source,
|
|
||||||
target: edge.target,
|
|
||||||
sourceHandle: edge.sourceHandle,
|
|
||||||
targetHandle: edge.targetHandle,
|
|
||||||
})),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const result = flowId
|
// historyIndex 이후의 히스토리 제거 (새로운 변경이 발생했으므로)
|
||||||
? await updateNodeFlow({
|
const newHistory = history.slice(0, historyIndex + 1);
|
||||||
flowId,
|
newHistory.push(snapshot);
|
||||||
flowName,
|
|
||||||
flowDescription,
|
|
||||||
flowData: JSON.stringify(flowData),
|
|
||||||
})
|
|
||||||
: await createNodeFlow({
|
|
||||||
flowName,
|
|
||||||
flowDescription,
|
|
||||||
flowData: JSON.stringify(flowData),
|
|
||||||
});
|
|
||||||
|
|
||||||
set({ flowId: result.flowId });
|
// 최대 크기 제한
|
||||||
return { success: true, flowId: result.flowId, message: "저장 완료!" };
|
if (newHistory.length > maxHistorySize) {
|
||||||
} catch (error) {
|
newHistory.shift();
|
||||||
console.error("플로우 저장 오류:", error);
|
}
|
||||||
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
|
|
||||||
} finally {
|
|
||||||
set({ isSaving: false });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
exportFlow: () => {
|
console.log("📸 히스토리 저장:", {
|
||||||
const { flowName, flowDescription, nodes, edges } = get();
|
노드수: nodes.length,
|
||||||
const flowData = {
|
엣지수: edges.length,
|
||||||
flowName,
|
히스토리크기: newHistory.length,
|
||||||
flowDescription,
|
현재인덱스: newHistory.length - 1,
|
||||||
nodes,
|
});
|
||||||
edges,
|
|
||||||
version: "1.0",
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
return JSON.stringify(flowData, null, 2);
|
|
||||||
},
|
|
||||||
|
|
||||||
// ========================================================================
|
set({
|
||||||
// 검증
|
history: newHistory,
|
||||||
// ========================================================================
|
historyIndex: newHistory.length - 1,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
validateFlow: () => {
|
undo: () => {
|
||||||
const { nodes, edges } = get();
|
const { history, historyIndex } = get();
|
||||||
const result = performFlowValidation(nodes, edges);
|
|
||||||
set({ validationResult: result });
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
setValidationResult: (result) => set({ validationResult: result }),
|
console.log("⏪ Undo 시도:", { historyIndex, historyLength: history.length });
|
||||||
|
|
||||||
// ========================================================================
|
if (historyIndex > 0) {
|
||||||
// UI 상태
|
const newIndex = historyIndex - 1;
|
||||||
// ========================================================================
|
const snapshot = history[newIndex];
|
||||||
|
|
||||||
setIsExecuting: (value) => set({ isExecuting: value }),
|
console.log("✅ Undo 실행:", {
|
||||||
setIsSaving: (value) => set({ isSaving: value }),
|
이전인덱스: historyIndex,
|
||||||
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
|
새인덱스: newIndex,
|
||||||
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
|
노드수: snapshot.nodes.length,
|
||||||
|
엣지수: snapshot.edges.length,
|
||||||
|
});
|
||||||
|
|
||||||
// ========================================================================
|
// 🔥 히스토리 복원 중 플래그 설정
|
||||||
// 유틸리티
|
set({ isRestoringHistory: true });
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
getNodeById: (id) => {
|
// 노드와 엣지 복원
|
||||||
return get().nodes.find((node) => node.id === id);
|
set({
|
||||||
},
|
nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
|
||||||
|
edges: JSON.parse(JSON.stringify(snapshot.edges)),
|
||||||
|
historyIndex: newIndex,
|
||||||
|
});
|
||||||
|
|
||||||
getEdgeById: (id) => {
|
// 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후)
|
||||||
return get().edges.find((edge) => edge.id === id);
|
setTimeout(() => {
|
||||||
},
|
set({ isRestoringHistory: false });
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
console.log("❌ Undo 불가: 히스토리가 없음");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getConnectedNodes: (nodeId) => {
|
redo: () => {
|
||||||
const { nodes, edges } = get();
|
const { history, historyIndex } = get();
|
||||||
|
|
||||||
const incoming = edges
|
console.log("⏩ Redo 시도:", { historyIndex, historyLength: history.length });
|
||||||
.filter((edge) => edge.target === nodeId)
|
|
||||||
.map((edge) => nodes.find((node) => node.id === edge.source))
|
|
||||||
.filter((node): node is FlowNode => node !== undefined);
|
|
||||||
|
|
||||||
const outgoing = edges
|
if (historyIndex < history.length - 1) {
|
||||||
.filter((edge) => edge.source === nodeId)
|
const newIndex = historyIndex + 1;
|
||||||
.map((edge) => nodes.find((node) => node.id === edge.target))
|
const snapshot = history[newIndex];
|
||||||
.filter((node): node is FlowNode => node !== undefined);
|
|
||||||
|
|
||||||
return { incoming, outgoing };
|
console.log("✅ Redo 실행:", {
|
||||||
},
|
이전인덱스: historyIndex,
|
||||||
|
새인덱스: newIndex,
|
||||||
|
노드수: snapshot.nodes.length,
|
||||||
|
엣지수: snapshot.edges.length,
|
||||||
|
});
|
||||||
|
|
||||||
// ========================================================================
|
// 🔥 히스토리 복원 중 플래그 설정
|
||||||
// 🔥 외부 커넥션 캐시 관리
|
set({ isRestoringHistory: true });
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
setExternalConnectionsCache: (data) => {
|
// 노드와 엣지 복원
|
||||||
set({
|
set({
|
||||||
externalConnectionsCache: {
|
nodes: JSON.parse(JSON.stringify(snapshot.nodes)),
|
||||||
data,
|
edges: JSON.parse(JSON.stringify(snapshot.edges)),
|
||||||
timestamp: Date.now(),
|
historyIndex: newIndex,
|
||||||
},
|
});
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
clearExternalConnectionsCache: () => {
|
// 🔥 다음 틱에서 플래그 해제 (ReactFlow 이벤트가 모두 처리된 후)
|
||||||
set({ externalConnectionsCache: null });
|
setTimeout(() => {
|
||||||
},
|
set({ isRestoringHistory: false });
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
console.log("❌ Redo 불가: 되돌릴 히스토리가 없음");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
getExternalConnectionsCache: () => {
|
canUndo: () => {
|
||||||
const cache = get().externalConnectionsCache;
|
const { historyIndex } = get();
|
||||||
if (!cache) return null;
|
return historyIndex > 0;
|
||||||
|
},
|
||||||
|
|
||||||
// 🔥 5분 후 캐시 만료
|
canRedo: () => {
|
||||||
const CACHE_DURATION = 5 * 60 * 1000; // 5분
|
const { history, historyIndex } = get();
|
||||||
const isExpired = Date.now() - cache.timestamp > CACHE_DURATION;
|
return historyIndex < history.length - 1;
|
||||||
|
},
|
||||||
|
|
||||||
if (isExpired) {
|
// ========================================================================
|
||||||
|
// 노드 관리
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
setNodes: (nodes) => set({ nodes }),
|
||||||
|
|
||||||
|
onNodesChange: (changes) => {
|
||||||
|
set({
|
||||||
|
nodes: applyNodeChanges(changes, get().nodes) as FlowNode[],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onNodeDragStart: () => {
|
||||||
|
// 🔥 히스토리 복원 중이면 저장하지 않음
|
||||||
|
if (get().isRestoringHistory) {
|
||||||
|
console.log("⏭️ 히스토리 복원 중, 저장 스킵");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 노드 드래그 시작 시 히스토리 저장 (변경 전 상태)
|
||||||
|
get().saveToHistory();
|
||||||
|
console.log("🎯 노드 이동 시작, 변경 전 상태 히스토리 저장");
|
||||||
|
},
|
||||||
|
|
||||||
|
addNode: (node) => {
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
if (!get().isRestoringHistory) {
|
||||||
|
get().saveToHistory();
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
nodes: [...state.nodes, node],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
updateNode: (id, data) => {
|
||||||
|
// 🔥 Debounced 히스토리 저장 (500ms 지연 - 타이핑 중에는 저장 안됨)
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
if (!get().isRestoringHistory && debouncedSaveToHistory) {
|
||||||
|
debouncedSaveToHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
nodes: state.nodes.map((node) =>
|
||||||
|
node.id === id
|
||||||
|
? {
|
||||||
|
...node,
|
||||||
|
data: { ...node.data, ...data },
|
||||||
|
}
|
||||||
|
: node,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNode: (id) => {
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
if (!get().isRestoringHistory) {
|
||||||
|
get().saveToHistory();
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
nodes: state.nodes.filter((node) => node.id !== id),
|
||||||
|
edges: state.edges.filter((edge) => edge.source !== id && edge.target !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeNodes: (ids) => {
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
if (!get().isRestoringHistory) {
|
||||||
|
get().saveToHistory();
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
nodes: state.nodes.filter((node) => !ids.includes(node.id)),
|
||||||
|
edges: state.edges.filter((edge) => !ids.includes(edge.source) && !ids.includes(edge.target)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 엣지 관리
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
setEdges: (edges) => set({ edges }),
|
||||||
|
|
||||||
|
onEdgesChange: (changes) => {
|
||||||
|
// 엣지 삭제(remove) 타입이 있으면 히스토리 저장
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
const hasRemove = changes.some((change) => change.type === "remove");
|
||||||
|
if (hasRemove && !get().isRestoringHistory) {
|
||||||
|
get().saveToHistory();
|
||||||
|
console.log("🔗 엣지 삭제, 변경 전 상태 히스토리 저장");
|
||||||
|
}
|
||||||
|
|
||||||
|
set({
|
||||||
|
edges: applyEdgeChanges(changes, get().edges) as FlowEdge[],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
onConnect: (connection) => {
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
if (!get().isRestoringHistory) {
|
||||||
|
get().saveToHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 연결 검증
|
||||||
|
const validation = validateConnection(connection, get().nodes);
|
||||||
|
if (!validation.valid) {
|
||||||
|
console.warn("연결 검증 실패:", validation.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
edges: addEdge(
|
||||||
|
{
|
||||||
|
...connection,
|
||||||
|
type: "smoothstep",
|
||||||
|
animated: false,
|
||||||
|
data: {
|
||||||
|
validation: { valid: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
state.edges,
|
||||||
|
) as FlowEdge[],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeEdge: (id) => {
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
if (!get().isRestoringHistory) {
|
||||||
|
get().saveToHistory();
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
edges: state.edges.filter((edge) => edge.id !== id),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
removeEdges: (ids) => {
|
||||||
|
// 🔥 히스토리 복원 중이 아닐 때만 저장
|
||||||
|
if (!get().isRestoringHistory) {
|
||||||
|
get().saveToHistory();
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
edges: state.edges.filter((edge) => !ids.includes(edge.id)),
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 선택 관리
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
selectNode: (id, multi = false) => {
|
||||||
|
set((state) => ({
|
||||||
|
selectedNodes: multi ? [...state.selectedNodes, id] : [id],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
selectNodes: (ids) => {
|
||||||
|
set({
|
||||||
|
selectedNodes: ids,
|
||||||
|
showPropertiesPanel: ids.length > 0, // 노드가 선택되면 속성창 자동으로 열기
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
selectEdge: (id, multi = false) => {
|
||||||
|
set((state) => ({
|
||||||
|
selectedEdges: multi ? [...state.selectedEdges, id] : [id],
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection: () => {
|
||||||
|
set({ selectedNodes: [], selectedEdges: [] });
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 플로우 관리
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
loadFlow: (id, name, description, nodes, edges) => {
|
||||||
|
console.log("📂 플로우 로드:", { id, name, 노드수: nodes.length, 엣지수: edges.length });
|
||||||
|
set({
|
||||||
|
flowId: id,
|
||||||
|
flowName: name,
|
||||||
|
flowDescription: description,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
selectedNodes: [],
|
||||||
|
selectedEdges: [],
|
||||||
|
// 로드된 상태를 히스토리의 첫 번째 스냅샷으로 저장
|
||||||
|
history: [{ nodes: JSON.parse(JSON.stringify(nodes)), edges: JSON.parse(JSON.stringify(edges)) }],
|
||||||
|
historyIndex: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFlow: () => {
|
||||||
|
console.log("🔄 플로우 초기화");
|
||||||
|
set({
|
||||||
|
flowId: null,
|
||||||
|
flowName: "새 제어 플로우",
|
||||||
|
flowDescription: "",
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
selectedNodes: [],
|
||||||
|
selectedEdges: [],
|
||||||
|
validationResult: null,
|
||||||
|
history: [{ nodes: [], edges: [] }], // 초기 빈 상태를 히스토리에 저장
|
||||||
|
historyIndex: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setFlowName: (name) => set({ flowName: name }),
|
||||||
|
setFlowDescription: (description) => set({ flowDescription: description }),
|
||||||
|
|
||||||
|
saveFlow: async () => {
|
||||||
|
const { flowId, flowName, flowDescription, nodes, edges } = get();
|
||||||
|
|
||||||
|
if (!flowName || flowName.trim() === "") {
|
||||||
|
return { success: false, message: "플로우 이름을 입력해주세요." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 검증 (순환 참조 및 기타 에러 체크)
|
||||||
|
const validation = get().validateFlow();
|
||||||
|
|
||||||
|
// 🔥 검증 실패 시 상세 메시지와 함께 저장 차단
|
||||||
|
if (!validation.valid) {
|
||||||
|
const errorMessages = validation.errors
|
||||||
|
.filter((err) => err.severity === "error")
|
||||||
|
.map((err) => err.message)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
// 🔥 검증 패널 표시하여 사용자가 오류를 확인할 수 있도록
|
||||||
|
set({ validationResult: validation, showValidationPanel: true });
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `플로우를 저장할 수 없습니다: ${errorMessages}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isSaving: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 플로우 데이터 직렬화
|
||||||
|
const flowData = {
|
||||||
|
nodes: nodes.map((node) => ({
|
||||||
|
id: node.id,
|
||||||
|
type: node.type,
|
||||||
|
position: node.position,
|
||||||
|
data: node.data,
|
||||||
|
})),
|
||||||
|
edges: edges.map((edge) => ({
|
||||||
|
id: edge.id,
|
||||||
|
source: edge.source,
|
||||||
|
target: edge.target,
|
||||||
|
sourceHandle: edge.sourceHandle,
|
||||||
|
targetHandle: edge.targetHandle,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = flowId
|
||||||
|
? await updateNodeFlow({
|
||||||
|
flowId,
|
||||||
|
flowName,
|
||||||
|
flowDescription,
|
||||||
|
flowData: JSON.stringify(flowData),
|
||||||
|
})
|
||||||
|
: await createNodeFlow({
|
||||||
|
flowName,
|
||||||
|
flowDescription,
|
||||||
|
flowData: JSON.stringify(flowData),
|
||||||
|
});
|
||||||
|
|
||||||
|
set({ flowId: result.flowId });
|
||||||
|
return { success: true, flowId: result.flowId, message: "저장 완료!" };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("플로우 저장 오류:", error);
|
||||||
|
return { success: false, message: error instanceof Error ? error.message : "저장 중 오류 발생" };
|
||||||
|
} finally {
|
||||||
|
set({ isSaving: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exportFlow: () => {
|
||||||
|
const { flowName, flowDescription, nodes, edges } = get();
|
||||||
|
const flowData = {
|
||||||
|
flowName,
|
||||||
|
flowDescription,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
version: "1.0",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
return JSON.stringify(flowData, null, 2);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 검증
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
validateFlow: () => {
|
||||||
|
const { nodes, edges } = get();
|
||||||
|
const result = performFlowValidation(nodes, edges);
|
||||||
|
set({ validationResult: result });
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
setValidationResult: (result) => set({ validationResult: result }),
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// UI 상태
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
setIsExecuting: (value) => set({ isExecuting: value }),
|
||||||
|
setIsSaving: (value) => set({ isSaving: value }),
|
||||||
|
setShowValidationPanel: (value) => set({ showValidationPanel: value }),
|
||||||
|
setShowPropertiesPanel: (value) => set({ showPropertiesPanel: value }),
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 유틸리티
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
getNodeById: (id) => {
|
||||||
|
return get().nodes.find((node) => node.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
|
getEdgeById: (id) => {
|
||||||
|
return get().edges.find((edge) => edge.id === id);
|
||||||
|
},
|
||||||
|
|
||||||
|
getConnectedNodes: (nodeId) => {
|
||||||
|
const { nodes, edges } = get();
|
||||||
|
|
||||||
|
const incoming = edges
|
||||||
|
.filter((edge) => edge.target === nodeId)
|
||||||
|
.map((edge) => nodes.find((node) => node.id === edge.source))
|
||||||
|
.filter((node): node is FlowNode => node !== undefined);
|
||||||
|
|
||||||
|
const outgoing = edges
|
||||||
|
.filter((edge) => edge.source === nodeId)
|
||||||
|
.map((edge) => nodes.find((node) => node.id === edge.target))
|
||||||
|
.filter((node): node is FlowNode => node !== undefined);
|
||||||
|
|
||||||
|
return { incoming, outgoing };
|
||||||
|
},
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// 🔥 외부 커넥션 캐시 관리
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
setExternalConnectionsCache: (data) => {
|
||||||
|
set({
|
||||||
|
externalConnectionsCache: {
|
||||||
|
data,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearExternalConnectionsCache: () => {
|
||||||
set({ externalConnectionsCache: null });
|
set({ externalConnectionsCache: null });
|
||||||
return null;
|
},
|
||||||
}
|
|
||||||
|
|
||||||
return cache.data;
|
getExternalConnectionsCache: () => {
|
||||||
},
|
const cache = get().externalConnectionsCache;
|
||||||
}));
|
if (!cache) return null;
|
||||||
|
|
||||||
|
// 🔥 5분 후 캐시 만료
|
||||||
|
const CACHE_DURATION = 5 * 60 * 1000; // 5분
|
||||||
|
const isExpired = Date.now() - cache.timestamp > CACHE_DURATION;
|
||||||
|
|
||||||
|
if (isExpired) {
|
||||||
|
set({ externalConnectionsCache: null });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return cache.data;
|
||||||
|
},
|
||||||
|
}; // 🔥 return 블록 종료
|
||||||
|
}); // 🔥 create 함수 종료
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 헬퍼 함수들
|
// 헬퍼 함수들
|
||||||
|
|
|
||||||
|
|
@ -1,155 +1,389 @@
|
||||||
import type { ComponentConfig, GridConfig } from "@/types/report";
|
import { Position, Size } from "@/types/screen";
|
||||||
|
import { GridSettings } from "@/types/screen-management";
|
||||||
|
|
||||||
/**
|
export interface GridInfo {
|
||||||
* 픽셀 좌표를 그리드 좌표로 변환
|
columnWidth: number;
|
||||||
*/
|
totalWidth: number;
|
||||||
export function pixelToGrid(pixel: number, cellSize: number): number {
|
totalHeight: number;
|
||||||
return Math.round(pixel / cellSize);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그리드 좌표를 픽셀 좌표로 변환
|
* 격자 정보 계산
|
||||||
*/
|
*/
|
||||||
export function gridToPixel(grid: number, cellSize: number): number {
|
export function calculateGridInfo(
|
||||||
return grid * cellSize;
|
containerWidth: number,
|
||||||
}
|
containerHeight: number,
|
||||||
|
gridSettings: GridSettings,
|
||||||
|
): GridInfo {
|
||||||
|
const { columns, gap, padding } = gridSettings;
|
||||||
|
|
||||||
/**
|
// 사용 가능한 너비 계산 (패딩 제외)
|
||||||
* 컴포넌트 위치/크기를 그리드에 스냅
|
const availableWidth = containerWidth - padding * 2;
|
||||||
*/
|
|
||||||
export function snapComponentToGrid(component: ComponentConfig, gridConfig: GridConfig): ComponentConfig {
|
|
||||||
if (!gridConfig.snapToGrid) {
|
|
||||||
return component;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 픽셀 좌표를 그리드 좌표로 변환
|
// 격자 간격을 고려한 컬럼 너비 계산
|
||||||
const gridX = pixelToGrid(component.x, gridConfig.cellWidth);
|
const totalGaps = (columns - 1) * gap;
|
||||||
const gridY = pixelToGrid(component.y, gridConfig.cellHeight);
|
const columnWidth = (availableWidth - totalGaps) / columns;
|
||||||
const gridWidth = Math.max(1, pixelToGrid(component.width, gridConfig.cellWidth));
|
|
||||||
const gridHeight = Math.max(1, pixelToGrid(component.height, gridConfig.cellHeight));
|
|
||||||
|
|
||||||
// 그리드 좌표를 다시 픽셀로 변환
|
|
||||||
return {
|
return {
|
||||||
...component,
|
columnWidth: Math.max(columnWidth, 20), // 최소 20px로 줄여서 더 많은 컬럼 표시
|
||||||
gridX,
|
totalWidth: containerWidth,
|
||||||
gridY,
|
totalHeight: containerHeight,
|
||||||
gridWidth,
|
|
||||||
gridHeight,
|
|
||||||
x: gridToPixel(gridX, gridConfig.cellWidth),
|
|
||||||
y: gridToPixel(gridY, gridConfig.cellHeight),
|
|
||||||
width: gridToPixel(gridWidth, gridConfig.cellWidth),
|
|
||||||
height: gridToPixel(gridHeight, gridConfig.cellHeight),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그리드 충돌 감지
|
* 위치를 격자에 맞춤
|
||||||
* 두 컴포넌트가 겹치는지 확인
|
|
||||||
*/
|
*/
|
||||||
export function detectGridCollision(
|
export function snapToGrid(position: Position, gridInfo: GridInfo, gridSettings: GridSettings): Position {
|
||||||
component: ComponentConfig,
|
if (!gridSettings.snapToGrid) {
|
||||||
otherComponents: ComponentConfig[],
|
return position;
|
||||||
gridConfig: GridConfig,
|
}
|
||||||
): boolean {
|
|
||||||
const comp1GridX = component.gridX ?? pixelToGrid(component.x, gridConfig.cellWidth);
|
|
||||||
const comp1GridY = component.gridY ?? pixelToGrid(component.y, gridConfig.cellHeight);
|
|
||||||
const comp1GridWidth = component.gridWidth ?? pixelToGrid(component.width, gridConfig.cellWidth);
|
|
||||||
const comp1GridHeight = component.gridHeight ?? pixelToGrid(component.height, gridConfig.cellHeight);
|
|
||||||
|
|
||||||
for (const other of otherComponents) {
|
const { columnWidth } = gridInfo;
|
||||||
if (other.id === component.id) continue;
|
const { gap, padding } = gridSettings;
|
||||||
|
|
||||||
const comp2GridX = other.gridX ?? pixelToGrid(other.x, gridConfig.cellWidth);
|
// 격자 셀 크기 (컬럼 너비 + 간격을 하나의 격자 단위로 계산)
|
||||||
const comp2GridY = other.gridY ?? pixelToGrid(other.y, gridConfig.cellHeight);
|
const cellWidth = columnWidth + gap;
|
||||||
const comp2GridWidth = other.gridWidth ?? pixelToGrid(other.width, gridConfig.cellWidth);
|
const cellHeight = Math.max(40, gap * 2); // 행 높이를 더 크게 설정
|
||||||
const comp2GridHeight = other.gridHeight ?? pixelToGrid(other.height, gridConfig.cellHeight);
|
|
||||||
|
|
||||||
// AABB (Axis-Aligned Bounding Box) 충돌 감지
|
// 패딩을 제외한 상대 위치
|
||||||
const xOverlap = comp1GridX < comp2GridX + comp2GridWidth && comp1GridX + comp1GridWidth > comp2GridX;
|
const relativeX = position.x - padding;
|
||||||
const yOverlap = comp1GridY < comp2GridY + comp2GridHeight && comp1GridY + comp1GridHeight > comp2GridY;
|
const relativeY = position.y - padding;
|
||||||
|
|
||||||
if (xOverlap && yOverlap) {
|
// 격자 기준으로 위치 계산 (가장 가까운 격자점으로 스냅)
|
||||||
return true;
|
const gridX = Math.round(relativeX / cellWidth);
|
||||||
|
const gridY = Math.round(relativeY / cellHeight);
|
||||||
|
|
||||||
|
// 실제 픽셀 위치로 변환
|
||||||
|
const snappedX = Math.max(padding, padding + gridX * cellWidth);
|
||||||
|
const snappedY = Math.max(padding, padding + gridY * cellHeight);
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: snappedX,
|
||||||
|
y: snappedY,
|
||||||
|
z: position.z,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 크기를 격자에 맞춤
|
||||||
|
*/
|
||||||
|
export function snapSizeToGrid(size: Size, gridInfo: GridInfo, gridSettings: GridSettings): Size {
|
||||||
|
if (!gridSettings.snapToGrid) {
|
||||||
|
return size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { columnWidth } = gridInfo;
|
||||||
|
const { gap } = gridSettings;
|
||||||
|
|
||||||
|
// 격자 단위로 너비 계산
|
||||||
|
// 컴포넌트가 차지하는 컬럼 수를 올바르게 계산
|
||||||
|
let gridColumns = 1;
|
||||||
|
|
||||||
|
// 현재 너비에서 가장 가까운 격자 컬럼 수 찾기
|
||||||
|
for (let cols = 1; cols <= gridSettings.columns; cols++) {
|
||||||
|
const targetWidth = cols * columnWidth + (cols - 1) * gap;
|
||||||
|
if (size.width <= targetWidth + (columnWidth + gap) / 2) {
|
||||||
|
gridColumns = cols;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
gridColumns = cols;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snappedWidth = gridColumns * columnWidth + (gridColumns - 1) * gap;
|
||||||
|
|
||||||
|
// 높이는 동적 행 높이 단위로 스냅
|
||||||
|
const rowHeight = Math.max(20, gap);
|
||||||
|
const snappedHeight = Math.max(40, Math.round(size.height / rowHeight) * rowHeight);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`📏 크기 스냅: ${size.width}px → ${snappedWidth}px (${gridColumns}컬럼, 컬럼너비:${columnWidth}px, 간격:${gap}px)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Math.max(columnWidth, snappedWidth),
|
||||||
|
height: snappedHeight,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 격자 컬럼 수로 너비 계산
|
||||||
|
*/
|
||||||
|
export function calculateWidthFromColumns(columns: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||||
|
const { columnWidth } = gridInfo;
|
||||||
|
const { gap } = gridSettings;
|
||||||
|
|
||||||
|
return columns * columnWidth + (columns - 1) * gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gridColumns 속성을 기반으로 컴포넌트 크기 업데이트
|
||||||
|
*/
|
||||||
|
export function updateSizeFromGridColumns(
|
||||||
|
component: { gridColumns?: number; size: Size },
|
||||||
|
gridInfo: GridInfo,
|
||||||
|
gridSettings: GridSettings,
|
||||||
|
): Size {
|
||||||
|
if (!component.gridColumns || component.gridColumns < 1) {
|
||||||
|
return component.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWidth = calculateWidthFromColumns(component.gridColumns, gridInfo, gridSettings);
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: newWidth,
|
||||||
|
height: component.size.height, // 높이는 유지
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 컴포넌트의 gridColumns를 자동으로 크기에 맞게 조정
|
||||||
|
*/
|
||||||
|
export function adjustGridColumnsFromSize(
|
||||||
|
component: { size: Size },
|
||||||
|
gridInfo: GridInfo,
|
||||||
|
gridSettings: GridSettings,
|
||||||
|
): number {
|
||||||
|
const columns = calculateColumnsFromWidth(component.size.width, gridInfo, gridSettings);
|
||||||
|
return Math.min(Math.max(1, columns), gridSettings.columns); // 1-12 범위로 제한
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 너비에서 격자 컬럼 수 계산
|
||||||
|
*/
|
||||||
|
export function calculateColumnsFromWidth(width: number, gridInfo: GridInfo, gridSettings: GridSettings): number {
|
||||||
|
const { columnWidth } = gridInfo;
|
||||||
|
const { gap } = gridSettings;
|
||||||
|
|
||||||
|
return Math.max(1, Math.round((width + gap) / (columnWidth + gap)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 격자 가이드라인 생성
|
||||||
|
*/
|
||||||
|
export function generateGridLines(
|
||||||
|
containerWidth: number,
|
||||||
|
containerHeight: number,
|
||||||
|
gridSettings: GridSettings,
|
||||||
|
): {
|
||||||
|
verticalLines: number[];
|
||||||
|
horizontalLines: number[];
|
||||||
|
} {
|
||||||
|
const { columns, gap, padding } = gridSettings;
|
||||||
|
const gridInfo = calculateGridInfo(containerWidth, containerHeight, gridSettings);
|
||||||
|
const { columnWidth } = gridInfo;
|
||||||
|
|
||||||
|
// 격자 셀 크기 (스냅 로직과 동일하게)
|
||||||
|
const cellWidth = columnWidth + gap;
|
||||||
|
const cellHeight = Math.max(40, gap * 2);
|
||||||
|
|
||||||
|
// 세로 격자선
|
||||||
|
const verticalLines: number[] = [];
|
||||||
|
for (let i = 0; i <= columns; i++) {
|
||||||
|
const x = padding + i * cellWidth;
|
||||||
|
if (x <= containerWidth) {
|
||||||
|
verticalLines.push(x);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
// 가로 격자선
|
||||||
}
|
const horizontalLines: number[] = [];
|
||||||
|
for (let y = padding; y < containerHeight; y += cellHeight) {
|
||||||
|
horizontalLines.push(y);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 페이지 크기 기반 그리드 행/열 계산
|
|
||||||
*/
|
|
||||||
export function calculateGridDimensions(
|
|
||||||
pageWidth: number,
|
|
||||||
pageHeight: number,
|
|
||||||
cellWidth: number,
|
|
||||||
cellHeight: number,
|
|
||||||
): { rows: number; columns: number } {
|
|
||||||
return {
|
return {
|
||||||
columns: Math.floor(pageWidth / cellWidth),
|
verticalLines,
|
||||||
rows: Math.floor(pageHeight / cellHeight),
|
horizontalLines,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 그리드 설정 생성
|
* 컴포넌트가 격자 경계에 있는지 확인
|
||||||
*/
|
*/
|
||||||
export function createDefaultGridConfig(pageWidth: number, pageHeight: number): GridConfig {
|
export function isOnGridBoundary(
|
||||||
const cellWidth = 20;
|
position: Position,
|
||||||
const cellHeight = 20;
|
size: Size,
|
||||||
const { rows, columns } = calculateGridDimensions(pageWidth, pageHeight, cellWidth, cellHeight);
|
gridInfo: GridInfo,
|
||||||
|
gridSettings: GridSettings,
|
||||||
return {
|
tolerance: number = 5,
|
||||||
cellWidth,
|
|
||||||
cellHeight,
|
|
||||||
rows,
|
|
||||||
columns,
|
|
||||||
visible: true,
|
|
||||||
snapToGrid: true,
|
|
||||||
gridColor: "#e5e7eb",
|
|
||||||
gridOpacity: 0.5,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 위치가 페이지 경계 내에 있는지 확인
|
|
||||||
*/
|
|
||||||
export function isWithinPageBounds(
|
|
||||||
component: ComponentConfig,
|
|
||||||
pageWidth: number,
|
|
||||||
pageHeight: number,
|
|
||||||
margins: { top: number; bottom: number; left: number; right: number },
|
|
||||||
): boolean {
|
): boolean {
|
||||||
const minX = margins.left;
|
const snappedPos = snapToGrid(position, gridInfo, gridSettings);
|
||||||
const minY = margins.top;
|
const snappedSize = snapSizeToGrid(size, gridInfo, gridSettings);
|
||||||
const maxX = pageWidth - margins.right;
|
|
||||||
const maxY = pageHeight - margins.bottom;
|
|
||||||
|
|
||||||
return (
|
const positionMatch =
|
||||||
component.x >= minX &&
|
Math.abs(position.x - snappedPos.x) <= tolerance && Math.abs(position.y - snappedPos.y) <= tolerance;
|
||||||
component.y >= minY &&
|
|
||||||
component.x + component.width <= maxX &&
|
const sizeMatch =
|
||||||
component.y + component.height <= maxY
|
Math.abs(size.width - snappedSize.width) <= tolerance && Math.abs(size.height - snappedSize.height) <= tolerance;
|
||||||
);
|
|
||||||
|
return positionMatch && sizeMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트를 페이지 경계 내로 제한
|
* 그룹 내부 컴포넌트들을 격자에 맞게 정렬
|
||||||
*/
|
*/
|
||||||
export function constrainToPageBounds(
|
export function alignGroupChildrenToGrid(
|
||||||
component: ComponentConfig,
|
children: any[],
|
||||||
pageWidth: number,
|
groupPosition: Position,
|
||||||
pageHeight: number,
|
gridInfo: GridInfo,
|
||||||
margins: { top: number; bottom: number; left: number; right: number },
|
gridSettings: GridSettings,
|
||||||
): ComponentConfig {
|
): any[] {
|
||||||
const minX = margins.left;
|
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||||
const minY = margins.top;
|
|
||||||
const maxX = pageWidth - margins.right - component.width;
|
|
||||||
const maxY = pageHeight - margins.bottom - component.height;
|
|
||||||
|
|
||||||
return {
|
console.log("🔧 alignGroupChildrenToGrid 시작:", {
|
||||||
...component,
|
childrenCount: children.length,
|
||||||
x: Math.max(minX, Math.min(maxX, component.x)),
|
groupPosition,
|
||||||
y: Math.max(minY, Math.min(maxY, component.y)),
|
gridInfo,
|
||||||
};
|
gridSettings,
|
||||||
|
});
|
||||||
|
|
||||||
|
return children.map((child, index) => {
|
||||||
|
console.log(`📐 자식 ${index + 1} 처리 중:`, {
|
||||||
|
childId: child.id,
|
||||||
|
originalPosition: child.position,
|
||||||
|
originalSize: child.size,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { columnWidth } = gridInfo;
|
||||||
|
const { gap } = gridSettings;
|
||||||
|
|
||||||
|
// 그룹 내부 패딩 고려한 격자 정렬
|
||||||
|
const padding = 16;
|
||||||
|
const effectiveX = child.position.x - padding;
|
||||||
|
const columnIndex = Math.round(effectiveX / (columnWidth + gap));
|
||||||
|
const snappedX = padding + columnIndex * (columnWidth + gap);
|
||||||
|
|
||||||
|
// Y 좌표는 동적 행 높이 단위로 스냅
|
||||||
|
const rowHeight = Math.max(20, gap);
|
||||||
|
const effectiveY = child.position.y - padding;
|
||||||
|
const rowIndex = Math.round(effectiveY / rowHeight);
|
||||||
|
const snappedY = padding + rowIndex * rowHeight;
|
||||||
|
|
||||||
|
// 크기는 외부 격자와 동일하게 스냅 (columnWidth + gap 사용)
|
||||||
|
const fullColumnWidth = columnWidth + gap; // 외부 격자와 동일한 크기
|
||||||
|
const widthInColumns = Math.max(1, Math.round(child.size.width / fullColumnWidth));
|
||||||
|
const snappedWidth = widthInColumns * fullColumnWidth - gap; // gap 제거하여 실제 컴포넌트 크기
|
||||||
|
const snappedHeight = Math.max(40, Math.round(child.size.height / rowHeight) * rowHeight);
|
||||||
|
|
||||||
|
const snappedChild = {
|
||||||
|
...child,
|
||||||
|
position: {
|
||||||
|
x: Math.max(padding, snappedX), // 패딩만큼 최소 여백 확보
|
||||||
|
y: Math.max(padding, snappedY),
|
||||||
|
z: child.position.z || 1,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
width: snappedWidth,
|
||||||
|
height: snappedHeight,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`✅ 자식 ${index + 1} 격자 정렬 완료:`, {
|
||||||
|
childId: child.id,
|
||||||
|
calculation: {
|
||||||
|
effectiveX,
|
||||||
|
effectiveY,
|
||||||
|
columnIndex,
|
||||||
|
rowIndex,
|
||||||
|
widthInColumns,
|
||||||
|
originalX: child.position.x,
|
||||||
|
snappedX: snappedChild.position.x,
|
||||||
|
padding,
|
||||||
|
},
|
||||||
|
snappedPosition: snappedChild.position,
|
||||||
|
snappedSize: snappedChild.size,
|
||||||
|
deltaX: snappedChild.position.x - child.position.x,
|
||||||
|
deltaY: snappedChild.position.y - child.position.y,
|
||||||
|
});
|
||||||
|
|
||||||
|
return snappedChild;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 생성 시 최적화된 그룹 크기 계산
|
||||||
|
*/
|
||||||
|
export function calculateOptimalGroupSize(
|
||||||
|
children: Array<{ position: Position; size: Size }>,
|
||||||
|
gridInfo: GridInfo,
|
||||||
|
gridSettings: GridSettings,
|
||||||
|
): Size {
|
||||||
|
if (children.length === 0) {
|
||||||
|
return { width: gridInfo.columnWidth * 2, height: 40 * 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("📏 calculateOptimalGroupSize 시작:", {
|
||||||
|
childrenCount: children.length,
|
||||||
|
children: children.map((c) => ({ pos: c.position, size: c.size })),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 자식 컴포넌트를 포함하는 최소 경계 계산
|
||||||
|
const bounds = children.reduce(
|
||||||
|
(acc, child) => ({
|
||||||
|
minX: Math.min(acc.minX, child.position.x),
|
||||||
|
minY: Math.min(acc.minY, child.position.y),
|
||||||
|
maxX: Math.max(acc.maxX, child.position.x + child.size.width),
|
||||||
|
maxY: Math.max(acc.maxY, child.position.y + child.size.height),
|
||||||
|
}),
|
||||||
|
{ minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity },
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log("📐 경계 계산:", bounds);
|
||||||
|
|
||||||
|
const contentWidth = bounds.maxX - bounds.minX;
|
||||||
|
const contentHeight = bounds.maxY - bounds.minY;
|
||||||
|
|
||||||
|
// 그룹은 격자 스냅 없이 컨텐츠에 맞는 자연스러운 크기
|
||||||
|
const padding = 16; // 그룹 내부 여백
|
||||||
|
const groupSize = {
|
||||||
|
width: contentWidth + padding * 2,
|
||||||
|
height: contentHeight + padding * 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("✅ 자연스러운 그룹 크기:", {
|
||||||
|
contentSize: { width: contentWidth, height: contentHeight },
|
||||||
|
withPadding: groupSize,
|
||||||
|
strategy: "그룹은 격자 스냅 없이, 내부 컴포넌트만 격자에 맞춤",
|
||||||
|
});
|
||||||
|
|
||||||
|
return groupSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 그룹 내 상대 좌표를 격자 기준으로 정규화
|
||||||
|
*/
|
||||||
|
export function normalizeGroupChildPositions(children: any[], gridSettings: GridSettings): any[] {
|
||||||
|
if (!gridSettings.snapToGrid || children.length === 0) return children;
|
||||||
|
|
||||||
|
console.log("🔄 normalizeGroupChildPositions 시작:", {
|
||||||
|
childrenCount: children.length,
|
||||||
|
originalPositions: children.map((c) => ({ id: c.id, pos: c.position })),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 모든 자식의 최소 위치 찾기
|
||||||
|
const minX = Math.min(...children.map((child) => child.position.x));
|
||||||
|
const minY = Math.min(...children.map((child) => child.position.y));
|
||||||
|
|
||||||
|
console.log("📍 최소 위치:", { minX, minY });
|
||||||
|
|
||||||
|
// 그룹 내에서 시작점을 패딩만큼 떨어뜨림 (자연스러운 여백)
|
||||||
|
const padding = 16;
|
||||||
|
const startX = padding;
|
||||||
|
const startY = padding;
|
||||||
|
|
||||||
|
const normalizedChildren = children.map((child) => ({
|
||||||
|
...child,
|
||||||
|
position: {
|
||||||
|
x: child.position.x - minX + startX,
|
||||||
|
y: child.position.y - minY + startY,
|
||||||
|
z: child.position.z || 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
console.log("✅ 정규화 완료:", {
|
||||||
|
normalizedPositions: normalizedChildren.map((c) => ({ id: c.id, pos: c.position })),
|
||||||
|
});
|
||||||
|
|
||||||
|
return normalizedChildren;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,6 @@ export type NodeType =
|
||||||
| "restAPISource" // REST API 소스
|
| "restAPISource" // REST API 소스
|
||||||
| "referenceLookup" // 참조 테이블 조회 (내부 DB 전용)
|
| "referenceLookup" // 참조 테이블 조회 (내부 DB 전용)
|
||||||
| "condition" // 조건 분기
|
| "condition" // 조건 분기
|
||||||
| "fieldMapping" // 필드 매핑
|
|
||||||
| "dataTransform" // 데이터 변환
|
| "dataTransform" // 데이터 변환
|
||||||
| "insertAction" // INSERT 액션
|
| "insertAction" // INSERT 액션
|
||||||
| "updateAction" // UPDATE 액션
|
| "updateAction" // UPDATE 액션
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue