Compare commits

..

No commits in common. "d8ea7981fef31ee0ba3515e44434029e9e83a564" and "54a559a30981211940e914bd71de5fe1bbe31188" have entirely different histories.

51 changed files with 238 additions and 12449 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,174 +0,0 @@
/**
* DB
*
*/
import { Pool } from "pg";
import { CredentialEncryption } from "../src/utils/credentialEncryption";
async function addExternalDbConnection() {
const pool = new Pool({
host: process.env.DB_HOST || "localhost",
port: parseInt(process.env.DB_PORT || "5432"),
database: process.env.DB_NAME || "plm",
user: process.env.DB_USER || "postgres",
password: process.env.DB_PASSWORD || "ph0909!!",
});
// 환경 변수에서 암호화 키 가져오기 (없으면 기본값 사용)
const encryptionKey =
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
const encryption = new CredentialEncryption(encryptionKey);
try {
// 외부 DB 연결 정보 (실제 사용할 외부 DB 정보를 여기에 입력)
const externalDbConnections = [
{
name: "운영_외부_PostgreSQL",
description: "운영용 외부 PostgreSQL 데이터베이스",
dbType: "postgresql",
host: "39.117.244.52",
port: 11132,
databaseName: "plm",
username: "postgres",
password: "ph0909!!", // 이 값은 암호화되어 저장됩니다
sslEnabled: false,
isActive: true,
},
// 필요한 경우 추가 외부 DB 연결 정보를 여기에 추가
// {
// name: "테스트_MySQL",
// description: "테스트용 MySQL 데이터베이스",
// dbType: "mysql",
// host: "test-mysql.example.com",
// port: 3306,
// databaseName: "testdb",
// username: "testuser",
// password: "testpass",
// sslEnabled: true,
// isActive: true,
// },
];
for (const conn of externalDbConnections) {
// 비밀번호 암호화
const encryptedPassword = encryption.encrypt(conn.password);
// 중복 체크 (이름 기준)
const existingResult = await pool.query(
"SELECT id FROM flow_external_db_connection WHERE name = $1",
[conn.name]
);
if (existingResult.rows.length > 0) {
console.log(
`⚠️ 이미 존재하는 연결: ${conn.name} (ID: ${existingResult.rows[0].id})`
);
// 기존 연결 업데이트
await pool.query(
`UPDATE flow_external_db_connection
SET description = $1,
db_type = $2,
host = $3,
port = $4,
database_name = $5,
username = $6,
password_encrypted = $7,
ssl_enabled = $8,
is_active = $9,
updated_at = NOW(),
updated_by = 'system'
WHERE name = $10`,
[
conn.description,
conn.dbType,
conn.host,
conn.port,
conn.databaseName,
conn.username,
encryptedPassword,
conn.sslEnabled,
conn.isActive,
conn.name,
]
);
console.log(`✅ 연결 정보 업데이트 완료: ${conn.name}`);
} else {
// 새 연결 추가
const result = await pool.query(
`INSERT INTO flow_external_db_connection (
name,
description,
db_type,
host,
port,
database_name,
username,
password_encrypted,
ssl_enabled,
is_active,
created_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'system')
RETURNING id`,
[
conn.name,
conn.description,
conn.dbType,
conn.host,
conn.port,
conn.databaseName,
conn.username,
encryptedPassword,
conn.sslEnabled,
conn.isActive,
]
);
console.log(
`✅ 새 연결 추가 완료: ${conn.name} (ID: ${result.rows[0].id})`
);
}
// 연결 테스트
console.log(`🔍 연결 테스트 중: ${conn.name}...`);
const testPool = new Pool({
host: conn.host,
port: conn.port,
database: conn.databaseName,
user: conn.username,
password: conn.password,
ssl: conn.sslEnabled,
connectionTimeoutMillis: 5000,
});
try {
const client = await testPool.connect();
await client.query("SELECT 1");
client.release();
console.log(`✅ 연결 테스트 성공: ${conn.name}`);
} catch (testError: any) {
console.error(`❌ 연결 테스트 실패: ${conn.name}`, testError.message);
} finally {
await testPool.end();
}
}
console.log("\n✅ 모든 외부 DB 연결 정보 처리 완료");
} catch (error) {
console.error("❌ 외부 DB 연결 정보 추가 오류:", error);
throw error;
} finally {
await pool.end();
}
}
// 스크립트 실행
addExternalDbConnection()
.then(() => {
console.log("✅ 스크립트 완료");
process.exit(0);
})
.catch((error) => {
console.error("❌ 스크립트 실패:", error);
process.exit(1);
});

View File

@ -1,16 +0,0 @@
/**
*
*/
import { CredentialEncryption } from "../src/utils/credentialEncryption";
const encryptionKey =
process.env.ENCRYPTION_SECRET_KEY || "default-secret-key-for-development";
const encryption = new CredentialEncryption(encryptionKey);
const password = process.argv[2] || "ph0909!!";
const encrypted = encryption.encrypt(password);
console.log("\n원본 비밀번호:", password);
console.log("암호화된 비밀번호:", encrypted);
console.log("\n복호화 테스트:", encryption.decrypt(encrypted));
console.log("✅ 암호화/복호화 성공\n");

View File

@ -56,9 +56,6 @@ import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
import { BatchSchedulerService } from "./services/batchSchedulerService";
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
@ -210,9 +207,6 @@ app.use("/api/todos", todoRoutes); // To-Do 관리
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
// app.use("/api/collections", collectionRoutes); // 임시 주석
// app.use("/api/batch", batchRoutes); // 임시 주석

View File

@ -1,676 +0,0 @@
/**
*
*/
import { Request, Response } from "express";
import { FlowDefinitionService } from "../services/flowDefinitionService";
import { FlowStepService } from "../services/flowStepService";
import { FlowConnectionService } from "../services/flowConnectionService";
import { FlowExecutionService } from "../services/flowExecutionService";
import { FlowDataMoveService } from "../services/flowDataMoveService";
export class FlowController {
private flowDefinitionService: FlowDefinitionService;
private flowStepService: FlowStepService;
private flowConnectionService: FlowConnectionService;
private flowExecutionService: FlowExecutionService;
private flowDataMoveService: FlowDataMoveService;
constructor() {
this.flowDefinitionService = new FlowDefinitionService();
this.flowStepService = new FlowStepService();
this.flowConnectionService = new FlowConnectionService();
this.flowExecutionService = new FlowExecutionService();
this.flowDataMoveService = new FlowDataMoveService();
}
// ==================== 플로우 정의 ====================
/**
*
*/
createFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try {
const { name, description, tableName } = req.body;
const userId = (req as any).user?.userId || "system";
if (!name || !tableName) {
res.status(400).json({
success: false,
message: "Name and tableName are required",
});
return;
}
// 테이블 존재 확인
const tableExists =
await this.flowDefinitionService.checkTableExists(tableName);
if (!tableExists) {
res.status(400).json({
success: false,
message: `Table '${tableName}' does not exist`,
});
return;
}
const flowDef = await this.flowDefinitionService.create(
{ name, description, tableName },
userId
);
res.json({
success: true,
data: flowDef,
});
} catch (error: any) {
console.error("Error creating flow definition:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to create flow definition",
});
}
};
/**
*
*/
getFlowDefinitions = async (req: Request, res: Response): Promise<void> => {
try {
const { tableName, isActive } = req.query;
const flows = await this.flowDefinitionService.findAll(
tableName as string | undefined,
isActive !== undefined ? isActive === "true" : undefined
);
res.json({
success: true,
data: flows,
});
} catch (error: any) {
console.error("Error fetching flow definitions:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to fetch flow definitions",
});
}
};
/**
* ( )
*/
getFlowDefinitionDetail = async (
req: Request,
res: Response
): Promise<void> => {
try {
const { id } = req.params;
const flowId = parseInt(id);
const definition = await this.flowDefinitionService.findById(flowId);
if (!definition) {
res.status(404).json({
success: false,
message: "Flow definition not found",
});
return;
}
const steps = await this.flowStepService.findByFlowId(flowId);
const connections = await this.flowConnectionService.findByFlowId(flowId);
res.json({
success: true,
data: {
definition,
steps,
connections,
},
});
} catch (error: any) {
console.error("Error fetching flow definition detail:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to fetch flow definition detail",
});
}
};
/**
*
*/
updateFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const flowId = parseInt(id);
const { name, description, isActive } = req.body;
const flowDef = await this.flowDefinitionService.update(flowId, {
name,
description,
isActive,
});
if (!flowDef) {
res.status(404).json({
success: false,
message: "Flow definition not found",
});
return;
}
res.json({
success: true,
data: flowDef,
});
} catch (error: any) {
console.error("Error updating flow definition:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to update flow definition",
});
}
};
/**
*
*/
deleteFlowDefinition = async (req: Request, res: Response): Promise<void> => {
try {
const { id } = req.params;
const flowId = parseInt(id);
const success = await this.flowDefinitionService.delete(flowId);
if (!success) {
res.status(404).json({
success: false,
message: "Flow definition not found",
});
return;
}
res.json({
success: true,
message: "Flow definition deleted successfully",
});
} catch (error: any) {
console.error("Error deleting flow definition:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to delete flow definition",
});
}
};
// ==================== 플로우 단계 ====================
/**
*
*/
getFlowSteps = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId } = req.params;
const flowDefinitionId = parseInt(flowId);
const steps = await this.flowStepService.findByFlowId(flowDefinitionId);
res.json({
success: true,
data: steps,
});
return;
} catch (error: any) {
console.error("Error fetching flow steps:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to fetch flow steps",
});
return;
}
};
/**
*
*/
createFlowStep = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId } = req.params;
const flowDefinitionId = parseInt(flowId);
const {
stepName,
stepOrder,
tableName,
conditionJson,
color,
positionX,
positionY,
} = req.body;
if (!stepName || stepOrder === undefined) {
res.status(400).json({
success: false,
message: "stepName and stepOrder are required",
});
return;
}
const step = await this.flowStepService.create({
flowDefinitionId,
stepName,
stepOrder,
tableName,
conditionJson,
color,
positionX,
positionY,
});
res.json({
success: true,
data: step,
});
} catch (error: any) {
console.error("Error creating flow step:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to create flow step",
});
}
};
/**
*
*/
updateFlowStep = async (req: Request, res: Response): Promise<void> => {
try {
const { stepId } = req.params;
const id = parseInt(stepId);
const {
stepName,
stepOrder,
tableName,
conditionJson,
color,
positionX,
positionY,
} = req.body;
const step = await this.flowStepService.update(id, {
stepName,
stepOrder,
tableName,
conditionJson,
color,
positionX,
positionY,
});
if (!step) {
res.status(404).json({
success: false,
message: "Flow step not found",
});
return;
}
res.json({
success: true,
data: step,
});
} catch (error: any) {
console.error("Error updating flow step:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to update flow step",
});
}
};
/**
*
*/
deleteFlowStep = async (req: Request, res: Response): Promise<void> => {
try {
const { stepId } = req.params;
const id = parseInt(stepId);
const success = await this.flowStepService.delete(id);
if (!success) {
res.status(404).json({
success: false,
message: "Flow step not found",
});
return;
}
res.json({
success: true,
message: "Flow step deleted successfully",
});
} catch (error: any) {
console.error("Error deleting flow step:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to delete flow step",
});
}
};
// ==================== 플로우 연결 ====================
/**
*
*/
getFlowConnections = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId } = req.params;
const flowDefinitionId = parseInt(flowId);
const connections =
await this.flowConnectionService.findByFlowId(flowDefinitionId);
res.json({
success: true,
data: connections,
});
return;
} catch (error: any) {
console.error("Error fetching flow connections:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to fetch flow connections",
});
return;
}
};
/**
*
*/
createConnection = async (req: Request, res: Response): Promise<void> => {
try {
const { flowDefinitionId, fromStepId, toStepId, label } = req.body;
if (!flowDefinitionId || !fromStepId || !toStepId) {
res.status(400).json({
success: false,
message: "flowDefinitionId, fromStepId, and toStepId are required",
});
return;
}
const connection = await this.flowConnectionService.create({
flowDefinitionId,
fromStepId,
toStepId,
label,
});
res.json({
success: true,
data: connection,
});
} catch (error: any) {
console.error("Error creating connection:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to create connection",
});
}
};
/**
*
*/
deleteConnection = async (req: Request, res: Response): Promise<void> => {
try {
const { connectionId } = req.params;
const id = parseInt(connectionId);
const success = await this.flowConnectionService.delete(id);
if (!success) {
res.status(404).json({
success: false,
message: "Connection not found",
});
return;
}
res.json({
success: true,
message: "Connection deleted successfully",
});
} catch (error: any) {
console.error("Error deleting connection:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to delete connection",
});
}
};
// ==================== 플로우 실행 ====================
/**
*
*/
getStepDataCount = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, stepId } = req.params;
const count = await this.flowExecutionService.getStepDataCount(
parseInt(flowId),
parseInt(stepId)
);
res.json({
success: true,
data: { count },
});
} catch (error: any) {
console.error("Error getting step data count:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to get step data count",
});
}
};
/**
*
*/
getStepDataList = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, stepId } = req.params;
const { page = 1, pageSize = 20 } = req.query;
const data = await this.flowExecutionService.getStepDataList(
parseInt(flowId),
parseInt(stepId),
parseInt(page as string),
parseInt(pageSize as string)
);
res.json({
success: true,
data,
});
} catch (error: any) {
console.error("Error getting step data list:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to get step data list",
});
}
};
/**
*
*/
getAllStepCounts = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId } = req.params;
const counts = await this.flowExecutionService.getAllStepCounts(
parseInt(flowId)
);
res.json({
success: true,
data: counts,
});
} catch (error: any) {
console.error("Error getting all step counts:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to get all step counts",
});
}
};
/**
*
*/
moveData = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, recordId, toStepId, note } = req.body;
const userId = (req as any).user?.userId || "system";
if (!flowId || !recordId || !toStepId) {
res.status(400).json({
success: false,
message: "flowId, recordId, and toStepId are required",
});
return;
}
await this.flowDataMoveService.moveDataToStep(
flowId,
recordId,
toStepId,
userId,
note
);
res.json({
success: true,
message: "Data moved successfully",
});
} catch (error: any) {
console.error("Error moving data:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to move data",
});
}
};
/**
*
*/
moveBatchData = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, fromStepId, toStepId, dataIds } = req.body;
const userId = (req as any).user?.userId || "system";
if (
!flowId ||
!fromStepId ||
!toStepId ||
!dataIds ||
!Array.isArray(dataIds)
) {
res.status(400).json({
success: false,
message:
"flowId, fromStepId, toStepId, and dataIds (array) are required",
});
return;
}
const result = await this.flowDataMoveService.moveBatchData(
flowId,
fromStepId,
toStepId,
dataIds,
userId
);
const successCount = result.results.filter((r) => r.success).length;
const failureCount = result.results.filter((r) => !r.success).length;
res.json({
success: result.success,
message: result.success
? `${successCount}건의 데이터를 성공적으로 이동했습니다`
: `${successCount}건 성공, ${failureCount}건 실패`,
data: {
successCount,
failureCount,
total: dataIds.length,
},
results: result.results,
});
} catch (error: any) {
console.error("Error moving batch data:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to move batch data",
});
}
};
/**
*
*/
getAuditLogs = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId, recordId } = req.params;
const logs = await this.flowDataMoveService.getAuditLogs(
parseInt(flowId),
recordId
);
res.json({
success: true,
data: logs,
});
} catch (error: any) {
console.error("Error getting audit logs:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to get audit logs",
});
}
};
/**
*
*/
getFlowAuditLogs = async (req: Request, res: Response): Promise<void> => {
try {
const { flowId } = req.params;
const { limit = 100 } = req.query;
const logs = await this.flowDataMoveService.getFlowAuditLogs(
parseInt(flowId),
parseInt(limit as string)
);
res.json({
success: true,
data: logs,
});
} catch (error: any) {
console.error("Error getting flow audit logs:", error);
res.status(500).json({
success: false,
message: error.message || "Failed to get flow audit logs",
});
}
};
}

View File

@ -1,328 +0,0 @@
import { Request, Response } from "express";
import { FlowExternalDbConnectionService } from "../services/flowExternalDbConnectionService";
import {
CreateFlowExternalDbConnectionRequest,
UpdateFlowExternalDbConnectionRequest,
} from "../types/flow";
import logger from "../utils/logger";
/**
* DB
*/
export class FlowExternalDbConnectionController {
private service: FlowExternalDbConnectionService;
constructor() {
this.service = new FlowExternalDbConnectionService();
}
/**
* GET /api/flow/external-db-connections
* DB
*/
async getAll(req: Request, res: Response): Promise<void> {
try {
const activeOnly = req.query.activeOnly === "true";
const connections = await this.service.findAll(activeOnly);
res.json({
success: true,
data: connections,
message: `${connections.length}개의 외부 DB 연결을 조회했습니다`,
});
} catch (error: any) {
logger.error("외부 DB 연결 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 연결 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
}
/**
* GET /api/flow/external-db-connections/:id
* DB
*/
async getById(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "유효하지 않은 연결 ID입니다",
});
return;
}
const connection = await this.service.findById(id);
if (!connection) {
res.status(404).json({
success: false,
message: "외부 DB 연결을 찾을 수 없습니다",
});
return;
}
res.json({
success: true,
data: connection,
});
} catch (error: any) {
logger.error("외부 DB 연결 조회 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 연결 조회 중 오류가 발생했습니다",
error: error.message,
});
}
}
/**
* POST /api/flow/external-db-connections
* DB
*/
async create(req: Request, res: Response): Promise<void> {
try {
const request: CreateFlowExternalDbConnectionRequest = req.body;
// 필수 필드 검증
if (
!request.name ||
!request.dbType ||
!request.host ||
!request.port ||
!request.databaseName ||
!request.username ||
!request.password
) {
res.status(400).json({
success: false,
message: "필수 필드가 누락되었습니다",
});
return;
}
const userId = (req as any).user?.userId || "system";
const connection = await this.service.create(request, userId);
logger.info(
`외부 DB 연결 생성: ${connection.name} (ID: ${connection.id})`
);
res.status(201).json({
success: true,
data: connection,
message: "외부 DB 연결이 생성되었습니다",
});
} catch (error: any) {
logger.error("외부 DB 연결 생성 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 연결 생성 중 오류가 발생했습니다",
error: error.message,
});
}
}
/**
* PUT /api/flow/external-db-connections/:id
* DB
*/
async update(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "유효하지 않은 연결 ID입니다",
});
return;
}
const request: UpdateFlowExternalDbConnectionRequest = req.body;
const userId = (req as any).user?.userId || "system";
const connection = await this.service.update(id, request, userId);
if (!connection) {
res.status(404).json({
success: false,
message: "외부 DB 연결을 찾을 수 없습니다",
});
return;
}
logger.info(`외부 DB 연결 수정: ${connection.name} (ID: ${id})`);
res.json({
success: true,
data: connection,
message: "외부 DB 연결이 수정되었습니다",
});
} catch (error: any) {
logger.error("외부 DB 연결 수정 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 연결 수정 중 오류가 발생했습니다",
error: error.message,
});
}
}
/**
* DELETE /api/flow/external-db-connections/:id
* DB
*/
async delete(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "유효하지 않은 연결 ID입니다",
});
return;
}
const success = await this.service.delete(id);
if (!success) {
res.status(404).json({
success: false,
message: "외부 DB 연결을 찾을 수 없습니다",
});
return;
}
logger.info(`외부 DB 연결 삭제: ID ${id}`);
res.json({
success: true,
message: "외부 DB 연결이 삭제되었습니다",
});
} catch (error: any) {
logger.error("외부 DB 연결 삭제 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 연결 삭제 중 오류가 발생했습니다",
error: error.message,
});
}
}
/**
* POST /api/flow/external-db-connections/:id/test
* DB
*/
async testConnection(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "유효하지 않은 연결 ID입니다",
});
return;
}
const result = await this.service.testConnection(id);
if (result.success) {
logger.info(`외부 DB 연결 테스트 성공: ID ${id}`);
res.json({
success: true,
message: result.message,
});
} else {
logger.warn(`외부 DB 연결 테스트 실패: ID ${id} - ${result.message}`);
res.status(400).json({
success: false,
message: result.message,
});
}
} catch (error: any) {
logger.error("외부 DB 연결 테스트 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 연결 테스트 중 오류가 발생했습니다",
error: error.message,
});
}
}
/**
* GET /api/flow/external-db-connections/:id/tables
* DB의
*/
async getTables(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "유효하지 않은 연결 ID입니다",
});
return;
}
const result = await this.service.getTables(id);
if (result.success) {
res.json(result);
} else {
res.status(400).json(result);
}
} catch (error: any) {
logger.error("외부 DB 테이블 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 테이블 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
}
/**
* GET /api/flow/external-db-connections/:id/tables/:tableName/columns
* DB
*/
async getTableColumns(req: Request, res: Response): Promise<void> {
try {
const id = parseInt(req.params.id);
const tableName = req.params.tableName;
if (isNaN(id)) {
res.status(400).json({
success: false,
message: "유효하지 않은 연결 ID입니다",
});
return;
}
if (!tableName) {
res.status(400).json({
success: false,
message: "테이블명이 필요합니다",
});
return;
}
const result = await this.service.getTableColumns(id, tableName);
if (result.success) {
res.json(result);
} else {
res.status(400).json(result);
}
} catch (error: any) {
logger.error("외부 DB 컬럼 목록 조회 오류:", error);
res.status(500).json({
success: false,
message: "외부 DB 컬럼 목록 조회 중 오류가 발생했습니다",
error: error.message,
});
}
}
}

View File

@ -1,48 +0,0 @@
import { Router } from "express";
import { FlowExternalDbConnectionController } from "../controllers/flowExternalDbConnectionController";
import { authenticateToken } from "../middleware/authMiddleware";
const router = Router();
const controller = new FlowExternalDbConnectionController();
/**
* DB
* DB
*/
// 모든 외부 DB 연결 목록 조회 (읽기 전용 - 인증 불필요)
// 민감한 정보(비밀번호)는 반환하지 않으므로 안전
router.get("/", (req, res) => controller.getAll(req, res));
// 특정 외부 DB 연결 조회
router.get("/:id", authenticateToken, (req, res) =>
controller.getById(req, res)
);
// 새 외부 DB 연결 생성
router.post("/", authenticateToken, (req, res) => controller.create(req, res));
// 외부 DB 연결 수정
router.put("/:id", authenticateToken, (req, res) =>
controller.update(req, res)
);
// 외부 DB 연결 삭제
router.delete("/:id", authenticateToken, (req, res) =>
controller.delete(req, res)
);
// 외부 DB 연결 테스트
router.post("/:id/test", authenticateToken, (req, res) =>
controller.testConnection(req, res)
);
// 외부 DB의 테이블 목록 조회 (읽기 전용 - 인증 불필요)
router.get("/:id/tables", (req, res) => controller.getTables(req, res));
// 외부 DB의 특정 테이블의 컬럼 목록 조회 (읽기 전용 - 인증 불필요)
router.get("/:id/tables/:tableName/columns", (req, res) =>
controller.getTableColumns(req, res)
);
export default router;

View File

@ -1,42 +0,0 @@
/**
*
*/
import { Router } from "express";
import { FlowController } from "../controllers/flowController";
const router = Router();
const flowController = new FlowController();
// ==================== 플로우 정의 ====================
router.post("/definitions", flowController.createFlowDefinition);
router.get("/definitions", flowController.getFlowDefinitions);
router.get("/definitions/:id", flowController.getFlowDefinitionDetail);
router.put("/definitions/:id", flowController.updateFlowDefinition);
router.delete("/definitions/:id", flowController.deleteFlowDefinition);
// ==================== 플로우 단계 ====================
router.get("/definitions/:flowId/steps", flowController.getFlowSteps); // 단계 목록 조회
router.post("/definitions/:flowId/steps", flowController.createFlowStep);
router.put("/steps/:stepId", flowController.updateFlowStep);
router.delete("/steps/:stepId", flowController.deleteFlowStep);
// ==================== 플로우 연결 ====================
router.get("/connections/:flowId", flowController.getFlowConnections); // 연결 목록 조회
router.post("/connections", flowController.createConnection);
router.delete("/connections/:connectionId", flowController.deleteConnection);
// ==================== 플로우 실행 ====================
router.get("/:flowId/step/:stepId/count", flowController.getStepDataCount);
router.get("/:flowId/step/:stepId/list", flowController.getStepDataList);
router.get("/:flowId/steps/counts", flowController.getAllStepCounts);
// ==================== 데이터 이동 ====================
router.post("/move", flowController.moveData);
router.post("/move-batch", flowController.moveBatchData);
// ==================== 오딧 로그 ====================
router.get("/audit/:flowId/:recordId", flowController.getAuditLogs);
router.get("/audit/:flowId", flowController.getFlowAuditLogs);
export default router;

View File

@ -1,215 +0,0 @@
/**
*
* JSON SQL WHERE
*/
import {
FlowCondition,
FlowConditionGroup,
SqlWhereResult,
} from "../types/flow";
export class FlowConditionParser {
/**
* JSON을 SQL WHERE
*/
static toSqlWhere(
conditionGroup: FlowConditionGroup | null | undefined
): SqlWhereResult {
if (
!conditionGroup ||
!conditionGroup.conditions ||
conditionGroup.conditions.length === 0
) {
return { where: "1=1", params: [] };
}
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
for (const condition of conditionGroup.conditions) {
const column = this.sanitizeColumnName(condition.column);
switch (condition.operator) {
case "equals":
case "=":
conditions.push(`${column} = $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "not_equals":
case "!=":
conditions.push(`${column} != $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "in":
if (Array.isArray(condition.value) && condition.value.length > 0) {
const placeholders = condition.value
.map(() => `$${paramIndex++}`)
.join(", ");
conditions.push(`${column} IN (${placeholders})`);
params.push(...condition.value);
}
break;
case "not_in":
if (Array.isArray(condition.value) && condition.value.length > 0) {
const placeholders = condition.value
.map(() => `$${paramIndex++}`)
.join(", ");
conditions.push(`${column} NOT IN (${placeholders})`);
params.push(...condition.value);
}
break;
case "greater_than":
case ">":
conditions.push(`${column} > $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "less_than":
case "<":
conditions.push(`${column} < $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "greater_than_or_equal":
case ">=":
conditions.push(`${column} >= $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "less_than_or_equal":
case "<=":
conditions.push(`${column} <= $${paramIndex}`);
params.push(condition.value);
paramIndex++;
break;
case "is_null":
conditions.push(`${column} IS NULL`);
break;
case "is_not_null":
conditions.push(`${column} IS NOT NULL`);
break;
case "like":
conditions.push(`${column} LIKE $${paramIndex}`);
params.push(`%${condition.value}%`);
paramIndex++;
break;
case "not_like":
conditions.push(`${column} NOT LIKE $${paramIndex}`);
params.push(`%${condition.value}%`);
paramIndex++;
break;
default:
throw new Error(`Unsupported operator: ${condition.operator}`);
}
}
if (conditions.length === 0) {
return { where: "1=1", params: [] };
}
const joinOperator = conditionGroup.type === "OR" ? " OR " : " AND ";
const where = conditions.join(joinOperator);
return { where, params };
}
/**
* SQL
*/
private static sanitizeColumnName(columnName: string): string {
// 알파벳, 숫자, 언더스코어, 점(.)만 허용 (테이블명.컬럼명 형태 지원)
if (!/^[a-zA-Z0-9_.]+$/.test(columnName)) {
throw new Error(`Invalid column name: ${columnName}`);
}
return columnName;
}
/**
*
*/
static validateConditionGroup(conditionGroup: FlowConditionGroup): void {
if (!conditionGroup) {
throw new Error("Condition group is required");
}
if (!["AND", "OR"].includes(conditionGroup.type)) {
throw new Error("Condition group type must be AND or OR");
}
if (!Array.isArray(conditionGroup.conditions)) {
throw new Error("Conditions must be an array");
}
for (const condition of conditionGroup.conditions) {
this.validateCondition(condition);
}
}
/**
*
*/
private static validateCondition(condition: FlowCondition): void {
if (!condition.column) {
throw new Error("Column name is required");
}
const validOperators = [
"equals",
"=",
"not_equals",
"!=",
"in",
"not_in",
"greater_than",
">",
"less_than",
"<",
"greater_than_or_equal",
">=",
"less_than_or_equal",
"<=",
"is_null",
"is_not_null",
"like",
"not_like",
];
if (!validOperators.includes(condition.operator)) {
throw new Error(`Invalid operator: ${condition.operator}`);
}
// is_null, is_not_null은 value가 필요 없음
if (!["is_null", "is_not_null"].includes(condition.operator)) {
if (condition.value === undefined || condition.value === null) {
throw new Error(
`Value is required for operator: ${condition.operator}`
);
}
}
// in, not_in은 배열이어야 함
if (["in", "not_in"].includes(condition.operator)) {
if (!Array.isArray(condition.value) || condition.value.length === 0) {
throw new Error(
`Operator ${condition.operator} requires a non-empty array value`
);
}
}
}
}

View File

@ -1,166 +0,0 @@
/**
*
*/
import db from "../database/db";
import { FlowStepConnection, CreateFlowConnectionRequest } from "../types/flow";
export class FlowConnectionService {
/**
*
*/
async create(
request: CreateFlowConnectionRequest
): Promise<FlowStepConnection> {
// 순환 참조 체크
if (
await this.wouldCreateCycle(
request.flowDefinitionId,
request.fromStepId,
request.toStepId
)
) {
throw new Error(
"Creating this connection would create a cycle in the flow"
);
}
const query = `
INSERT INTO flow_step_connection (
flow_definition_id, from_step_id, to_step_id, label
)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const result = await db.query(query, [
request.flowDefinitionId,
request.fromStepId,
request.toStepId,
request.label || null,
]);
return this.mapToFlowConnection(result[0]);
}
/**
*
*/
async findByFlowId(flowDefinitionId: number): Promise<FlowStepConnection[]> {
const query = `
SELECT * FROM flow_step_connection
WHERE flow_definition_id = $1
ORDER BY id ASC
`;
const result = await db.query(query, [flowDefinitionId]);
return result.map(this.mapToFlowConnection);
}
/**
*
*/
async findById(id: number): Promise<FlowStepConnection | null> {
const query = "SELECT * FROM flow_step_connection WHERE id = $1";
const result = await db.query(query, [id]);
if (result.length === 0) {
return null;
}
return this.mapToFlowConnection(result[0]);
}
/**
*
*/
async delete(id: number): Promise<boolean> {
const query = "DELETE FROM flow_step_connection WHERE id = $1 RETURNING id";
const result = await db.query(query, [id]);
return result.length > 0;
}
/**
*
*/
async findOutgoingConnections(stepId: number): Promise<FlowStepConnection[]> {
const query = `
SELECT * FROM flow_step_connection
WHERE from_step_id = $1
ORDER BY id ASC
`;
const result = await db.query(query, [stepId]);
return result.map(this.mapToFlowConnection);
}
/**
*
*/
async findIncomingConnections(stepId: number): Promise<FlowStepConnection[]> {
const query = `
SELECT * FROM flow_step_connection
WHERE to_step_id = $1
ORDER BY id ASC
`;
const result = await db.query(query, [stepId]);
return result.map(this.mapToFlowConnection);
}
/**
* (DFS)
*/
private async wouldCreateCycle(
flowDefinitionId: number,
fromStepId: number,
toStepId: number
): Promise<boolean> {
// toStepId에서 출발해서 fromStepId에 도달할 수 있는지 확인
const visited = new Set<number>();
const stack = [toStepId];
while (stack.length > 0) {
const current = stack.pop()!;
if (current === fromStepId) {
return true; // 순환 발견
}
if (visited.has(current)) {
continue;
}
visited.add(current);
// 현재 노드에서 나가는 모든 연결 조회
const query = `
SELECT to_step_id
FROM flow_step_connection
WHERE flow_definition_id = $1 AND from_step_id = $2
`;
const result = await db.query(query, [flowDefinitionId, current]);
for (const row of result) {
stack.push(row.to_step_id);
}
}
return false; // 순환 없음
}
/**
* DB FlowStepConnection
*/
private mapToFlowConnection(row: any): FlowStepConnection {
return {
id: row.id,
flowDefinitionId: row.flow_definition_id,
fromStepId: row.from_step_id,
toStepId: row.to_step_id,
label: row.label,
createdAt: row.created_at,
};
}
}

View File

@ -1,593 +0,0 @@
/**
* ( )
* - 방식: 같은
* - 방식: 다른
* - 방식:
*/
import db from "../database/db";
import { FlowAuditLog, FlowIntegrationContext } from "../types/flow";
import { FlowDefinitionService } from "./flowDefinitionService";
import { FlowStepService } from "./flowStepService";
import { FlowExternalDbIntegrationService } from "./flowExternalDbIntegrationService";
export class FlowDataMoveService {
private flowDefinitionService: FlowDefinitionService;
private flowStepService: FlowStepService;
private externalDbIntegrationService: FlowExternalDbIntegrationService;
constructor() {
this.flowDefinitionService = new FlowDefinitionService();
this.flowStepService = new FlowStepService();
this.externalDbIntegrationService = new FlowExternalDbIntegrationService();
}
/**
* ( )
*/
async moveDataToStep(
flowId: number,
fromStepId: number,
toStepId: number,
dataId: any,
userId: string = "system",
additionalData?: Record<string, any>
): Promise<{ success: boolean; targetDataId?: any; message?: string }> {
return await db.transaction(async (client) => {
try {
// 1. 단계 정보 조회
const fromStep = await this.flowStepService.findById(fromStepId);
const toStep = await this.flowStepService.findById(toStepId);
if (!fromStep || !toStep) {
throw new Error("유효하지 않은 단계입니다");
}
let targetDataId = dataId;
let sourceTable = fromStep.tableName;
let targetTable = toStep.tableName || fromStep.tableName;
// 2. 이동 방식에 따라 처리
switch (toStep.moveType || "status") {
case "status":
// 상태 변경 방식
await this.moveByStatusChange(
client,
fromStep,
toStep,
dataId,
additionalData
);
break;
case "table":
// 테이블 이동 방식
targetDataId = await this.moveByTableTransfer(
client,
fromStep,
toStep,
dataId,
additionalData
);
targetTable = toStep.targetTable || toStep.tableName;
break;
case "both":
// 하이브리드 방식: 둘 다 수행
await this.moveByStatusChange(
client,
fromStep,
toStep,
dataId,
additionalData
);
targetDataId = await this.moveByTableTransfer(
client,
fromStep,
toStep,
dataId,
additionalData
);
targetTable = toStep.targetTable || toStep.tableName;
break;
default:
throw new Error(`지원하지 않는 이동 방식: ${toStep.moveType}`);
}
// 3. 매핑 테이블 업데이트 (테이블 이동 방식일 때)
if (toStep.moveType === "table" || toStep.moveType === "both") {
await this.updateDataMapping(
client,
flowId,
toStepId,
fromStepId,
dataId,
targetDataId
);
}
// 4. 외부 DB 연동 실행 (설정된 경우)
if (
toStep.integrationType &&
toStep.integrationType !== "internal" &&
toStep.integrationConfig
) {
await this.executeExternalIntegration(
toStep,
flowId,
targetDataId,
sourceTable,
userId,
additionalData
);
}
// 5. 감사 로그 기록
await this.logDataMove(client, {
flowId,
fromStepId,
toStepId,
moveType: toStep.moveType || "status",
sourceTable,
targetTable,
sourceDataId: String(dataId),
targetDataId: String(targetDataId),
statusFrom: fromStep.statusValue,
statusTo: toStep.statusValue,
userId,
});
return {
success: true,
targetDataId,
message: "데이터가 성공적으로 이동되었습니다",
};
} catch (error: any) {
console.error("데이터 이동 실패:", error);
throw error;
}
});
}
/**
*
*/
private async moveByStatusChange(
client: any,
fromStep: any,
toStep: any,
dataId: any,
additionalData?: Record<string, any>
): Promise<void> {
const statusColumn = toStep.statusColumn || "flow_status";
const tableName = fromStep.tableName;
// 추가 필드 업데이트 준비
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
const values: any[] = [dataId, toStep.statusValue];
let paramIndex = 3;
// 추가 데이터가 있으면 함께 업데이트
if (additionalData) {
for (const [key, value] of Object.entries(additionalData)) {
updates.push(`${key} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
const updateQuery = `
UPDATE ${tableName}
SET ${updates.join(", ")}
WHERE id = $1
`;
const result = await client.query(updateQuery, values);
if (result.rowCount === 0) {
throw new Error(`데이터를 찾을 수 없습니다: ${dataId}`);
}
}
/**
*
*/
private async moveByTableTransfer(
client: any,
fromStep: any,
toStep: any,
dataId: any,
additionalData?: Record<string, any>
): Promise<any> {
const sourceTable = fromStep.tableName;
const targetTable = toStep.targetTable || toStep.tableName;
const fieldMappings = toStep.fieldMappings || {};
// 1. 소스 데이터 조회
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
const sourceResult = await client.query(selectQuery, [dataId]);
if (sourceResult.length === 0) {
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
}
const sourceData = sourceResult[0];
// 2. 필드 매핑 적용
const mappedData: Record<string, any> = {};
// 매핑 정의가 있으면 적용
for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
if (sourceData[sourceField] !== undefined) {
mappedData[targetField as string] = sourceData[sourceField];
}
}
// 추가 데이터 병합
if (additionalData) {
Object.assign(mappedData, additionalData);
}
// 3. 타겟 테이블에 데이터 삽입
if (Object.keys(mappedData).length === 0) {
throw new Error("매핑할 데이터가 없습니다");
}
const columns = Object.keys(mappedData);
const values = Object.values(mappedData);
const placeholders = columns.map((_, i) => `$${i + 1}`).join(", ");
const insertQuery = `
INSERT INTO ${targetTable} (${columns.join(", ")})
VALUES (${placeholders})
RETURNING id
`;
const insertResult = await client.query(insertQuery, values);
return insertResult[0].id;
}
/**
*
*/
private async updateDataMapping(
client: any,
flowId: number,
currentStepId: number,
prevStepId: number,
sourceDataId: any,
targetDataId: any
): Promise<void> {
// 기존 매핑 조회
const selectQuery = `
SELECT id, step_data_map
FROM flow_data_mapping
WHERE flow_definition_id = $1
AND step_data_map->$2 = $3
`;
const mappingResult = await client.query(selectQuery, [
flowId,
String(prevStepId),
JSON.stringify(String(sourceDataId)),
]);
const stepDataMap: Record<string, string> =
mappingResult.length > 0 ? mappingResult[0].step_data_map : {};
// 새 단계 데이터 추가
stepDataMap[String(currentStepId)] = String(targetDataId);
if (mappingResult.length > 0) {
// 기존 매핑 업데이트
const updateQuery = `
UPDATE flow_data_mapping
SET current_step_id = $1,
step_data_map = $2,
updated_at = NOW()
WHERE id = $3
`;
await client.query(updateQuery, [
currentStepId,
JSON.stringify(stepDataMap),
mappingResult[0].id,
]);
} else {
// 새 매핑 생성
const insertQuery = `
INSERT INTO flow_data_mapping
(flow_definition_id, current_step_id, step_data_map)
VALUES ($1, $2, $3)
`;
await client.query(insertQuery, [
flowId,
currentStepId,
JSON.stringify(stepDataMap),
]);
}
}
/**
*
*/
private async logDataMove(client: any, params: any): Promise<void> {
const query = `
INSERT INTO flow_audit_log (
flow_definition_id, from_step_id, to_step_id,
move_type, source_table, target_table,
source_data_id, target_data_id,
status_from, status_to,
changed_by, note
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
`;
await client.query(query, [
params.flowId,
params.fromStepId,
params.toStepId,
params.moveType,
params.sourceTable,
params.targetTable,
params.sourceDataId,
params.targetDataId,
params.statusFrom,
params.statusTo,
params.userId,
params.note || null,
]);
}
/**
*
*/
async moveBatchData(
flowId: number,
fromStepId: number,
toStepId: number,
dataIds: any[],
userId: string = "system"
): Promise<{ success: boolean; results: any[] }> {
const results = [];
for (const dataId of dataIds) {
try {
const result = await this.moveDataToStep(
flowId,
fromStepId,
toStepId,
dataId,
userId
);
results.push({ dataId, ...result });
} catch (error: any) {
results.push({ dataId, success: false, message: error.message });
}
}
return {
success: results.every((r) => r.success),
results,
};
}
/**
*
*/
async getAuditLogs(flowId: number, dataId: string): Promise<FlowAuditLog[]> {
const query = `
SELECT
fal.*,
fs_from.step_name as from_step_name,
fs_to.step_name as to_step_name
FROM flow_audit_log fal
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
WHERE fal.flow_definition_id = $1
AND (fal.source_data_id = $2 OR fal.target_data_id = $2)
ORDER BY fal.changed_at DESC
`;
const result = await db.query(query, [flowId, dataId]);
return result.map((row) => ({
id: row.id,
flowDefinitionId: row.flow_definition_id,
tableName: row.table_name || row.source_table,
recordId: row.record_id || row.source_data_id,
fromStepId: row.from_step_id,
toStepId: row.to_step_id,
changedBy: row.changed_by,
changedAt: row.changed_at,
note: row.note,
fromStepName: row.from_step_name,
toStepName: row.to_step_name,
moveType: row.move_type,
sourceTable: row.source_table,
targetTable: row.target_table,
sourceDataId: row.source_data_id,
targetDataId: row.target_data_id,
statusFrom: row.status_from,
statusTo: row.status_to,
}));
}
/**
*
*/
async getFlowAuditLogs(
flowId: number,
limit: number = 100
): Promise<FlowAuditLog[]> {
const query = `
SELECT
fal.*,
fs_from.step_name as from_step_name,
fs_to.step_name as to_step_name
FROM flow_audit_log fal
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
LEFT JOIN flow_step fs_to ON fal.to_step_id = fs_to.id
WHERE fal.flow_definition_id = $1
ORDER BY fal.changed_at DESC
LIMIT $2
`;
const result = await db.query(query, [flowId, limit]);
return result.map((row) => ({
id: row.id,
flowDefinitionId: row.flow_definition_id,
tableName: row.table_name || row.source_table,
recordId: row.record_id || row.source_data_id,
fromStepId: row.from_step_id,
toStepId: row.to_step_id,
changedBy: row.changed_by,
changedAt: row.changed_at,
note: row.note,
fromStepName: row.from_step_name,
toStepName: row.to_step_name,
moveType: row.move_type,
sourceTable: row.source_table,
targetTable: row.target_table,
sourceDataId: row.source_data_id,
targetDataId: row.target_data_id,
statusFrom: row.status_from,
statusTo: row.status_to,
}));
}
/**
* DB
*/
private async executeExternalIntegration(
toStep: any,
flowId: number,
dataId: any,
tableName: string | undefined,
userId: string,
additionalData?: Record<string, any>
): Promise<void> {
const startTime = Date.now();
try {
// 연동 컨텍스트 구성
const context: FlowIntegrationContext = {
flowId,
stepId: toStep.id,
dataId,
tableName,
currentUser: userId,
variables: {
...additionalData,
stepName: toStep.stepName,
stepId: toStep.id,
},
};
// 연동 타입별 처리
switch (toStep.integrationType) {
case "external_db":
const result = await this.externalDbIntegrationService.execute(
context,
toStep.integrationConfig
);
// 연동 로그 기록
await this.logIntegration(
flowId,
toStep.id,
dataId,
toStep.integrationType,
toStep.integrationConfig.connectionId,
toStep.integrationConfig,
result.data,
result.success ? "success" : "failed",
result.error?.message,
Date.now() - startTime,
userId
);
if (!result.success) {
throw new Error(
`외부 DB 연동 실패: ${result.error?.message || "알 수 없는 오류"}`
);
}
break;
case "rest_api":
// REST API 연동 (추후 구현)
console.warn("REST API 연동은 아직 구현되지 않았습니다");
break;
case "webhook":
// Webhook 연동 (추후 구현)
console.warn("Webhook 연동은 아직 구현되지 않았습니다");
break;
case "hybrid":
// 복합 연동 (추후 구현)
console.warn("복합 연동은 아직 구현되지 않았습니다");
break;
default:
throw new Error(`지원하지 않는 연동 타입: ${toStep.integrationType}`);
}
} catch (error: any) {
console.error("외부 연동 실행 실패:", error);
// 연동 실패 로그 기록
await this.logIntegration(
flowId,
toStep.id,
dataId,
toStep.integrationType,
toStep.integrationConfig?.connectionId,
toStep.integrationConfig,
null,
"failed",
error.message,
Date.now() - startTime,
userId
);
throw error;
}
}
/**
*
*/
private async logIntegration(
flowId: number,
stepId: number,
dataId: any,
integrationType: string,
connectionId: number | undefined,
requestPayload: any,
responsePayload: any,
status: "success" | "failed" | "timeout" | "rollback",
errorMessage: string | undefined,
executionTimeMs: number,
userId: string
): Promise<void> {
const query = `
INSERT INTO flow_integration_log (
flow_definition_id, step_id, data_id, integration_type, connection_id,
request_payload, response_payload, status, error_message,
execution_time_ms, executed_by, executed_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW())
`;
await db.query(query, [
flowId,
stepId,
String(dataId),
integrationType,
connectionId || null,
requestPayload ? JSON.stringify(requestPayload) : null,
responsePayload ? JSON.stringify(responsePayload) : null,
status,
errorMessage || null,
executionTimeMs,
userId,
]);
}
}

View File

@ -1,171 +0,0 @@
/**
*
*/
import db from "../database/db";
import {
FlowDefinition,
CreateFlowDefinitionRequest,
UpdateFlowDefinitionRequest,
} from "../types/flow";
export class FlowDefinitionService {
/**
*
*/
async create(
request: CreateFlowDefinitionRequest,
userId: string
): Promise<FlowDefinition> {
const query = `
INSERT INTO flow_definition (name, description, table_name, created_by)
VALUES ($1, $2, $3, $4)
RETURNING *
`;
const result = await db.query(query, [
request.name,
request.description || null,
request.tableName,
userId,
]);
return this.mapToFlowDefinition(result[0]);
}
/**
*
*/
async findAll(
tableName?: string,
isActive?: boolean
): Promise<FlowDefinition[]> {
let query = "SELECT * FROM flow_definition WHERE 1=1";
const params: any[] = [];
let paramIndex = 1;
if (tableName) {
query += ` AND table_name = $${paramIndex}`;
params.push(tableName);
paramIndex++;
}
if (isActive !== undefined) {
query += ` AND is_active = $${paramIndex}`;
params.push(isActive);
paramIndex++;
}
query += " ORDER BY created_at DESC";
const result = await db.query(query, params);
return result.map(this.mapToFlowDefinition);
}
/**
*
*/
async findById(id: number): Promise<FlowDefinition | null> {
const query = "SELECT * FROM flow_definition WHERE id = $1";
const result = await db.query(query, [id]);
if (result.length === 0) {
return null;
}
return this.mapToFlowDefinition(result[0]);
}
/**
*
*/
async update(
id: number,
request: UpdateFlowDefinitionRequest
): Promise<FlowDefinition | null> {
const fields: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (request.name !== undefined) {
fields.push(`name = $${paramIndex}`);
params.push(request.name);
paramIndex++;
}
if (request.description !== undefined) {
fields.push(`description = $${paramIndex}`);
params.push(request.description);
paramIndex++;
}
if (request.isActive !== undefined) {
fields.push(`is_active = $${paramIndex}`);
params.push(request.isActive);
paramIndex++;
}
if (fields.length === 0) {
return this.findById(id);
}
fields.push(`updated_at = NOW()`);
const query = `
UPDATE flow_definition
SET ${fields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await db.query(query, params);
if (result.length === 0) {
return null;
}
return this.mapToFlowDefinition(result[0]);
}
/**
*
*/
async delete(id: number): Promise<boolean> {
const query = "DELETE FROM flow_definition WHERE id = $1 RETURNING id";
const result = await db.query(query, [id]);
return result.length > 0;
}
/**
*
*/
async checkTableExists(tableName: string): Promise<boolean> {
const query = `
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
) as exists
`;
const result = await db.query(query, [tableName]);
return result[0].exists;
}
/**
* DB FlowDefinition
*/
private mapToFlowDefinition(row: any): FlowDefinition {
return {
id: row.id,
name: row.name,
description: row.description,
tableName: row.table_name,
isActive: row.is_active,
createdBy: row.created_by,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View File

@ -1,176 +0,0 @@
/**
*
*
*/
import db from "../database/db";
import { FlowStepDataCount, FlowStepDataList } from "../types/flow";
import { FlowDefinitionService } from "./flowDefinitionService";
import { FlowStepService } from "./flowStepService";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowExecutionService {
private flowDefinitionService: FlowDefinitionService;
private flowStepService: FlowStepService;
constructor() {
this.flowDefinitionService = new FlowDefinitionService();
this.flowStepService = new FlowStepService();
}
/**
*
*/
async getStepDataCount(flowId: number, stepId: number): Promise<number> {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 플로우 단계 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
if (step.flowDefinitionId !== flowId) {
throw new Error(`Step ${stepId} does not belong to flow ${flowId}`);
}
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName;
// 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere(
step.conditionJson
);
// 5. 카운트 쿼리 실행
const query = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
const result = await db.query(query, params);
return parseInt(result[0].count);
}
/**
*
*/
async getStepDataList(
flowId: number,
stepId: number,
page: number = 1,
pageSize: number = 20
): Promise<FlowStepDataList> {
// 1. 플로우 정의 조회
const flowDef = await this.flowDefinitionService.findById(flowId);
if (!flowDef) {
throw new Error(`Flow definition not found: ${flowId}`);
}
// 2. 플로우 단계 조회
const step = await this.flowStepService.findById(stepId);
if (!step) {
throw new Error(`Flow step not found: ${stepId}`);
}
if (step.flowDefinitionId !== flowId) {
throw new Error(`Step ${stepId} does not belong to flow ${flowId}`);
}
// 3. 테이블명 결정: 단계에 지정된 테이블이 있으면 사용, 없으면 플로우의 기본 테이블 사용
const tableName = step.tableName || flowDef.tableName;
// 4. 조건 JSON을 SQL WHERE절로 변환
const { where, params } = FlowConditionParser.toSqlWhere(
step.conditionJson
);
const offset = (page - 1) * pageSize;
// 5. 전체 카운트
const countQuery = `SELECT COUNT(*) as count FROM ${tableName} WHERE ${where}`;
const countResult = await db.query(countQuery, params);
const total = parseInt(countResult[0].count);
// 6. 테이블의 Primary Key 컬럼 찾기
let orderByColumn = "";
try {
const pkQuery = `
SELECT a.attname
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = $1::regclass
AND i.indisprimary
LIMIT 1
`;
const pkResult = await db.query(pkQuery, [tableName]);
if (pkResult.length > 0) {
orderByColumn = pkResult[0].attname;
}
} catch (err) {
// Primary Key를 찾지 못하면 ORDER BY 없이 진행
console.warn(`Could not find primary key for table ${tableName}:`, err);
}
// 7. 데이터 조회
const orderByClause = orderByColumn ? `ORDER BY ${orderByColumn} DESC` : "";
const dataQuery = `
SELECT * FROM ${tableName}
WHERE ${where}
${orderByClause}
LIMIT $${params.length + 1} OFFSET $${params.length + 2}
`;
const dataResult = await db.query(dataQuery, [...params, pageSize, offset]);
return {
records: dataResult,
total,
page,
pageSize,
};
}
/**
*
*/
async getAllStepCounts(flowId: number): Promise<FlowStepDataCount[]> {
const steps = await this.flowStepService.findByFlowId(flowId);
const counts: FlowStepDataCount[] = [];
for (const step of steps) {
const count = await this.getStepDataCount(flowId, step.id);
counts.push({
stepId: step.id,
count,
});
}
return counts;
}
/**
*
*/
async getCurrentStatus(
flowId: number,
recordId: string
): Promise<{ currentStepId: number | null; tableName: string } | null> {
const query = `
SELECT current_step_id, table_name
FROM flow_data_status
WHERE flow_definition_id = $1 AND record_id = $2
`;
const result = await db.query(query, [flowId, recordId]);
if (result.length === 0) {
return null;
}
return {
currentStepId: result[0].current_step_id,
tableName: result[0].table_name,
};
}
}

View File

@ -1,436 +0,0 @@
import db from "../database/db";
import {
FlowExternalDbConnection,
CreateFlowExternalDbConnectionRequest,
UpdateFlowExternalDbConnectionRequest,
} from "../types/flow";
import { CredentialEncryption } from "../utils/credentialEncryption";
import { Pool } from "pg";
// import mysql from 'mysql2/promise'; // MySQL용 (추후)
// import { ConnectionPool } from 'mssql'; // MSSQL용 (추후)
/**
* DB
* ( DB )
*/
export class FlowExternalDbConnectionService {
private encryption: CredentialEncryption;
private connectionPools: Map<number, Pool> = new Map();
constructor() {
// 환경 변수에서 SECRET_KEY를 가져오거나 기본값 설정
const secretKey =
process.env.SECRET_KEY || "flow-external-db-secret-key-2025";
this.encryption = new CredentialEncryption(secretKey);
}
/**
* DB
*/
async create(
request: CreateFlowExternalDbConnectionRequest,
userId: string = "system"
): Promise<FlowExternalDbConnection> {
// 비밀번호 암호화
const encryptedPassword = this.encryption.encrypt(request.password);
const query = `
INSERT INTO flow_external_db_connection (
name, description, db_type, host, port, database_name, username,
password_encrypted, ssl_enabled, connection_options, created_by, updated_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *
`;
const result = await db.query(query, [
request.name,
request.description || null,
request.dbType,
request.host,
request.port,
request.databaseName,
request.username,
encryptedPassword,
request.sslEnabled || false,
request.connectionOptions
? JSON.stringify(request.connectionOptions)
: null,
userId,
userId,
]);
return this.mapToFlowExternalDbConnection(result[0]);
}
/**
* ID로 DB
*/
async findById(id: number): Promise<FlowExternalDbConnection | null> {
const query = "SELECT * FROM flow_external_db_connection WHERE id = $1";
const result = await db.query(query, [id]);
if (result.length === 0) {
return null;
}
return this.mapToFlowExternalDbConnection(result[0]);
}
/**
* DB
*/
async findAll(
activeOnly: boolean = false
): Promise<FlowExternalDbConnection[]> {
let query = "SELECT * FROM flow_external_db_connection";
if (activeOnly) {
query += " WHERE is_active = true";
}
query += " ORDER BY name ASC";
const result = await db.query(query);
return result.map((row) => this.mapToFlowExternalDbConnection(row));
}
/**
* DB
*/
async update(
id: number,
request: UpdateFlowExternalDbConnectionRequest,
userId: string = "system"
): Promise<FlowExternalDbConnection | null> {
const fields: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (request.name !== undefined) {
fields.push(`name = $${paramIndex}`);
params.push(request.name);
paramIndex++;
}
if (request.description !== undefined) {
fields.push(`description = $${paramIndex}`);
params.push(request.description);
paramIndex++;
}
if (request.host !== undefined) {
fields.push(`host = $${paramIndex}`);
params.push(request.host);
paramIndex++;
}
if (request.port !== undefined) {
fields.push(`port = $${paramIndex}`);
params.push(request.port);
paramIndex++;
}
if (request.databaseName !== undefined) {
fields.push(`database_name = $${paramIndex}`);
params.push(request.databaseName);
paramIndex++;
}
if (request.username !== undefined) {
fields.push(`username = $${paramIndex}`);
params.push(request.username);
paramIndex++;
}
if (request.password !== undefined) {
const encryptedPassword = this.encryption.encrypt(request.password);
fields.push(`password_encrypted = $${paramIndex}`);
params.push(encryptedPassword);
paramIndex++;
}
if (request.sslEnabled !== undefined) {
fields.push(`ssl_enabled = $${paramIndex}`);
params.push(request.sslEnabled);
paramIndex++;
}
if (request.connectionOptions !== undefined) {
fields.push(`connection_options = $${paramIndex}`);
params.push(
request.connectionOptions
? JSON.stringify(request.connectionOptions)
: null
);
paramIndex++;
}
if (request.isActive !== undefined) {
fields.push(`is_active = $${paramIndex}`);
params.push(request.isActive);
paramIndex++;
}
if (fields.length === 0) {
return this.findById(id);
}
fields.push(`updated_by = $${paramIndex}`);
params.push(userId);
paramIndex++;
fields.push(`updated_at = NOW()`);
const query = `
UPDATE flow_external_db_connection
SET ${fields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await db.query(query, params);
if (result.length === 0) {
return null;
}
// 연결 풀 갱신 (비밀번호 변경 시)
if (request.password !== undefined || request.host !== undefined) {
this.closeConnection(id);
}
return this.mapToFlowExternalDbConnection(result[0]);
}
/**
* DB
*/
async delete(id: number): Promise<boolean> {
// 연결 풀 정리
this.closeConnection(id);
const query =
"DELETE FROM flow_external_db_connection WHERE id = $1 RETURNING id";
const result = await db.query(query, [id]);
return result.length > 0;
}
/**
*
*/
async testConnection(
id: number
): Promise<{ success: boolean; message: string }> {
try {
const connection = await this.findById(id);
if (!connection) {
return { success: false, message: "연결 정보를 찾을 수 없습니다." };
}
const pool = await this.getConnectionPool(connection);
// 간단한 쿼리로 연결 테스트
const client = await pool.connect();
try {
await client.query("SELECT 1");
return { success: true, message: "연결 성공" };
} finally {
client.release();
}
} catch (error: any) {
return { success: false, message: error.message };
}
}
/**
* DB의
*/
async getTables(
id: number
): Promise<{ success: boolean; data?: string[]; message?: string }> {
try {
const connection = await this.findById(id);
if (!connection) {
return { success: false, message: "연결 정보를 찾을 수 없습니다." };
}
const pool = await this.getConnectionPool(connection);
const client = await pool.connect();
try {
let query: string;
switch (connection.dbType) {
case "postgresql":
query =
"SELECT tablename FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename";
break;
case "mysql":
query = `SELECT table_name as tablename FROM information_schema.tables WHERE table_schema = '${connection.databaseName}' ORDER BY table_name`;
break;
default:
return {
success: false,
message: `지원하지 않는 DB 타입: ${connection.dbType}`,
};
}
const result = await client.query(query);
const tables = result.rows.map((row: any) => row.tablename);
return { success: true, data: tables };
} finally {
client.release();
}
} catch (error: any) {
return { success: false, message: error.message };
}
}
/**
* DB의
*/
async getTableColumns(
id: number,
tableName: string
): Promise<{
success: boolean;
data?: { column_name: string; data_type: string }[];
message?: string;
}> {
try {
const connection = await this.findById(id);
if (!connection) {
return { success: false, message: "연결 정보를 찾을 수 없습니다." };
}
const pool = await this.getConnectionPool(connection);
const client = await pool.connect();
try {
let query: string;
switch (connection.dbType) {
case "postgresql":
query = `SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = $1
ORDER BY ordinal_position`;
break;
case "mysql":
query = `SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = '${connection.databaseName}' AND table_name = ?
ORDER BY ordinal_position`;
break;
default:
return {
success: false,
message: `지원하지 않는 DB 타입: ${connection.dbType}`,
};
}
const result = await client.query(query, [tableName]);
return { success: true, data: result.rows };
} finally {
client.release();
}
} catch (error: any) {
return { success: false, message: error.message };
}
}
/**
* ()
*/
async getConnectionPool(connection: FlowExternalDbConnection): Promise<Pool> {
if (this.connectionPools.has(connection.id)) {
return this.connectionPools.get(connection.id)!;
}
// 비밀번호 복호화
const decryptedPassword = this.encryption.decrypt(
connection.passwordEncrypted
);
let pool: Pool;
switch (connection.dbType) {
case "postgresql":
pool = new Pool({
host: connection.host,
port: connection.port,
database: connection.databaseName,
user: connection.username,
password: decryptedPassword,
ssl: connection.sslEnabled,
// 연결 풀 설정 (고갈 방지)
max: 10, // 최대 연결 수
min: 2, // 최소 연결 수
idleTimeoutMillis: 30000, // 30초 유휴 시간 후 연결 해제
connectionTimeoutMillis: 10000, // 10초 연결 타임아웃
...(connection.connectionOptions || {}),
});
// 에러 핸들러 등록
pool.on("error", (err) => {
console.error(`외부 DB 연결 풀 오류 (ID: ${connection.id}):`, err);
});
break;
// case "mysql":
// pool = mysql.createPool({ ... });
// break;
// case "mssql":
// pool = new ConnectionPool({ ... });
// break;
default:
throw new Error(`지원하지 않는 DB 타입: ${connection.dbType}`);
}
this.connectionPools.set(connection.id, pool);
return pool;
}
/**
*
*/
closeConnection(id: number): void {
const pool = this.connectionPools.get(id);
if (pool) {
pool.end();
this.connectionPools.delete(id);
}
}
/**
*
*/
closeAllConnections(): void {
for (const [id, pool] of this.connectionPools.entries()) {
pool.end();
}
this.connectionPools.clear();
}
/**
* DB row를 FlowExternalDbConnection으로
*/
private mapToFlowExternalDbConnection(row: any): FlowExternalDbConnection {
return {
id: row.id,
name: row.name,
description: row.description || undefined,
dbType: row.db_type,
host: row.host,
port: row.port,
databaseName: row.database_name,
username: row.username,
passwordEncrypted: row.password_encrypted,
sslEnabled: row.ssl_enabled,
connectionOptions: row.connection_options || undefined,
isActive: row.is_active,
createdBy: row.created_by || undefined,
updatedBy: row.updated_by || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View File

@ -1,353 +0,0 @@
import {
FlowExternalDbIntegrationConfig,
FlowIntegrationContext,
FlowIntegrationResult,
} from "../types/flow";
import { FlowExternalDbConnectionService } from "./flowExternalDbConnectionService";
import { Pool } from "pg";
/**
* DB
* (INSERT, UPDATE, DELETE, CUSTOM QUERY)
*/
export class FlowExternalDbIntegrationService {
private connectionService: FlowExternalDbConnectionService;
constructor() {
this.connectionService = new FlowExternalDbConnectionService();
}
/**
* DB
*/
async execute(
context: FlowIntegrationContext,
config: FlowExternalDbIntegrationConfig
): Promise<FlowIntegrationResult> {
const startTime = Date.now();
try {
// 1. 연결 정보 조회
const connection = await this.connectionService.findById(
config.connectionId
);
if (!connection) {
return {
success: false,
error: {
code: "CONNECTION_NOT_FOUND",
message: `외부 DB 연결 정보를 찾을 수 없습니다 (ID: ${config.connectionId})`,
},
};
}
if (!connection.isActive) {
return {
success: false,
error: {
code: "CONNECTION_INACTIVE",
message: `외부 DB 연결이 비활성화 상태입니다 (${connection.name})`,
},
};
}
// 2. 쿼리 생성 (템플릿 변수 치환)
const query = this.buildQuery(config, context);
// 3. 실행
const pool = await this.connectionService.getConnectionPool(connection);
const result = await this.executeQuery(pool, query);
const executionTime = Date.now() - startTime;
return {
success: true,
message: `외부 DB 작업 성공 (${config.operation}, ${executionTime}ms)`,
data: result,
rollbackInfo: {
query: this.buildRollbackQuery(config, context, result),
connectionId: config.connectionId,
},
};
} catch (error: any) {
const executionTime = Date.now() - startTime;
return {
success: false,
error: {
code: "EXTERNAL_DB_ERROR",
message: error.message || "외부 DB 작업 실패",
details: {
operation: config.operation,
tableName: config.tableName,
executionTime,
originalError: error,
},
},
};
}
}
/**
*
*/
private async executeQuery(
pool: Pool,
query: { sql: string; params: any[] }
): Promise<any> {
const client = await pool.connect();
try {
const result = await client.query(query.sql, query.params);
return result.rows;
} finally {
client.release();
}
}
/**
* (릿 )
*/
private buildQuery(
config: FlowExternalDbIntegrationConfig,
context: FlowIntegrationContext
): { sql: string; params: any[] } {
let sql = "";
const params: any[] = [];
let paramIndex = 1;
switch (config.operation) {
case "update":
return this.buildUpdateQuery(config, context, paramIndex);
case "insert":
return this.buildInsertQuery(config, context, paramIndex);
case "delete":
return this.buildDeleteQuery(config, context, paramIndex);
case "custom":
return this.buildCustomQuery(config, context);
default:
throw new Error(`지원하지 않는 작업: ${config.operation}`);
}
}
/**
* UPDATE
*/
private buildUpdateQuery(
config: FlowExternalDbIntegrationConfig,
context: FlowIntegrationContext,
startIndex: number
): { sql: string; params: any[] } {
if (!config.updateFields || Object.keys(config.updateFields).length === 0) {
throw new Error("UPDATE 작업에는 updateFields가 필요합니다");
}
if (
!config.whereCondition ||
Object.keys(config.whereCondition).length === 0
) {
throw new Error("UPDATE 작업에는 whereCondition이 필요합니다");
}
const setClauses: string[] = [];
const params: any[] = [];
let paramIndex = startIndex;
// SET 절 생성
for (const [key, value] of Object.entries(config.updateFields)) {
setClauses.push(`${key} = $${paramIndex}`);
params.push(this.replaceVariables(value, context));
paramIndex++;
}
// WHERE 절 생성
const whereClauses: string[] = [];
for (const [key, value] of Object.entries(config.whereCondition)) {
whereClauses.push(`${key} = $${paramIndex}`);
params.push(this.replaceVariables(value, context));
paramIndex++;
}
const sql = `UPDATE ${config.tableName} SET ${setClauses.join(", ")} WHERE ${whereClauses.join(" AND ")}`;
return { sql, params };
}
/**
* INSERT
*/
private buildInsertQuery(
config: FlowExternalDbIntegrationConfig,
context: FlowIntegrationContext,
startIndex: number
): { sql: string; params: any[] } {
if (!config.updateFields || Object.keys(config.updateFields).length === 0) {
throw new Error("INSERT 작업에는 updateFields가 필요합니다");
}
const columns: string[] = [];
const placeholders: string[] = [];
const params: any[] = [];
let paramIndex = startIndex;
for (const [key, value] of Object.entries(config.updateFields)) {
columns.push(key);
placeholders.push(`$${paramIndex}`);
params.push(this.replaceVariables(value, context));
paramIndex++;
}
const sql = `INSERT INTO ${config.tableName} (${columns.join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`;
return { sql, params };
}
/**
* DELETE
*/
private buildDeleteQuery(
config: FlowExternalDbIntegrationConfig,
context: FlowIntegrationContext,
startIndex: number
): { sql: string; params: any[] } {
if (
!config.whereCondition ||
Object.keys(config.whereCondition).length === 0
) {
throw new Error("DELETE 작업에는 whereCondition이 필요합니다");
}
const whereClauses: string[] = [];
const params: any[] = [];
let paramIndex = startIndex;
for (const [key, value] of Object.entries(config.whereCondition)) {
whereClauses.push(`${key} = $${paramIndex}`);
params.push(this.replaceVariables(value, context));
paramIndex++;
}
const sql = `DELETE FROM ${config.tableName} WHERE ${whereClauses.join(" AND ")}`;
return { sql, params };
}
/**
* CUSTOM
*/
private buildCustomQuery(
config: FlowExternalDbIntegrationConfig,
context: FlowIntegrationContext
): { sql: string; params: any[] } {
if (!config.customQuery) {
throw new Error("CUSTOM 작업에는 customQuery가 필요합니다");
}
// 템플릿 변수 치환
const sql = this.replaceVariables(config.customQuery, context);
// 커스텀 쿼리는 파라미터를 직접 관리
// 보안을 위해 가능하면 파라미터 바인딩 사용 권장
return { sql, params: [] };
}
/**
* 릿
*/
private replaceVariables(value: any, context: FlowIntegrationContext): any {
if (typeof value !== "string") {
return value;
}
let result = value;
// {{dataId}} 치환
result = result.replace(/\{\{dataId\}\}/g, String(context.dataId));
// {{currentUser}} 치환
result = result.replace(/\{\{currentUser\}\}/g, context.currentUser);
// {{currentTimestamp}} 치환
result = result.replace(
/\{\{currentTimestamp\}\}/g,
new Date().toISOString()
);
// {{flowId}} 치환
result = result.replace(/\{\{flowId\}\}/g, String(context.flowId));
// {{stepId}} 치환
result = result.replace(/\{\{stepId\}\}/g, String(context.stepId));
// {{tableName}} 치환
if (context.tableName) {
result = result.replace(/\{\{tableName\}\}/g, context.tableName);
}
// context.variables의 커스텀 변수 치환
for (const [key, val] of Object.entries(context.variables)) {
const regex = new RegExp(`\\{\\{${key}\\}\\}`, "g");
result = result.replace(regex, String(val));
}
// NOW() 같은 SQL 함수는 그대로 반환
if (result === "NOW()" || result.startsWith("CURRENT_")) {
return result;
}
return result;
}
/**
*
*/
private buildRollbackQuery(
config: FlowExternalDbIntegrationConfig,
context: FlowIntegrationContext,
result: any
): { sql: string; params: any[] } | null {
// 롤백 쿼리 생성 로직 (복잡하므로 실제 구현 시 상세 설계 필요)
// 예: INSERT -> DELETE, UPDATE -> 이전 값으로 UPDATE
switch (config.operation) {
case "insert":
// INSERT를 롤백하려면 삽입된 레코드를 DELETE
if (result && result[0] && result[0].id) {
return {
sql: `DELETE FROM ${config.tableName} WHERE id = $1`,
params: [result[0].id],
};
}
break;
case "delete":
// DELETE 롤백은 매우 어려움 (원본 데이터 필요)
console.warn("DELETE 작업의 롤백은 지원하지 않습니다");
break;
case "update":
// UPDATE 롤백을 위해서는 이전 값을 저장해야 함
console.warn("UPDATE 작업의 롤백은 현재 구현되지 않았습니다");
break;
default:
break;
}
return null;
}
/**
*
*/
async rollback(
connectionId: number,
rollbackQuery: { sql: string; params: any[] }
): Promise<void> {
const connection = await this.connectionService.findById(connectionId);
if (!connection) {
throw new Error(
`롤백 실패: 연결 정보를 찾을 수 없습니다 (ID: ${connectionId})`
);
}
const pool = await this.connectionService.getConnectionPool(connection);
await this.executeQuery(pool, rollbackQuery);
}
}

View File

@ -1,289 +0,0 @@
/**
*
*/
import db from "../database/db";
import {
FlowStep,
CreateFlowStepRequest,
UpdateFlowStepRequest,
FlowConditionGroup,
} from "../types/flow";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowStepService {
/**
*
*/
async create(request: CreateFlowStepRequest): Promise<FlowStep> {
// 조건 검증
if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson);
}
const query = `
INSERT INTO flow_step (
flow_definition_id, step_name, step_order, table_name, condition_json,
color, position_x, position_y, move_type, status_column, status_value,
target_table, field_mappings, required_fields,
integration_type, integration_config
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
RETURNING *
`;
const result = await db.query(query, [
request.flowDefinitionId,
request.stepName,
request.stepOrder,
request.tableName || null,
request.conditionJson ? JSON.stringify(request.conditionJson) : null,
request.color || "#3B82F6",
request.positionX || 0,
request.positionY || 0,
request.moveType || null,
request.statusColumn || null,
request.statusValue || null,
request.targetTable || null,
request.fieldMappings ? JSON.stringify(request.fieldMappings) : null,
request.requiredFields ? JSON.stringify(request.requiredFields) : null,
request.integrationType || "internal",
request.integrationConfig
? JSON.stringify(request.integrationConfig)
: null,
]);
return this.mapToFlowStep(result[0]);
}
/**
*
*/
async findByFlowId(flowDefinitionId: number): Promise<FlowStep[]> {
const query = `
SELECT * FROM flow_step
WHERE flow_definition_id = $1
ORDER BY step_order ASC
`;
const result = await db.query(query, [flowDefinitionId]);
return result.map(this.mapToFlowStep);
}
/**
*
*/
async findById(id: number): Promise<FlowStep | null> {
const query = "SELECT * FROM flow_step WHERE id = $1";
const result = await db.query(query, [id]);
if (result.length === 0) {
return null;
}
return this.mapToFlowStep(result[0]);
}
/**
*
*/
async update(
id: number,
request: UpdateFlowStepRequest
): Promise<FlowStep | null> {
console.log("🔧 FlowStepService.update called with:", {
id,
statusColumn: request.statusColumn,
statusValue: request.statusValue,
fullRequest: JSON.stringify(request),
});
// 조건 검증
if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson);
}
const fields: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (request.stepName !== undefined) {
fields.push(`step_name = $${paramIndex}`);
params.push(request.stepName);
paramIndex++;
}
if (request.stepOrder !== undefined) {
fields.push(`step_order = $${paramIndex}`);
params.push(request.stepOrder);
paramIndex++;
}
if (request.tableName !== undefined) {
fields.push(`table_name = $${paramIndex}`);
params.push(request.tableName);
paramIndex++;
}
if (request.conditionJson !== undefined) {
fields.push(`condition_json = $${paramIndex}`);
params.push(
request.conditionJson ? JSON.stringify(request.conditionJson) : null
);
paramIndex++;
}
if (request.color !== undefined) {
fields.push(`color = $${paramIndex}`);
params.push(request.color);
paramIndex++;
}
if (request.positionX !== undefined) {
fields.push(`position_x = $${paramIndex}`);
params.push(request.positionX);
paramIndex++;
}
if (request.positionY !== undefined) {
fields.push(`position_y = $${paramIndex}`);
params.push(request.positionY);
paramIndex++;
}
// 하이브리드 플로우 필드
if (request.moveType !== undefined) {
fields.push(`move_type = $${paramIndex}`);
params.push(request.moveType);
paramIndex++;
}
if (request.statusColumn !== undefined) {
fields.push(`status_column = $${paramIndex}`);
params.push(request.statusColumn);
paramIndex++;
}
if (request.statusValue !== undefined) {
fields.push(`status_value = $${paramIndex}`);
params.push(request.statusValue);
paramIndex++;
}
if (request.targetTable !== undefined) {
fields.push(`target_table = $${paramIndex}`);
params.push(request.targetTable);
paramIndex++;
}
if (request.fieldMappings !== undefined) {
fields.push(`field_mappings = $${paramIndex}`);
params.push(
request.fieldMappings ? JSON.stringify(request.fieldMappings) : null
);
paramIndex++;
}
if (request.requiredFields !== undefined) {
fields.push(`required_fields = $${paramIndex}`);
params.push(
request.requiredFields ? JSON.stringify(request.requiredFields) : null
);
paramIndex++;
}
// 외부 연동 필드
if (request.integrationType !== undefined) {
fields.push(`integration_type = $${paramIndex}`);
params.push(request.integrationType);
paramIndex++;
}
if (request.integrationConfig !== undefined) {
fields.push(`integration_config = $${paramIndex}`);
params.push(
request.integrationConfig
? JSON.stringify(request.integrationConfig)
: null
);
paramIndex++;
}
if (fields.length === 0) {
return this.findById(id);
}
fields.push(`updated_at = NOW()`);
const query = `
UPDATE flow_step
SET ${fields.join(", ")}
WHERE id = $${paramIndex}
RETURNING *
`;
params.push(id);
const result = await db.query(query, params);
if (result.length === 0) {
return null;
}
return this.mapToFlowStep(result[0]);
}
/**
*
*/
async delete(id: number): Promise<boolean> {
const query = "DELETE FROM flow_step WHERE id = $1 RETURNING id";
const result = await db.query(query, [id]);
return result.length > 0;
}
/**
*
*/
async reorder(
flowDefinitionId: number,
stepOrders: { id: number; order: number }[]
): Promise<void> {
await db.transaction(async (client) => {
for (const { id, order } of stepOrders) {
await client.query(
"UPDATE flow_step SET step_order = $1, updated_at = NOW() WHERE id = $2 AND flow_definition_id = $3",
[order, id, flowDefinitionId]
);
}
});
}
/**
* DB FlowStep
*/
private mapToFlowStep(row: any): FlowStep {
return {
id: row.id,
flowDefinitionId: row.flow_definition_id,
stepName: row.step_name,
stepOrder: row.step_order,
tableName: row.table_name || undefined,
conditionJson: row.condition_json as FlowConditionGroup | undefined,
color: row.color,
positionX: row.position_x,
positionY: row.position_y,
// 하이브리드 플로우 지원 필드
moveType: row.move_type || undefined,
statusColumn: row.status_column || undefined,
statusValue: row.status_value || undefined,
targetTable: row.target_table || undefined,
fieldMappings: row.field_mappings || undefined,
requiredFields: row.required_fields || undefined,
// 외부 연동 필드
integrationType: row.integration_type || "internal",
integrationConfig: row.integration_config || undefined,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
}

View File

@ -1,345 +0,0 @@
/**
*
*/
// 플로우 정의
export interface FlowDefinition {
id: number;
name: string;
description?: string;
tableName: string;
isActive: boolean;
createdBy?: string;
createdAt: Date;
updatedAt: Date;
}
// 플로우 정의 생성 요청
export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
}
// 플로우 정의 수정 요청
export interface UpdateFlowDefinitionRequest {
name?: string;
description?: string;
isActive?: boolean;
}
// 조건 연산자
export type ConditionOperator =
| "equals"
| "="
| "not_equals"
| "!="
| "in"
| "not_in"
| "greater_than"
| ">"
| "less_than"
| "<"
| "greater_than_or_equal"
| ">="
| "less_than_or_equal"
| "<="
| "is_null"
| "is_not_null"
| "like"
| "not_like";
// 플로우 조건
export interface FlowCondition {
column: string;
operator: ConditionOperator;
value: any;
}
// 플로우 조건 그룹
export interface FlowConditionGroup {
type: "AND" | "OR";
conditions: FlowCondition[];
}
// 플로우 단계
export interface FlowStep {
id: number;
flowDefinitionId: number;
stepName: string;
stepOrder: number;
tableName?: string; // 이 단계에서 조회할 테이블명 (NULL이면 flow_definition의 tableName 사용)
conditionJson?: FlowConditionGroup;
color: string;
positionX: number;
positionY: number;
// 하이브리드 플로우 지원 필드
moveType?: "status" | "table" | "both"; // 데이터 이동 방식
statusColumn?: string; // 상태 컬럼명 (상태 변경 방식)
statusValue?: string; // 이 단계의 상태값
targetTable?: string; // 타겟 테이블명 (테이블 이동 방식)
fieldMappings?: Record<string, string>; // 필드 매핑 정보
requiredFields?: string[]; // 필수 입력 필드
// 외부 연동 필드
integrationType?: FlowIntegrationType; // 연동 타입 (기본값: internal)
integrationConfig?: FlowIntegrationConfig; // 연동 설정 (JSONB)
createdAt: Date;
updatedAt: Date;
}
// 플로우 단계 생성 요청
export interface CreateFlowStepRequest {
flowDefinitionId: number;
stepName: string;
stepOrder: number;
tableName?: string; // 이 단계에서 조회할 테이블명
conditionJson?: FlowConditionGroup;
color?: string;
positionX?: number;
positionY?: number;
// 하이브리드 플로우 지원 필드
moveType?: "status" | "table" | "both";
statusColumn?: string;
statusValue?: string;
targetTable?: string;
fieldMappings?: Record<string, string>;
requiredFields?: string[];
// 외부 연동 필드
integrationType?: FlowIntegrationType;
integrationConfig?: FlowIntegrationConfig;
}
// 플로우 단계 수정 요청
export interface UpdateFlowStepRequest {
stepName?: string;
stepOrder?: number;
tableName?: string; // 이 단계에서 조회할 테이블명
conditionJson?: FlowConditionGroup;
color?: string;
positionX?: number;
positionY?: number;
// 하이브리드 플로우 지원 필드
moveType?: "status" | "table" | "both";
statusColumn?: string;
statusValue?: string;
targetTable?: string;
fieldMappings?: Record<string, string>;
requiredFields?: string[];
// 외부 연동 필드
integrationType?: FlowIntegrationType;
integrationConfig?: FlowIntegrationConfig;
}
// 플로우 단계 연결
export interface FlowStepConnection {
id: number;
flowDefinitionId: number;
fromStepId: number;
toStepId: number;
label?: string;
createdAt: Date;
}
// 플로우 단계 연결 생성 요청
export interface CreateFlowConnectionRequest {
flowDefinitionId: number;
fromStepId: number;
toStepId: number;
label?: string;
}
// 플로우 데이터 상태
export interface FlowDataStatus {
id: number;
flowDefinitionId: number;
tableName: string;
recordId: string;
currentStepId?: number;
updatedBy?: string;
updatedAt: Date;
}
// 플로우 오딧 로그
export interface FlowAuditLog {
id: number;
flowDefinitionId: number;
tableName: string;
recordId: string;
fromStepId?: number;
toStepId?: number;
changedBy?: string;
changedAt: Date;
note?: string;
// 하이브리드 플로우 지원 필드
moveType?: "status" | "table" | "both";
sourceTable?: string;
targetTable?: string;
sourceDataId?: string;
targetDataId?: string;
statusFrom?: string;
statusTo?: string;
// 조인 필드
fromStepName?: string;
toStepName?: string;
}
// 플로우 상세 정보
export interface FlowDetailResponse {
definition: FlowDefinition;
steps: FlowStep[];
connections: FlowStepConnection[];
}
// 단계별 데이터 카운트
export interface FlowStepDataCount {
stepId: number;
count: number;
}
// 단계별 데이터 리스트
export interface FlowStepDataList {
records: any[];
total: number;
page: number;
pageSize: number;
}
// 데이터 이동 요청
export interface MoveDataRequest {
flowId: number;
recordId: string;
toStepId: number;
note?: string;
}
// SQL WHERE 절 결과
export interface SqlWhereResult {
where: string;
params: any[];
}
// ==================== 플로우 외부 연동 타입 ====================
// 연동 타입
export type FlowIntegrationType =
| "internal" // 내부 DB (기본값)
| "external_db" // 외부 DB
| "rest_api" // REST API (추후 구현)
| "webhook" // Webhook (추후 구현)
| "hybrid"; // 복합 연동 (추후 구현)
// 플로우 전용 외부 DB 연결 정보
export interface FlowExternalDbConnection {
id: number;
name: string;
description?: string;
dbType: "postgresql" | "mysql" | "mssql" | "oracle";
host: string;
port: number;
databaseName: string;
username: string;
passwordEncrypted: string; // 암호화된 비밀번호
sslEnabled: boolean;
connectionOptions?: Record<string, any>;
isActive: boolean;
createdBy?: string;
updatedBy?: string;
createdAt: Date;
updatedAt: Date;
}
// 외부 DB 연결 생성 요청
export interface CreateFlowExternalDbConnectionRequest {
name: string;
description?: string;
dbType: "postgresql" | "mysql" | "mssql" | "oracle";
host: string;
port: number;
databaseName: string;
username: string;
password: string; // 평문 비밀번호 (저장 시 암호화)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
}
// 외부 DB 연결 수정 요청
export interface UpdateFlowExternalDbConnectionRequest {
name?: string;
description?: string;
host?: string;
port?: number;
databaseName?: string;
username?: string;
password?: string; // 평문 비밀번호 (저장 시 암호화)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
isActive?: boolean;
}
// 외부 DB 연동 설정 (integration_config JSON)
export interface FlowExternalDbIntegrationConfig {
type: "external_db";
connectionId: number; // flow_external_db_connection.id
operation: "update" | "insert" | "delete" | "custom";
tableName: string;
updateFields?: Record<string, any>; // 업데이트할 필드 (템플릿 변수 지원)
whereCondition?: Record<string, any>; // WHERE 조건 (템플릿 변수 지원)
customQuery?: string; // operation이 'custom'인 경우 사용
}
// 연동 설정 통합 타입
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig; // 나중에 다른 타입 추가
// 연동 실행 컨텍스트
export interface FlowIntegrationContext {
flowId: number;
stepId: number;
dataId: string | number;
tableName?: string;
currentUser: string;
variables: Record<string, any>; // 템플릿 변수 ({{dataId}}, {{currentUser}} 등)
transactionId?: string;
}
// 연동 실행 결과
export interface FlowIntegrationResult {
success: boolean;
message?: string;
data?: any;
error?: {
code: string;
message: string;
details?: any;
};
rollbackInfo?: any; // 롤백을 위한 정보
}
// 외부 연동 실행 로그
export interface FlowIntegrationLog {
id: number;
flowDefinitionId: number;
stepId: number;
dataId?: string;
integrationType: string;
connectionId?: number;
requestPayload?: Record<string, any>;
responsePayload?: Record<string, any>;
status: "success" | "failed" | "timeout" | "rollback";
errorMessage?: string;
executionTimeMs?: number;
executedBy?: string;
executedAt: Date;
}
// 외부 연결 권한
export interface FlowExternalConnectionPermission {
id: number;
connectionId: number;
userId?: number;
roleName?: string;
canView: boolean;
canUse: boolean;
canEdit: boolean;
canDelete: boolean;
createdAt: Date;
}

View File

@ -1,61 +0,0 @@
import crypto from "crypto";
/**
*
* AES-256-GCM
*/
export class CredentialEncryption {
private algorithm = "aes-256-gcm";
private key: Buffer;
constructor(secretKey: string) {
// scrypt로 안전한 키 생성
this.key = crypto.scryptSync(secretKey, "salt", 32);
}
/**
*
*/
encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(
this.algorithm,
this.key,
iv
) as crypto.CipherGCM;
let encrypted = cipher.update(text, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
// IV:AuthTag:EncryptedText 형식으로 반환
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
/**
*
*/
decrypt(encrypted: string): string {
const [ivHex, authTagHex, encryptedText] = encrypted.split(":");
if (!ivHex || !authTagHex || !encryptedText) {
throw new Error("Invalid encrypted string format");
}
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = crypto.createDecipheriv(
this.algorithm,
this.key,
iv
) as crypto.DecipherGCM;
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
}

View File

@ -1,302 +0,0 @@
# 플로우 데이터 구조 설계 가이드
## 개요
플로우 관리 시스템에서 각 단계별로 테이블 구조가 다른 경우의 데이터 관리 방법
## 추천 아키텍처: 하이브리드 접근
### 1. 메인 데이터 테이블 (상태 기반)
각 플로우의 핵심 데이터를 담는 메인 테이블에 `flow_status` 컬럼을 추가합니다.
```sql
-- 예시: 제품 수명주기 관리
CREATE TABLE product_lifecycle (
id SERIAL PRIMARY KEY,
product_code VARCHAR(50) UNIQUE NOT NULL,
product_name VARCHAR(200) NOT NULL,
flow_status VARCHAR(50) NOT NULL, -- 'purchase', 'installation', 'disposal'
-- 공통 필드
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by VARCHAR(50),
-- 단계별 핵심 정보 (NULL 허용)
purchase_date DATE,
purchase_price DECIMAL(15,2),
installation_date DATE,
installation_location VARCHAR(200),
disposal_date DATE,
disposal_method VARCHAR(100),
-- 인덱스
INDEX idx_flow_status (flow_status),
INDEX idx_product_code (product_code)
);
```
### 2. 단계별 상세 정보 테이블 (선택적)
각 단계에서 필요한 상세 정보는 별도 테이블에 저장합니다.
```sql
-- 구매 단계 상세 정보
CREATE TABLE product_purchase_detail (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES product_lifecycle(id),
vendor_name VARCHAR(200),
vendor_contact VARCHAR(100),
purchase_order_no VARCHAR(50),
warranty_period INTEGER, -- 월 단위
warranty_end_date DATE,
specifications JSONB, -- 유연한 사양 정보
created_at TIMESTAMP DEFAULT NOW()
);
-- 설치 단계 상세 정보
CREATE TABLE product_installation_detail (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES product_lifecycle(id),
technician_name VARCHAR(100),
installation_address TEXT,
installation_notes TEXT,
installation_photos JSONB, -- [{url, description}]
created_at TIMESTAMP DEFAULT NOW()
);
-- 폐기 단계 상세 정보
CREATE TABLE product_disposal_detail (
id SERIAL PRIMARY KEY,
product_id INTEGER REFERENCES product_lifecycle(id),
disposal_company VARCHAR(200),
disposal_certificate_no VARCHAR(100),
environmental_compliance BOOLEAN,
disposal_cost DECIMAL(15,2),
created_at TIMESTAMP DEFAULT NOW()
);
```
### 3. 플로우 단계 설정 테이블 수정
`flow_step` 테이블에 단계별 필드 매핑 정보를 추가합니다.
```sql
ALTER TABLE flow_step
ADD COLUMN status_value VARCHAR(50), -- 이 단계의 상태값
ADD COLUMN required_fields JSONB, -- 필수 입력 필드 목록
ADD COLUMN detail_table_name VARCHAR(200), -- 상세 정보 테이블명 (선택적)
ADD COLUMN field_mappings JSONB; -- 메인 테이블과 상세 테이블 필드 매핑
-- 예시 데이터
INSERT INTO flow_step (flow_definition_id, step_name, step_order, table_name, status_value, required_fields, detail_table_name) VALUES
(1, '구매', 1, 'product_lifecycle', 'purchase',
'["product_code", "product_name", "purchase_date", "purchase_price"]'::jsonb,
'product_purchase_detail'),
(1, '설치', 2, 'product_lifecycle', 'installation',
'["installation_date", "installation_location"]'::jsonb,
'product_installation_detail'),
(1, '폐기', 3, 'product_lifecycle', 'disposal',
'["disposal_date", "disposal_method"]'::jsonb,
'product_disposal_detail');
```
## 데이터 이동 로직
### 백엔드 서비스 수정
```typescript
// backend-node/src/services/flowDataMoveService.ts
export class FlowDataMoveService {
/**
* 다음 단계로 데이터 이동
*/
async moveToNextStep(
flowId: number,
currentStepId: number,
nextStepId: number,
dataId: any
): Promise<boolean> {
const client = await db.getClient();
try {
await client.query("BEGIN");
// 1. 현재 단계와 다음 단계 정보 조회
const currentStep = await this.getStepInfo(currentStepId);
const nextStep = await this.getStepInfo(nextStepId);
if (!currentStep || !nextStep) {
throw new Error("유효하지 않은 단계입니다");
}
// 2. 메인 테이블의 상태 업데이트
const updateQuery = `
UPDATE ${currentStep.table_name}
SET flow_status = $1,
updated_at = NOW()
WHERE id = $2
AND flow_status = $3
`;
const result = await client.query(updateQuery, [
nextStep.status_value,
dataId,
currentStep.status_value,
]);
if (result.rowCount === 0) {
throw new Error("데이터를 찾을 수 없거나 이미 이동되었습니다");
}
// 3. 감사 로그 기록
await this.logDataMove(client, {
flowId,
fromStepId: currentStepId,
toStepId: nextStepId,
dataId,
tableName: currentStep.table_name,
statusFrom: currentStep.status_value,
statusTo: nextStep.status_value,
});
await client.query("COMMIT");
return true;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
client.release();
}
}
private async getStepInfo(stepId: number) {
const query = `
SELECT id, table_name, status_value, detail_table_name, required_fields
FROM flow_step
WHERE id = $1
`;
const result = await db.query(query, [stepId]);
return result[0];
}
private async logDataMove(client: any, params: any) {
const query = `
INSERT INTO flow_audit_log (
flow_definition_id, from_step_id, to_step_id,
data_id, table_name, status_from, status_to,
moved_at, moved_by
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'system')
`;
await client.query(query, [
params.flowId,
params.fromStepId,
params.toStepId,
params.dataId,
params.tableName,
params.statusFrom,
params.statusTo,
]);
}
}
```
## 플로우 조건 설정
각 단계의 조건은 `flow_status` 컬럼을 기준으로 설정합니다:
```json
// 구매 단계 조건
{
"operator": "AND",
"conditions": [
{
"column": "flow_status",
"operator": "=",
"value": "purchase"
}
]
}
// 설치 단계 조건
{
"operator": "AND",
"conditions": [
{
"column": "flow_status",
"operator": "=",
"value": "installation"
}
]
}
```
## 프론트엔드 구현
### 단계별 폼 렌더링
각 단계에서 필요한 필드를 동적으로 렌더링합니다.
```typescript
// 단계 정보에서 필수 필드 가져오기
const requiredFields = step.required_fields; // ["purchase_date", "purchase_price"]
// 동적 폼 생성
{
requiredFields.map((fieldName) => (
<FormField
key={fieldName}
name={fieldName}
label={getFieldLabel(fieldName)}
type={getFieldType(fieldName)}
required={true}
/>
));
}
```
## 장점
1. **단순한 데이터 이동**: 상태값만 업데이트
2. **유연한 구조**: 단계별 상세 정보는 별도 테이블
3. **완벽한 이력 추적**: 감사 로그로 모든 이동 기록
4. **쿼리 효율**: 단일 테이블 조회로 각 단계 데이터 확인
5. **확장성**: 새로운 단계 추가 시 컬럼 추가 또는 상세 테이블 생성
## 마이그레이션 스크립트
```sql
-- 1. 기존 테이블에 flow_status 컬럼 추가
ALTER TABLE product_lifecycle
ADD COLUMN flow_status VARCHAR(50) DEFAULT 'purchase';
-- 2. 인덱스 생성
CREATE INDEX idx_product_lifecycle_status ON product_lifecycle(flow_status);
-- 3. flow_step 테이블 확장
ALTER TABLE flow_step
ADD COLUMN status_value VARCHAR(50),
ADD COLUMN required_fields JSONB,
ADD COLUMN detail_table_name VARCHAR(200);
-- 4. 기존 데이터 마이그레이션
UPDATE flow_step
SET status_value = CASE step_order
WHEN 1 THEN 'purchase'
WHEN 2 THEN 'installation'
WHEN 3 THEN 'disposal'
END
WHERE flow_definition_id = 1;
```
## 결론
이 하이브리드 접근 방식을 사용하면:
- 각 단계의 데이터는 같은 메인 테이블에서 `flow_status`로 구분
- 단계별 추가 정보는 별도 상세 테이블에 저장 (선택적)
- 데이터 이동은 상태값 업데이트만으로 간단하게 처리
- 완전한 감사 로그와 이력 추적 가능

View File

@ -1,762 +0,0 @@
# 플로우 관리 시스템 - 외부 연동 확장 계획
## 개요
현재 플로우 관리 시스템은 내부 데이터베이스의 상태 변경만 지원합니다.
실제 업무 환경에서는 다음과 같은 외부 연동이 필요합니다:
1. **외부 데이터베이스**: 다른 DB 서버의 데이터 상태 변경
2. **REST API 호출**: 외부 시스템 API를 통한 상태 업데이트
3. **Webhook**: 외부 시스템으로 이벤트 전송
4. **복합 연동**: 내부 DB + 외부 API 동시 처리
---
## 1. 데이터베이스 스키마 확장
### 1.1 플로우 단계 설정 확장
```sql
-- flow_step 테이블에 외부 연동 설정 추가
ALTER TABLE flow_step ADD COLUMN integration_type VARCHAR(50);
-- 값: 'internal' | 'external_db' | 'rest_api' | 'webhook' | 'hybrid'
ALTER TABLE flow_step ADD COLUMN integration_config JSONB;
-- 외부 연동 상세 설정 (JSON)
COMMENT ON COLUMN flow_step.integration_type IS '연동 타입: internal/external_db/rest_api/webhook/hybrid';
COMMENT ON COLUMN flow_step.integration_config IS '외부 연동 설정 (JSON 형식)';
```
### 1.2 외부 연결 정보 관리 테이블
```sql
-- 외부 데이터베이스 연결 정보
CREATE TABLE external_db_connection (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
db_type VARCHAR(50) NOT NULL, -- 'postgresql' | 'mysql' | 'mssql' | 'oracle'
host VARCHAR(255) NOT NULL,
port INTEGER NOT NULL,
database_name VARCHAR(100) NOT NULL,
username VARCHAR(100) NOT NULL,
password_encrypted TEXT NOT NULL, -- 암호화된 비밀번호
ssl_enabled BOOLEAN DEFAULT false,
connection_options JSONB, -- 추가 연결 옵션
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE external_db_connection IS '외부 데이터베이스 연결 정보';
-- 외부 API 연결 정보
CREATE TABLE external_api_connection (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
base_url VARCHAR(500) NOT NULL,
auth_type VARCHAR(50), -- 'none' | 'basic' | 'bearer' | 'api_key' | 'oauth2'
auth_config JSONB, -- 인증 설정 (암호화된 토큰/키 포함)
default_headers JSONB, -- 기본 헤더
timeout_ms INTEGER DEFAULT 30000,
retry_count INTEGER DEFAULT 3,
is_active BOOLEAN DEFAULT true,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
COMMENT ON TABLE external_api_connection IS '외부 REST API 연결 정보';
```
---
## 2. integration_config JSON 스키마
### 2.1 External DB 설정
```json
{
"type": "external_db",
"connectionId": 5, // external_db_connection.id
"operation": "update", // 'update' | 'insert' | 'delete' | 'custom'
"tableName": "external_orders",
"updateFields": {
"status": "approved",
"approved_at": "NOW()",
"approved_by": "{{currentUser}}"
},
"whereCondition": {
"id": "{{dataId}}",
"company_code": "{{companyCode}}"
},
"customQuery": null // operation이 'custom'인 경우 사용
}
```
### 2.2 REST API 설정
```json
{
"type": "rest_api",
"connectionId": 3, // external_api_connection.id
"method": "POST", // 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
"endpoint": "/api/orders/{{dataId}}/approve",
"headers": {
"Content-Type": "application/json",
"X-Request-ID": "{{generateUUID}}"
},
"body": {
"status": "approved",
"approvedBy": "{{currentUser}}",
"approvedAt": "{{currentTimestamp}}",
"notes": "{{notes}}"
},
"successCondition": {
"statusCode": [200, 201],
"responseField": "success",
"expectedValue": true
},
"errorHandling": {
"onFailure": "rollback", // 'rollback' | 'continue' | 'retry'
"maxRetries": 3,
"retryDelay": 1000
}
}
```
### 2.3 Webhook 설정
```json
{
"type": "webhook",
"url": "https://external-system.com/webhooks/flow-status-change",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer {{webhookToken}}"
},
"payload": {
"event": "flow.status.changed",
"flowId": "{{flowId}}",
"stepId": "{{stepId}}",
"dataId": "{{dataId}}",
"previousStatus": "{{previousStatus}}",
"currentStatus": "{{currentStatus}}",
"changedBy": "{{currentUser}}",
"changedAt": "{{currentTimestamp}}"
},
"async": true, // 비동기 처리 여부
"timeout": 5000
}
```
### 2.4 Hybrid (복합) 설정
```json
{
"type": "hybrid",
"steps": [
{
"order": 1,
"name": "internal_db_update",
"type": "internal",
"config": {
"tableName": "orders",
"statusColumn": "order_status",
"statusValue": "approved"
},
"onError": "rollback"
},
{
"order": 2,
"name": "notify_external_system",
"type": "rest_api",
"config": {
"connectionId": 3,
"method": "POST",
"endpoint": "/api/notifications/order-approved",
"body": {
"orderId": "{{dataId}}",
"status": "approved"
}
},
"onError": "log" // API 실패해도 계속 진행
},
{
"order": 3,
"name": "update_warehouse_system",
"type": "external_db",
"config": {
"connectionId": 5,
"operation": "update",
"tableName": "warehouse_orders",
"updateFields": {
"status": "ready_to_ship"
},
"whereCondition": {
"order_ref": "{{dataId}}"
}
},
"onError": "rollback"
}
],
"transactionMode": "sequential", // 'sequential' | 'parallel'
"rollbackStrategy": "all" // 'all' | 'completed_only' | 'none'
}
```
---
## 3. 백엔드 서비스 구조
### 3.1 서비스 계층 구조
```
flowDataMoveService (기존)
└── FlowIntegrationService (신규)
├── InternalDbIntegration
├── ExternalDbIntegration
├── RestApiIntegration
├── WebhookIntegration
└── HybridIntegration
```
### 3.2 주요 인터페이스
```typescript
// 통합 인터페이스
interface FlowIntegration {
execute(context: IntegrationContext): Promise<IntegrationResult>;
validate(config: any): ValidationResult;
rollback(context: IntegrationContext): Promise<void>;
}
// 실행 컨텍스트
interface IntegrationContext {
flowId: number;
stepId: number;
dataId: string | number;
tableName?: string;
currentUser: string;
variables: Record<string, any>; // 템플릿 변수
transactionId?: string;
}
// 실행 결과
interface IntegrationResult {
success: boolean;
message?: string;
data?: any;
error?: {
code: string;
message: string;
details?: any;
};
rollbackInfo?: any; // 롤백을 위한 정보
}
```
### 3.3 외부 DB 연동 서비스
```typescript
export class ExternalDbIntegration implements FlowIntegration {
private connectionPool: Map<number, any> = new Map();
async execute(context: IntegrationContext): Promise<IntegrationResult> {
const config = context.step.integrationConfig;
// 1. 연결 정보 조회
const connection = await this.getConnection(config.connectionId);
// 2. 쿼리 생성 (템플릿 변수 치환)
const query = this.buildQuery(config, context);
// 3. 실행
try {
const result = await this.executeQuery(connection, query);
return {
success: true,
data: result,
rollbackInfo: {
query: this.buildRollbackQuery(config, context),
connection: config.connectionId,
},
};
} catch (error) {
return {
success: false,
error: {
code: "EXTERNAL_DB_ERROR",
message: error.message,
details: error,
},
};
}
}
async getConnection(connectionId: number) {
// 연결 풀에서 가져오거나 새로 생성
if (this.connectionPool.has(connectionId)) {
return this.connectionPool.get(connectionId);
}
const connInfo = await this.loadConnectionInfo(connectionId);
const connection = await this.createConnection(connInfo);
this.connectionPool.set(connectionId, connection);
return connection;
}
private buildQuery(config: any, context: IntegrationContext): string {
// 템플릿 변수 치환
const replacedConfig = this.replaceVariables(config, context);
switch (config.operation) {
case "update":
return this.buildUpdateQuery(replacedConfig);
case "insert":
return this.buildInsertQuery(replacedConfig);
case "delete":
return this.buildDeleteQuery(replacedConfig);
case "custom":
return replacedConfig.customQuery;
default:
throw new Error(`Unsupported operation: ${config.operation}`);
}
}
async rollback(context: IntegrationContext): Promise<void> {
const rollbackInfo = context.rollbackInfo;
const connection = await this.getConnection(rollbackInfo.connection);
await this.executeQuery(connection, rollbackInfo.query);
}
}
```
### 3.4 REST API 연동 서비스
```typescript
export class RestApiIntegration implements FlowIntegration {
private axiosInstances: Map<number, AxiosInstance> = new Map();
async execute(context: IntegrationContext): Promise<IntegrationResult> {
const config = context.step.integrationConfig;
// 1. API 클라이언트 생성
const client = await this.getApiClient(config.connectionId);
// 2. 요청 구성 (템플릿 변수 치환)
const request = this.buildRequest(config, context);
// 3. API 호출
try {
const response = await this.executeRequest(client, request);
// 4. 성공 조건 검증
const isSuccess = this.validateSuccess(response, config.successCondition);
if (isSuccess) {
return {
success: true,
data: response.data,
rollbackInfo: {
compensatingRequest: this.buildCompensatingRequest(
config,
context,
response
),
},
};
} else {
throw new Error("API call succeeded but validation failed");
}
} catch (error) {
// 에러 처리 및 재시도
return this.handleError(error, config, context);
}
}
private async executeRequest(
client: AxiosInstance,
request: any
): Promise<AxiosResponse> {
const { method, endpoint, headers, body, timeout } = request;
return await client.request({
method,
url: endpoint,
headers,
data: body,
timeout: timeout || 30000,
});
}
private async handleError(
error: any,
config: any,
context: IntegrationContext
): Promise<IntegrationResult> {
const errorHandling = config.errorHandling;
if (errorHandling.onFailure === "retry") {
// 재시도 로직
for (let i = 0; i < errorHandling.maxRetries; i++) {
await this.delay(errorHandling.retryDelay);
try {
return await this.execute(context);
} catch (retryError) {
if (i === errorHandling.maxRetries - 1) {
throw retryError;
}
}
}
}
return {
success: false,
error: {
code: "REST_API_ERROR",
message: error.message,
details: error.response?.data,
},
};
}
async rollback(context: IntegrationContext): Promise<void> {
const rollbackInfo = context.rollbackInfo;
if (rollbackInfo.compensatingRequest) {
const client = await this.getApiClient(rollbackInfo.connectionId);
await this.executeRequest(client, rollbackInfo.compensatingRequest);
}
}
}
```
---
## 4. 프론트엔드 UI 확장
### 4.1 플로우 단계 설정 패널 확장
```typescript
// FlowStepPanel.tsx에 추가
// 연동 타입 선택
<Select value={integrationType} onValueChange={setIntegrationType}>
<SelectItem value="internal">내부 DB</SelectItem>
<SelectItem value="external_db">외부 DB</SelectItem>
<SelectItem value="rest_api">REST API</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
<SelectItem value="hybrid">복합 연동</SelectItem>
</Select>;
// 연동 타입별 설정 UI
{
integrationType === "external_db" && (
<ExternalDbConfigPanel
config={integrationConfig}
onChange={setIntegrationConfig}
/>
);
}
{
integrationType === "rest_api" && (
<RestApiConfigPanel
config={integrationConfig}
onChange={setIntegrationConfig}
/>
);
}
```
### 4.2 외부 DB 설정 패널
```typescript
export function ExternalDbConfigPanel({ config, onChange }) {
return (
<div className="space-y-4">
{/* 연결 선택 */}
<Select value={config.connectionId}>
<SelectLabel>외부 DB 연결</SelectLabel>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id}>
{conn.name} ({conn.dbType})
</SelectItem>
))}
</Select>
{/* 작업 타입 */}
<Select value={config.operation}>
<SelectItem value="update">업데이트</SelectItem>
<SelectItem value="insert">삽입</SelectItem>
<SelectItem value="delete">삭제</SelectItem>
<SelectItem value="custom">커스텀 쿼리</SelectItem>
</Select>
{/* 테이블명 */}
<Input
label="테이블명"
value={config.tableName}
onChange={(e) => onChange({ ...config, tableName: e.target.value })}
/>
{/* 업데이트 필드 */}
<FieldMapper
fields={config.updateFields}
onChange={(fields) => onChange({ ...config, updateFields: fields })}
/>
{/* WHERE 조건 */}
<ConditionBuilder
conditions={config.whereCondition}
onChange={(conditions) =>
onChange({ ...config, whereCondition: conditions })
}
/>
</div>
);
}
```
### 4.3 REST API 설정 패널
```typescript
export function RestApiConfigPanel({ config, onChange }) {
return (
<div className="space-y-4">
{/* API 연결 선택 */}
<Select value={config.connectionId}>
<SelectLabel>API 연결</SelectLabel>
{apiConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id}>
{conn.name} ({conn.baseUrl})
</SelectItem>
))}
</Select>
{/* HTTP 메서드 */}
<Select value={config.method}>
<SelectItem value="GET">GET</SelectItem>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="PUT">PUT</SelectItem>
<SelectItem value="PATCH">PATCH</SelectItem>
<SelectItem value="DELETE">DELETE</SelectItem>
</Select>
{/* 엔드포인트 */}
<Input
label="엔드포인트"
placeholder="/api/orders/{{dataId}}/approve"
value={config.endpoint}
onChange={(e) => onChange({ ...config, endpoint: e.target.value })}
/>
{/* 헤더 */}
<KeyValueEditor
label="헤더"
data={config.headers}
onChange={(headers) => onChange({ ...config, headers })}
/>
{/* 요청 본문 */}
<JsonEditor
label="요청 본문"
value={config.body}
onChange={(body) => onChange({ ...config, body })}
/>
{/* 성공 조건 */}
<SuccessConditionEditor
condition={config.successCondition}
onChange={(condition) =>
onChange({ ...config, successCondition: condition })
}
/>
</div>
);
}
```
---
## 5. 보안 고려사항
### 5.1 자격 증명 암호화
```typescript
// 비밀번호/토큰 암호화
import crypto from "crypto";
export class CredentialEncryption {
private algorithm = "aes-256-gcm";
private key: Buffer;
constructor(secretKey: string) {
this.key = crypto.scryptSync(secretKey, "salt", 32);
}
encrypt(text: string): string {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(text, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
}
decrypt(encrypted: string): string {
const [ivHex, authTagHex, encryptedText] = encrypted.split(":");
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
const decipher = crypto.createDecipheriv(this.algorithm, this.key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedText, "hex", "utf8");
decrypted += decipher.final("utf8");
return decrypted;
}
}
```
### 5.2 권한 관리
```sql
-- 외부 연결 접근 권한
CREATE TABLE external_connection_permission (
id SERIAL PRIMARY KEY,
connection_type VARCHAR(50) NOT NULL, -- 'db' | 'api'
connection_id INTEGER NOT NULL,
user_id INTEGER,
role_id INTEGER,
can_view BOOLEAN DEFAULT false,
can_use BOOLEAN DEFAULT false,
can_edit BOOLEAN DEFAULT false,
can_delete BOOLEAN DEFAULT false,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
```
---
## 6. 모니터링 및 로깅
### 6.1 외부 연동 로그
```sql
CREATE TABLE flow_integration_log (
id SERIAL PRIMARY KEY,
flow_definition_id INTEGER NOT NULL,
step_id INTEGER NOT NULL,
data_id VARCHAR(100),
integration_type VARCHAR(50) NOT NULL,
connection_id INTEGER,
request_payload JSONB,
response_payload JSONB,
status VARCHAR(50) NOT NULL, -- 'success' | 'failed' | 'timeout' | 'rollback'
error_message TEXT,
execution_time_ms INTEGER,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_flow_integration_log_flow ON flow_integration_log(flow_definition_id);
CREATE INDEX idx_flow_integration_log_status ON flow_integration_log(status);
CREATE INDEX idx_flow_integration_log_created ON flow_integration_log(created_at);
```
---
## 7. 구현 우선순위
### Phase 1: 외부 DB 연동 (2-3주)
1. 외부 DB 연결 정보 관리 UI
2. ExternalDbIntegration 서비스 구현
3. 플로우 단계 설정에서 외부 DB 선택 기능
4. 테스트 및 검증
### Phase 2: REST API 연동 (2-3주)
1. API 연결 정보 관리 UI
2. RestApiIntegration 서비스 구현
3. 템플릿 변수 시스템 구축
4. 재시도 및 에러 처리
### Phase 3: 복합 연동 (2주)
1. HybridIntegration 서비스 구현
2. 트랜잭션 관리 및 롤백
3. UI에서 복합 시나리오 구성
### Phase 4: 모니터링 및 최적화 (1-2주)
1. 로깅 시스템 구축
2. 성능 모니터링 대시보드
3. 알림 시스템
---
## 8. 사용 예시
### 예시 1: 주문 승인 시 외부 ERP 시스템 업데이트
```
플로우: 주문 승인 프로세스
검토중 단계
승인됨 단계 (외부 연동)
- 내부 DB: orders.status = 'approved'
- 외부 ERP API: POST /api/orders/approve
{
"orderId": "{{dataId}}",
"approvedBy": "{{currentUser}}",
"approvedAt": "{{timestamp}}"
}
- Webhook: 회계 시스템에 승인 알림
```
### 예시 2: 재고 이동 시 창고 관리 DB 업데이트
```
플로우: 재고 이동 프로세스
이동 요청 단계
이동 완료 단계 (외부 DB 연동)
- 내부 DB: inventory_transfer.status = 'completed'
- 외부 창고 DB:
UPDATE warehouse_stock
SET quantity = quantity - {{transferQty}}
WHERE product_id = {{productId}}
AND warehouse_id = {{fromWarehouse}}
```
---
## 9. 기대 효과
1. **시스템 통합**: 여러 시스템 간 데이터 동기화 자동화
2. **업무 효율**: 수동 데이터 입력 감소
3. **실시간 연동**: 상태 변경 즉시 외부 시스템에 반영
4. **확장성**: 새로운 외부 시스템 쉽게 추가
5. **트랜잭션 보장**: 롤백 기능으로 데이터 일관성 유지
---
## 10. 참고사항
- 외부 연동 설정은 관리자 권한 필요
- 모든 외부 호출은 로그 기록
- 타임아웃 및 재시도 정책 필수 설정
- 정기적인 연결 상태 모니터링 필요
- 보안을 위해 자격 증명은 반드시 암호화

View File

@ -1,381 +0,0 @@
# 플로우 하이브리드 모드 사용 가이드
## 개요
플로우 관리 시스템은 세 가지 데이터 이동 방식을 지원합니다:
1. **상태 변경 방식(status)**: 같은 테이블 내에서 상태 컬럼만 업데이트
2. **테이블 이동 방식(table)**: 완전히 다른 테이블로 데이터 복사 및 이동
3. **하이브리드 방식(both)**: 두 가지 모두 수행
## 1. 상태 변경 방식 (Status Mode)
### 사용 시나리오
- 같은 엔티티가 여러 단계를 거치는 경우
- 예: 승인 프로세스 (대기 → 검토 → 승인 → 완료)
### 설정 방법
```sql
-- 플로우 정의 생성
INSERT INTO flow_definition (name, description, table_name, is_active)
VALUES ('문서 승인', '문서 승인 프로세스', 'documents', true);
-- 단계 생성 (상태 변경 방식)
INSERT INTO flow_step (
flow_definition_id, step_name, step_order,
table_name, move_type, status_column, status_value,
condition_json
) VALUES
(1, '대기', 1, 'documents', 'status', 'approval_status', 'pending',
'{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"pending"}]}'::jsonb),
(1, '검토중', 2, 'documents', 'status', 'approval_status', 'reviewing',
'{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"reviewing"}]}'::jsonb),
(1, '승인됨', 3, 'documents', 'status', 'approval_status', 'approved',
'{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"approved"}]}'::jsonb);
```
### 테이블 구조
```sql
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
title VARCHAR(200),
content TEXT,
approval_status VARCHAR(50) DEFAULT 'pending', -- 상태 컬럼
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
### 데이터 이동
```typescript
// 프론트엔드에서 호출
await moveData(flowId, currentStepId, nextStepId, documentId);
// 백엔드에서 처리
// documents 테이블의 approval_status가 'pending' → 'reviewing'으로 변경됨
```
## 2. 테이블 이동 방식 (Table Mode)
### 사용 시나리오
- 완전히 다른 엔티티를 다루는 경우
- 예: 제품 수명주기 (구매 주문 → 설치 작업 → 폐기 신청)
### 설정 방법
```sql
-- 플로우 정의 생성
INSERT INTO flow_definition (name, description, table_name, is_active)
VALUES ('제품 수명주기', '구매→설치→폐기 프로세스', 'purchase_orders', true);
-- 단계 생성 (테이블 이동 방식)
INSERT INTO flow_step (
flow_definition_id, step_name, step_order,
table_name, move_type, target_table,
field_mappings, required_fields
) VALUES
(2, '구매', 1, 'purchase_orders', 'table', 'installations',
'{"order_id":"purchase_order_id","product_name":"product_name","product_code":"product_code"}'::jsonb,
'["product_name","purchase_date","purchase_price"]'::jsonb),
(2, '설치', 2, 'installations', 'table', 'disposals',
'{"installation_id":"installation_id","product_name":"product_name","product_code":"product_code"}'::jsonb,
'["installation_date","installation_location","technician"]'::jsonb),
(2, '폐기', 3, 'disposals', 'table', NULL,
NULL,
'["disposal_date","disposal_method","disposal_cost"]'::jsonb);
```
### 테이블 구조
```sql
-- 단계 1: 구매 주문 테이블
CREATE TABLE purchase_orders (
id SERIAL PRIMARY KEY,
order_id VARCHAR(50) UNIQUE,
product_name VARCHAR(200),
product_code VARCHAR(50),
purchase_date DATE,
purchase_price DECIMAL(15,2),
vendor_name VARCHAR(200),
created_at TIMESTAMP DEFAULT NOW()
);
-- 단계 2: 설치 작업 테이블
CREATE TABLE installations (
id SERIAL PRIMARY KEY,
purchase_order_id VARCHAR(50), -- 매핑 필드
product_name VARCHAR(200),
product_code VARCHAR(50),
installation_date DATE,
installation_location TEXT,
technician VARCHAR(100),
created_at TIMESTAMP DEFAULT NOW()
);
-- 단계 3: 폐기 신청 테이블
CREATE TABLE disposals (
id SERIAL PRIMARY KEY,
installation_id INTEGER, -- 매핑 필드
product_name VARCHAR(200),
product_code VARCHAR(50),
disposal_date DATE,
disposal_method VARCHAR(100),
disposal_cost DECIMAL(15,2),
created_at TIMESTAMP DEFAULT NOW()
);
```
### 데이터 이동
```typescript
// 구매 → 설치 단계로 이동
const result = await moveData(
flowId,
purchaseStepId,
installationStepId,
purchaseOrderId
);
// 결과:
// 1. purchase_orders 테이블에서 데이터 조회
// 2. field_mappings에 따라 필드 매핑
// 3. installations 테이블에 새 레코드 생성
// 4. flow_data_mapping 테이블에 매핑 정보 저장
// 5. flow_audit_log에 이동 이력 기록
```
### 매핑 정보 조회
```sql
-- 플로우 전체 이력 조회
SELECT * FROM flow_data_mapping
WHERE flow_definition_id = 2;
-- 결과 예시:
-- {
-- "current_step_id": 2,
-- "step_data_map": {
-- "1": "123", -- 구매 주문 ID
-- "2": "456" -- 설치 작업 ID
-- }
-- }
```
## 3. 하이브리드 방식 (Both Mode)
### 사용 시나리오
- 상태도 변경하면서 다른 테이블로도 이동해야 하는 경우
- 예: 검토 완료 후 승인 테이블로 이동하면서 원본 테이블의 상태도 변경
### 설정 방법
```sql
INSERT INTO flow_step (
flow_definition_id, step_name, step_order,
table_name, move_type,
status_column, status_value, -- 상태 변경용
target_table, field_mappings, -- 테이블 이동용
required_fields
) VALUES
(3, '검토 완료', 1, 'review_queue', 'both',
'status', 'reviewed',
'approved_items',
'{"item_id":"source_item_id","item_name":"name","review_score":"score"}'::jsonb,
'["review_date","reviewer_id","review_comment"]'::jsonb);
```
### 동작
1. **상태 변경**: review_queue 테이블의 status를 'reviewed'로 업데이트
2. **테이블 이동**: approved_items 테이블에 새 레코드 생성
3. **매핑 저장**: flow_data_mapping에 양쪽 ID 기록
## 4. 프론트엔드 구현
### FlowWidget에서 데이터 이동
```typescript
// frontend/components/screen/widgets/FlowWidget.tsx
const handleMoveToNext = async () => {
// ... 선택된 데이터 준비 ...
for (const data of selectedData) {
// Primary Key 추출 (첫 번째 컬럼 또는 'id' 컬럼)
const dataId = data.id || data[stepDataColumns[0]];
// API 호출
const response = await moveData(flowId, currentStepId, nextStepId, dataId);
if (!response.success) {
toast.error(`이동 실패: ${response.message}`);
continue;
}
// 성공 시 targetDataId 확인 가능
if (response.data?.targetDataId) {
console.log(`새 테이블 ID: ${response.data.targetDataId}`);
}
}
// 데이터 새로고침
await refreshStepData();
};
```
### 추가 데이터 전달
```typescript
// 다음 단계로 이동하면서 추가 데이터 입력
const additionalData = {
installation_date: "2025-10-20",
technician: "John Doe",
installation_notes: "Installed successfully",
};
const response = await fetch(`/api/flow/${flowId}/move`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
fromStepId: currentStepId,
toStepId: nextStepId,
dataId: dataId,
additionalData: additionalData,
}),
});
```
## 5. 감사 로그 조회
### 특정 데이터의 이력 조회
```typescript
const auditLogs = await getFlowAuditLogs(flowId, dataId);
// 결과:
[
{
id: 1,
moveType: "table",
sourceTable: "purchase_orders",
targetTable: "installations",
sourceDataId: "123",
targetDataId: "456",
fromStepName: "구매",
toStepName: "설치",
changedBy: "system",
changedAt: "2025-10-20T10:30:00",
},
{
id: 2,
moveType: "table",
sourceTable: "installations",
targetTable: "disposals",
sourceDataId: "456",
targetDataId: "789",
fromStepName: "설치",
toStepName: "폐기",
changedBy: "user123",
changedAt: "2025-10-21T14:20:00",
},
];
```
## 6. 모범 사례
### 상태 변경 방식 사용 시
**권장**:
- 단일 엔티티의 생명주기 관리
- 간단한 승인 프로세스
- 빠른 상태 조회가 필요한 경우
**비권장**:
- 각 단계마다 완전히 다른 데이터 구조가 필요한 경우
### 테이블 이동 방식 사용 시
**권장**:
- 각 단계가 독립적인 엔티티
- 단계별로 다른 팀/부서에서 관리
- 각 단계의 데이터 구조가 완전히 다른 경우
**비권장**:
- 단순한 상태 변경만 필요한 경우 (오버엔지니어링)
- 실시간 조회 성능이 중요한 경우 (JOIN 비용)
### 하이브리드 방식 사용 시
**권장**:
- 원본 데이터는 보존하면서 처리된 데이터는 별도 저장
- 이중 추적이 필요한 경우
## 7. 주의사항
1. **필드 매핑 주의**: `field_mappings`의 소스/타겟 필드가 정확해야 함
2. **필수 필드 검증**: `required_fields`에 명시된 필드는 반드시 입력
3. **트랜잭션**: 모든 이동은 트랜잭션으로 처리되어 원자성 보장
4. **Primary Key**: 테이블 이동 시 소스 데이터의 Primary Key가 명확해야 함
5. **순환 참조 방지**: 플로우 연결 시 사이클이 발생하지 않도록 주의
## 8. 트러블슈팅
### Q1: "데이터를 찾을 수 없습니다" 오류
- 원인: Primary Key가 잘못되었거나 데이터가 이미 이동됨
- 해결: `flow_audit_log`에서 이동 이력 확인
### Q2: "매핑할 데이터가 없습니다" 오류
- 원인: `field_mappings`가 비어있거나 소스 필드가 없음
- 해결: 소스 테이블에 매핑 필드가 존재하는지 확인
### Q3: 테이블 이동 후 원본 데이터 처리
- 원본 데이터는 자동으로 삭제되지 않음
- 필요시 별도 로직으로 처리하거나 `is_archived` 플래그 사용
## 9. 성능 최적화
1. **인덱스 생성**: 상태 컬럼에 인덱스 필수
```sql
CREATE INDEX idx_documents_status ON documents(approval_status);
```
2. **배치 이동**: 대량 데이터는 배치 API 사용
```typescript
await moveBatchData(flowId, fromStepId, toStepId, dataIds);
```
3. **매핑 테이블 정리**: 주기적으로 완료된 플로우의 매핑 데이터 아카이빙
```sql
DELETE FROM flow_data_mapping
WHERE created_at < NOW() - INTERVAL '1 year'
AND current_step_id IN (SELECT id FROM flow_step WHERE step_order = (SELECT MAX(step_order) FROM flow_step WHERE flow_definition_id = ?));
```
## 결론
하이브리드 플로우 시스템은 다양한 비즈니스 요구사항에 유연하게 대응할 수 있습니다:
- 간단한 상태 관리부터
- 복잡한 다단계 프로세스까지
- 하나의 시스템으로 통합 관리 가능

View File

@ -1,646 +0,0 @@
# 플로우 관리 시스템 UI 설계
## 1. 플로우 관리 화면 (/flow-management)
### 1.1 전체 레이아웃
```
┌─────────────────────────────────────────────────────────────────────┐
│ 플로우 관리 [+ 새 플로우] [저장] │
├──────────────┬──────────────────────────────────────┬───────────────┤
│ │ │ │
│ 플로우 목록 │ 플로우 편집 캔버스 │ 속성 패널 │
│ (좌측) │ (중앙) │ (우측) │
│ │ │ │
│ ┌────────┐ │ ┌──────┐ │ ┌───────────┐ │
│ │플로우 1│ │ │ │ ┌──────┐ │ │ 단계명: │ │
│ ├────────┤ │ │ 구매 │─────▶│ 설치 │ │ │ [구매] │ │
│ │플로우 2│ │ │ │ └──────┘ │ │ │ │
│ ├────────┤ │ └──────┘ │ │ │ 색상: │ │
│ │플로우 3│ │ │ │ │ [파랑] │ │
│ └────────┘ │ ▼ │ │ │ │
│ │ ┌──────┐ │ │ 조건 설정: │ │
│ [테이블 선택]│ │ 폐기 │ │ │ │ │
│ [product_ │ └──────┘ │ │ 컬럼: │ │
│ dtg ] │ │ │ [status] │ │
│ │ │ │ │ │
│ │ │ │ 연산자: │ │
│ │ │ │ [equals] │ │
│ │ │ │ │ │
│ │ │ │ 값: │ │
│ │ │ │ [구매완료]│ │
│ │ │ │ │ │
│ │ │ │[+조건추가]│ │
│ │ │ └───────────┘ │
├──────────────┴──────────────────────────────────────┴───────────────┤
│ 도구 모음: [노드 추가] [연결] [삭제] [정렬] [줌 인/아웃] [미니맵] │
└─────────────────────────────────────────────────────────────────────┘
```
### 1.2 플로우 노드 상세
```
┌─────────────────────────────┐
│ 구매 [x] │ ← 닫기 버튼
├─────────────────────────────┤
│ 상태: status = '구매완료' │ ← 조건 요약
│ AND install_date IS NULL │
├─────────────────────────────┤
│ 데이터: 15건 │ ← 현재 조건에 맞는 데이터 수
└─────────────────────────────┘
[연결선 라벨]
┌─────────────────────────────┐
│ 설치 [x] │
├─────────────────────────────┤
│ 상태: status = '설치완료' │
│ AND disposal_date IS NULL │
├─────────────────────────────┤
│ 데이터: 8건 │
└─────────────────────────────┘
```
### 1.3 조건 빌더 UI
```
┌─────────────────────────────────────────────┐
│ 조건 설정 [AND▼] │
├─────────────────────────────────────────────┤
│ 조건 1: [- 삭제]│
│ ┌───────────┬──────────┬──────────────┐ │
│ │ 컬럼 │ 연산자 │ 값 │ │
│ │ [status▼] │[equals▼] │[구매완료 ]│ │
│ └───────────┴──────────┴──────────────┘ │
│ │
│ 조건 2: [- 삭제]│
│ ┌───────────┬──────────┬──────────────┐ │
│ │ 컬럼 │ 연산자 │ 값 │ │
│ │[install_ │[is_null▼]│ │ │
│ │ date ▼]│ │ │ │
│ └───────────┴──────────┴──────────────┘ │
│ │
│ [+ 조건 추가] │
└─────────────────────────────────────────────┘
연산자 옵션:
- equals (같음)
- not_equals (같지 않음)
- in (포함)
- not_in (포함하지 않음)
- greater_than (크다)
- less_than (작다)
- is_null (NULL)
- is_not_null (NULL 아님)
```
---
## 2. 화면관리에서 플로우 위젯 배치
### 2.1 화면 편집기 (ScreenDesigner)
```
┌─────────────────────────────────────────────────────────────────────┐
│ 화면 편집기 - DTG 제품 관리 [저장] │
├─────────────┬───────────────────────────────────────┬───────────────┤
│ │ │ │
│ 컴포넌트 │ 캔버스 │ 속성 패널 │
│ │ │ │
│ ┌─────────┐ │ ┌─────────────────────────────────┐ │ 타입: │
│ │ 입력필드 │ │ │ DTG 제품 라이프사이클 │ │ [flow-widget] │
│ ├─────────┤ │ ├─────┬─────┬─────┬─────┬─────────┤ │ │
│ │ 버튼 │ │ │구매 │ │설치 │ │ 폐기 │ │ 플로우 선택: │
│ ├─────────┤ │ │ │ → │ │ → │ │ │ [DTG 라이프 │
│ │ 테이블 │ │ │ 15건│ │ 8건 │ │ 3건 │ │ 사이클 ▼] │
│ ├─────────┤ │ └─────┴─────┴─────┴─────┴─────────┘ │ │
│ │플로우 │ │ ◀ 드래그앤드롭으로 배치 │ 레이아웃: │
│ └─────────┘ │ │ [가로▼] │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ 제품 상세 정보 │ │ 카드 너비: │
│ │ │ ┌────────────┬───────────────┐ │ │ [200px] │
│ │ │ │ 제품명: │ [ ] │ │ │ │
│ │ │ │ 구매일자: │ [ ] │ │ │ 데이터 카운트 │
│ │ │ │ 설치일자: │ [ ] │ │ │ [✓] 표시 │
│ │ │ │ 폐기일자: │ [ ] │ │ │ │
│ │ │ └────────────┴───────────────┘ │ │ 연결선 │
│ │ └─────────────────────────────────┘ │ [✓] 표시 │
│ │ │ │
└─────────────┴───────────────────────────────────────┴───────────────┘
```
### 2.2 플로우 위젯 설정 패널
```
┌─────────────────────────────────────┐
│ 플로우 위젯 설정 │
├─────────────────────────────────────┤
│ 플로우 선택: │
│ ┌───────────────────────────────┐ │
│ │ DTG 제품 라이프사이클 ▼ │ │
│ └───────────────────────────────┘ │
│ │
│ 레이아웃: │
│ ( ) 가로 (•) 세로 │
│ │
│ 카드 너비: │
│ [200px ] │
│ │
│ 카드 높이: │
│ [120px ] │
│ │
│ [✓] 데이터 카운트 표시 │
│ [✓] 연결선 표시 │
│ [ ] 컴팩트 모드 │
│ │
│ 카드 스타일: │
│ ┌─────────────────────────────┐ │
│ │ 테두리 색상: [단계별 색상 ▼] │ │
│ │ 배경 색상: [흰색 ▼] │ │
│ │ 그림자: [중간 ▼] │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘
```
---
## 3. 실제 화면에서 플로우 표시 (InteractiveScreenViewer)
### 3.1 가로 레이아웃
```
┌─────────────────────────────────────────────────────────────────────┐
│ DTG 제품 관리 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ DTG 제품 라이프사이클 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ 구매 │ │ 설치 │ │ 폐기 │ │
│ │ │ → │ │ → │ │ │
│ │ 15건 │ │ 8건 │ │ 3건 │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ │
│ ↑ 클릭하면 데이터 리스트 모달 열림 │
│ │
│ ───────────────────────────────────────────────────────────────── │
│ │
│ 제품 상세 정보 │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 제품명: [DTG-001 ] │ │
│ │ 구매일자: [2024-01-15 ] │ │
│ │ 설치일자: [2024-02-20 ] │ │
│ │ 폐기일자: [ ] │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 3.2 세로 레이아웃
```
┌─────────────────────────────────────────────────────────────────────┐
│ DTG 제품 관리 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ DTG 제품 라이프사이클 │
│ ┌─────────────┐ │
│ │ 구매 │ │
│ │ │ │
│ │ 15건 │ │
│ └─────────────┘ │
│ ↓ │
│ ┌─────────────┐ │
│ │ 설치 │ │
│ │ │ │
│ │ 8건 │ │
│ └─────────────┘ │
│ ↓ │
│ ┌─────────────┐ │
│ │ 폐기 │ │
│ │ │ │
│ │ 3건 │ │
│ └─────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 4. 플로우 단계 클릭 시 데이터 리스트 모달
```
┌─────────────────────────────────────────────────────────────────────┐
│ 구매 단계 - 데이터 목록 [X 닫기] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──┬──────────┬────────────┬────────────┬──────────┬──────────┐ │
│ │□ │ 제품명 │ 구매일자 │ 구매금액 │ 구매처 │ 상태 │ │
│ ├──┼──────────┼────────────┼────────────┼──────────┼──────────┤ │
│ │☑ │ DTG-001 │ 2024-01-15 │ 15,000,000 │ A업체 │ 구매완료 │ │
│ │☑ │ DTG-002 │ 2024-01-20 │ 15,500,000 │ B업체 │ 구매완료 │ │
│ │□ │ DTG-003 │ 2024-02-01 │ 14,800,000 │ A업체 │ 구매완료 │ │
│ │□ │ DTG-004 │ 2024-02-05 │ 16,200,000 │ C업체 │ 구매완료 │ │
│ │☑ │ DTG-005 │ 2024-02-10 │ 15,000,000 │ B업체 │ 구매완료 │ │
│ │□ │ DTG-006 │ 2024-02-15 │ 15,300,000 │ A업체 │ 구매완료 │ │
│ │□ │ DTG-007 │ 2024-02-20 │ 15,700,000 │ B업체 │ 구매완료 │ │
│ │□ │ DTG-008 │ 2024-02-25 │ 16,000,000 │ C업체 │ 구매완료 │ │
│ └──┴──────────┴────────────┴────────────┴──────────┴──────────┘ │
│ │
│ 선택된 항목: 3개 [1] [2] [3] [4] [5] │
│ │
├─────────────────────────────────────────────────────────────────────┤
│ [취소] [설치 단계로 이동] ← │
└─────────────────────────────────────────────────────────────────────┘
```
### 4.1 단계 이동 확인 대화상자
```
┌─────────────────────────────────────────┐
│ 단계 이동 확인 │
├─────────────────────────────────────────┤
│ │
│ 선택한 3개의 제품을 │
│ '구매' 단계에서 '설치' 단계로 │
│ 이동하시겠습니까? │
│ │
│ 이동 사유 (선택): │
│ ┌─────────────────────────────────┐ │
│ │ 설치 일정 확정 │ │
│ │ │ │
│ └─────────────────────────────────┘ │
│ │
│ [취소] [확인] │
└─────────────────────────────────────────┘
```
---
## 5. 오딧 로그 (이력) 화면
### 5.1 제품 상세 화면 내 이력 탭
```
┌─────────────────────────────────────────────────────────────────────┐
│ 제품 상세: DTG-001 │
├──────┬──────────────────────────────────────────────────────────────┤
│ 기본 │ 플로우 이력 │ 문서 │ AS 이력 │ │
├──────┴──────────────────────────────────────────────────────────────┤
│ │
│ DTG 제품 라이프사이클 이력 │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 2024-02-20 14:30:25 │ │
│ │ [구매] → [설치] │ │
│ │ 변경자: 홍길동 (설치팀) │ │
│ │ 사유: 고객사 설치 완료 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────────────┐ │
│ │ 2024-01-15 09:15:00 │ │
│ │ [시작] → [구매] │ │
│ │ 변경자: 김철수 (구매팀) │ │
│ │ 사유: 신규 제품 구매 등록 │ │
│ └──────────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
### 5.2 타임라인 뷰
```
┌─────────────────────────────────────────────────────────────────────┐
│ 플로우 이력 (타임라인) │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 2024-01-15 │
│ │ │
│ ● 구매 (김철수) │
│ │ "신규 제품 구매 등록" │
│ │ │
│ │ (36일 경과) │
│ │ │
│ 2024-02-20 │
│ │ │
│ ● 설치 (홍길동) │
│ │ "고객사 설치 완료" │
│ │ │
│ │ (진행 중...) │
│ │ │
│ ○ 폐기 (예정) │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 6. 플로우 위젯 스타일 변형
### 6.1 컴팩트 모드
```
┌─────────────────────────────────────────┐
│ DTG 라이프사이클 │
│ [구매 15] → [설치 8] → [폐기 3] │
└─────────────────────────────────────────┘
```
### 6.2 카드 상세 모드
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ 구매 │ │ 설치 │ │ 폐기 │
├──────────────────┤ ├──────────────────┤ ├──────────────────┤
│ │ │ │ │ │
│ 15건 │→ │ 8건 │→ │ 3건 │
│ │ │ │ │ │
├──────────────────┤ ├──────────────────┤ ├──────────────────┤
│ 조건: │ │ 조건: │ │ 조건: │
│ status=구매완료 │ │ status=설치완료 │ │ status=폐기완료 │
│ │ │ │ │ │
│ 최근 업데이트: │ │ 최근 업데이트: │ │ 최근 업데이트: │
│ 2024-02-25 │ │ 2024-02-20 │ │ 2024-01-15 │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
### 6.3 프로그레스 바 스타일
```
┌─────────────────────────────────────────────────────────────────────┐
│ DTG 제품 라이프사이클 │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 구매 ━━━━━━━━━ 설치 ━━━━━━━━━ 폐기 │
│ 15건 57% 8건 31% 3건 12% │
│ ████████████▓▓▓▓▓▓▓▓▓░░░░░░░░ │
│ │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 7. 모바일 반응형 디자인
### 7.1 모바일 뷰 (세로)
```
┌─────────────────────┐
│ ☰ DTG 제품 관리 │
├─────────────────────┤
│ │
│ 라이프사이클: │
│ │
│ ┌───────────────┐ │
│ │ 구매 │ │
│ │ 15건 │ │
│ └───────────────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ 설치 │ │
│ │ 8건 │ │
│ └───────────────┘ │
│ ↓ │
│ ┌───────────────┐ │
│ │ 폐기 │ │
│ │ 3건 │ │
│ └───────────────┘ │
│ │
│ ─────────────── │
│ │
│ 제품 정보 │
│ 제품명: DTG-001 │
│ 구매일: 2024-01-15 │
│ 설치일: 2024-02-20 │
│ │
└─────────────────────┘
```
---
## 8. 플로우 편집기 상세 기능
### 8.1 노드 추가 메뉴
```
┌─────────────────────────────────────────┐
│ 캔버스 우클릭 메뉴 │
├─────────────────────────────────────────┤
단계 추가 │
│ 🔗 연결선 추가 │
│ 📋 붙여넣기 │
│ ─────────────────────────────────── │
│ 🎨 배경 색상 변경 │
│ 📏 격자 설정 │
│ 🔍 확대/축소 │
└─────────────────────────────────────────┘
```
### 8.2 노드 우클릭 메뉴
```
┌─────────────────────────────────────────┐
│ 단계 메뉴 │
├─────────────────────────────────────────┤
│ ✏️ 편집 │
│ 📋 복사 │
│ 🗑️ 삭제 │
│ ─────────────────────────────────── │
│ 🔗 다음 단계로 연결 │
│ 🎨 색상 변경 │
│ 📊 데이터 미리보기 (15건) │
│ ─────────────────────────────────── │
│ ⬆️ 앞으로 가져오기 │
│ ⬇️ 뒤로 보내기 │
└─────────────────────────────────────────┘
```
### 8.3 미니맵
```
┌──────────────────────┐
│ 미니맵 [X] │
├──────────────────────┤
│ ┌────────────────┐ │
│ │ ● │ │
│ │ ● │ │
│ │ │ │
│ │ ● │ │
│ │ │ │
│ │ [====] ←현재 │ │
│ │ 뷰포트│ │
│ └────────────────┘ │
│ │
│ 줌: 100% │
│ [] ■■■■■ [] │
└──────────────────────┘
```
---
## 9. 플로우 템플릿 선택 화면
```
┌─────────────────────────────────────────────────────────────────────┐
│ 새 플로우 만들기 [X 닫기] │
├─────────────────────────────────────────────────────────────────────┤
│ │
│ 템플릿 선택: │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 빈 플로우 │ │ 3단계 플로우 │ │ 승인 플로우 │ │
│ │ │ │ │ │ │ │
│ │ │ │ ● → ● → ● │ │ ● → ● → ● │ │
│ │ + │ │ │ │ ↓ ↓ │ │
│ │ │ │ │ │ ● ● │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 구매→설치 │ │ 품질검사 │ │ 커스텀 │ │
│ │ │ │ │ │ │ │
│ │ ● → ● → ● │ │ ● → ● → ● │ │ │ │
│ │ │ │ ↓ ↓ ↓ │ │ 불러오기 │ │
│ │ │ │ ● ● ● │ │ │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ │
│ 또는 │
│ │
│ 플로우 이름: [ ] │
│ 연결 테이블: [product_dtg ▼] │
│ │
│ [취소] [빈 플로우로 시작] │
└─────────────────────────────────────────────────────────────────────┘
```
---
## 10. 데이터 흐름 다이어그램
### 10.1 전체 시스템 흐름
```
┌─────────────────────────────────────────────────────────────────────┐
│ 플로우 관리 시스템 │
└─────────────────────────────────────────────────────────────────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 플로우 정의 │ │ 조건 설정 │ │ 시각화 편집 │
│ (정의/수정) │ │ (SQL 변환) │ │ (React Flow) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└────────────────────────┼────────────────────────┘
┌─────────────────────────┐
│ 데이터베이스 저장 │
│ - flow_definition │
│ - flow_step │
│ - flow_step_connection │
└────────────┬────────────┘
┌────────────────────────┼────────────────────────┐
│ │ │
▼ ▼ ▼
┌───────────────┐ ┌───────────────┐ ┌───────────────┐
│ 화면관리 │ │ 실시간 카운트 │ │ 데이터 이동 │
│ (위젯 배치) │ │ (조건 조회) │ │ (상태 변경) │
└───────┬───────┘ └───────┬───────┘ └───────┬───────┘
│ │ │
└────────────────────────┼────────────────────────┘
┌─────────────────────────┐
│ 사용자 화면에 표시 │
│ + 오딧 로그 기록 │
└─────────────────────────┘
```
---
## 11. 사용자 시나리오 플로우
```
┌─────────────────────────────────────────────────────────────────────┐
│ 시나리오: DTG 제품 라이프사이클 관리 │
└─────────────────────────────────────────────────────────────────────┘
단계 1: 플로우 정의
─────────────────────
관리자가 플로우 관리 화면에서:
1. "새 플로우" 클릭
2. 이름: "DTG 제품 라이프사이클" 입력
3. 테이블: "product_dtg" 선택
4. "구매", "설치", "폐기" 3개 단계 추가
5. 각 단계의 조건 설정
6. 저장
단계 2: 화면에 배치
─────────────────────
관리자가 화면관리에서:
1. "DTG 제품 관리" 화면 열기
2. 컴포넌트 팔레트에서 "플로우 위젯" 드래그
3. "DTG 제품 라이프사이클" 플로우 선택
4. 레이아웃 및 스타일 설정
5. 저장
단계 3: 일반 사용자 사용
─────────────────────
일반 사용자가:
1. "DTG 제품 관리" 화면 접속
2. 플로우 위젯에서 각 단계별 건수 확인
- 구매: 15건
- 설치: 8건
- 폐기: 3건
3. "구매" 단계 클릭 → 데이터 리스트 모달 열림
4. 설치 완료된 제품 2개 선택
5. "설치 단계로 이동" 버튼 클릭
6. 이동 사유 입력: "설치 완료"
7. 확인 → 데이터 이동 및 오딧 로그 기록
단계 4: 이력 조회
─────────────────────
사용자가:
1. 특정 제품(DTG-001) 상세 화면 열기
2. "플로우 이력" 탭 클릭
3. 모든 상태 변경 이력 확인
- 언제, 누가, 어떤 단계로 이동했는지
- 이동 사유
```
---
## 12. 색상 및 테마
### 12.1 기본 색상 팔레트
```
플로우 단계 색상:
┌──────┬──────┬──────┬──────┬──────┬──────┐
│ 파랑 │ 초록 │ 주황 │ 빨강 │ 보라 │ 회색 │
│#3B82F6│#10B981│#F59E0B│#EF4444│#8B5CF6│#6B7280│
└──────┴──────┴──────┴──────┴──────┴──────┘
상태별 색상:
- 시작: 파랑
- 진행 중: 초록
- 대기: 주황
- 완료: 회색
- 거부/폐기: 빨강
```
### 12.2 다크모드
```
┌─────────────────────────────────────────┐
│ 플로우 관리 (다크모드) │
├─────────────────────────────────────────┤
│ 배경: #1F2937 (어두운 회색) │
│ 카드: #374151 (중간 회색) │
│ 텍스트: #F9FAFB (밝은 회색) │
│ 강조: #3B82F6 (파랑) │
└─────────────────────────────────────────┘
```
이상으로 플로우 관리 시스템의 UI 설계를 도식화했습니다!

View File

@ -1,216 +0,0 @@
# Phase 1 플로우 관리 시스템 구현 완료 보고서
## 구현 일시
2024년 (구현 완료)
## 구현 내역
### 1. 데이터베이스 구조 ✅
#### 생성된 테이블 (5개)
1. **flow_definition** - 플로우 정의
- 플로우 이름, 설명, 연결 테이블명
- 활성화 상태 관리
- 생성자 및 타임스탬프
2. **flow_step** - 플로우 단계
- 단계 이름, 순서, 조건(JSONB)
- 색상, 캔버스 위치(X, Y)
- 타임스탬프
3. **flow_step_connection** - 플로우 단계 연결
- 시작 단계 → 종료 단계
- 연결선 라벨
4. **flow_data_status** - 데이터의 현재 플로우 상태
- 레코드별 현재 단계 추적
- 수정자 및 타임스탬프
- UNIQUE 제약조건 (flowId + tableName + recordId)
5. **flow_audit_log** - 플로우 상태 변경 이력
- 이전 단계 → 이동 단계
- 변경자, 변경 사유, 타임스탬프
#### 생성된 인덱스 (13개)
- 테이블명, 활성 상태, 단계 순서, 레코드 조회 등 성능 최적화
### 2. 백엔드 서비스 구현 ✅
#### 서비스 파일 (6개)
1. **flowConditionParser.ts**
- JSON 조건을 SQL WHERE 절로 변환
- 12개 연산자 지원 (equals, not_equals, in, not_in, greater_than, less_than, >=, <=, is_null, is_not_null, like, not_like)
- SQL 인젝션 방지 (컬럼명 검증)
- 조건 유효성 검증
2. **flowDefinitionService.ts**
- 플로우 정의 CRUD
- 테이블 존재 여부 확인
- 테이블명, 활성 상태로 필터링
3. **flowStepService.ts**
- 플로우 단계 CRUD
- 단계 순서 재정렬 기능
- 조건 JSON 검증
4. **flowConnectionService.ts**
- 플로우 단계 연결 관리
- 순환 참조 체크 (DFS 알고리즘)
- 나가는/들어오는 연결 조회
5. **flowExecutionService.ts**
- 단계별 데이터 카운트 조회
- 단계별 데이터 리스트 조회 (페이징 지원)
- 모든 단계별 카운트 일괄 조회
- 현재 플로우 상태 조회
6. **flowDataMoveService.ts**
- 데이터 단계 이동 (트랜잭션 처리)
- 여러 데이터 일괄 이동
- 오딧 로그 기록
- 플로우 이력 조회 (단일 레코드 / 전체 플로우)
### 3. API 컨트롤러 및 라우터 ✅
#### FlowController (20개 엔드포인트)
**플로우 정의 (5개)**
- POST /api/flow/definitions - 생성
- GET /api/flow/definitions - 목록
- GET /api/flow/definitions/:id - 상세
- PUT /api/flow/definitions/:id - 수정
- DELETE /api/flow/definitions/:id - 삭제
**플로우 단계 (3개)**
- POST /api/flow/definitions/:flowId/steps - 생성
- PUT /api/flow/steps/:stepId - 수정
- DELETE /api/flow/steps/:stepId - 삭제
**플로우 연결 (2개)**
- POST /api/flow/connections - 생성
- DELETE /api/flow/connections/:connectionId - 삭제
**플로우 실행 (3개)**
- GET /api/flow/:flowId/step/:stepId/count - 단계별 카운트
- GET /api/flow/:flowId/step/:stepId/data - 단계별 데이터 리스트
- GET /api/flow/:flowId/counts - 모든 단계별 카운트
**데이터 이동 (2개)**
- POST /api/flow/move - 단일 데이터 이동
- POST /api/flow/move-batch - 여러 데이터 일괄 이동
**오딧 로그 (2개)**
- GET /api/flow/audit/:flowId/:recordId - 레코드별 이력
- GET /api/flow/audit/:flowId - 플로우 전체 이력
### 4. 타입 정의 ✅
**types/flow.ts** - 완전한 TypeScript 타입 정의
- 22개 인터페이스 및 타입
- 요청/응답 타입 분리
- ConditionOperator 타입 정의
### 5. 통합 완료 ✅
- app.ts에 flowRoutes 등록
- 데이터베이스 마이그레이션 실행 완료
- 모든 테이블 및 인덱스 생성 완료
## 구현된 주요 기능
### 1. 조건 시스템
- 복잡한 AND/OR 조건 지원
- 12개 연산자로 유연한 필터링
- SQL 인젝션 방지
### 2. 순환 참조 방지
- DFS 알고리즘으로 순환 참조 체크
- 무한 루프 방지
### 3. 트랜잭션 처리
- 데이터 이동 시 원자성 보장
- flow_data_status + flow_audit_log 동시 업데이트
- 실패 시 자동 롤백
### 4. 성능 최적화
- 적절한 인덱스 생성
- 페이징 지원
- 필터링 쿼리 최적화
### 5. 오딧 로그
- 모든 상태 변경 추적
- 변경자, 변경 사유 기록
- 단계명 조인 (from_step_name, to_step_name)
## 테스트 준비
**test-flow-api.rest** 파일 생성 (20개 테스트 케이스)
- 플로우 정의 CRUD
- 플로우 단계 관리
- 플로우 연결 관리
- 데이터 조회 (카운트, 리스트)
- 데이터 이동 (단일, 일괄)
- 오딧 로그 조회
## 다음 단계 (Phase 2)
### 프론트엔드 구현
1. React Flow 라이브러리 설치
2. FlowEditor 컴포넌트
3. FlowConditionBuilder UI
4. FlowList 컴포넌트
5. FlowStepPanel 속성 편집
### 예상 소요 시간: 1주
## 기술 스택
- **Backend**: Node.js + Express + TypeScript
- **Database**: PostgreSQL
- **ORM**: Raw SQL (트랜잭션 세밀 제어)
- **Validation**: 커스텀 검증 로직
## 코드 품질
- ✅ TypeScript 타입 안전성
- ✅ 에러 처리
- ✅ SQL 인젝션 방지
- ✅ 트랜잭션 관리
- ✅ 코드 주석 및 문서화
## 결론
Phase 1의 모든 목표가 성공적으로 완료되었습니다. 백엔드 API가 완전히 구현되었으며, 데이터베이스 스키마도 안정적으로 생성되었습니다. 이제 프론트엔드 구현(Phase 2)을 진행할 준비가 완료되었습니다.
---
**구현 완료일**: 2024년
**구현자**: AI Assistant
**검토 상태**: 대기 중

View File

@ -1,384 +0,0 @@
"use client";
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { useToast } from "@/hooks/use-toast";
import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
import {
FlowExternalDbConnection,
CreateFlowExternalDbConnectionRequest,
UpdateFlowExternalDbConnectionRequest,
DB_TYPE_OPTIONS,
getDbTypeLabel,
} from "@/types/flowExternalDb";
import { Plus, Pencil, Trash2, TestTube, Loader2 } from "lucide-react";
import { Switch } from "@/components/ui/switch";
export default function FlowExternalDbPage() {
const { toast } = useToast();
const [connections, setConnections] = useState<FlowExternalDbConnection[]>([]);
const [loading, setLoading] = useState(true);
const [showDialog, setShowDialog] = useState(false);
const [editingConnection, setEditingConnection] = useState<FlowExternalDbConnection | null>(null);
const [testingId, setTestingId] = useState<number | null>(null);
// 폼 상태
const [formData, setFormData] = useState<
CreateFlowExternalDbConnectionRequest | UpdateFlowExternalDbConnectionRequest
>({
name: "",
description: "",
dbType: "postgresql",
host: "",
port: 5432,
databaseName: "",
username: "",
password: "",
sslEnabled: false,
});
useEffect(() => {
loadConnections();
}, []);
const loadConnections = async () => {
try {
setLoading(true);
const response = await flowExternalDbApi.getAll();
if (response.success) {
setConnections(response.data);
}
} catch (error: any) {
toast({
title: "오류",
description: error.message || "외부 DB 연결 목록 조회 실패",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleCreate = () => {
setEditingConnection(null);
setFormData({
name: "",
description: "",
dbType: "postgresql",
host: "",
port: 5432,
databaseName: "",
username: "",
password: "",
sslEnabled: false,
});
setShowDialog(true);
};
const handleEdit = (connection: FlowExternalDbConnection) => {
setEditingConnection(connection);
setFormData({
name: connection.name,
description: connection.description,
host: connection.host,
port: connection.port,
databaseName: connection.databaseName,
username: connection.username,
password: "", // 비밀번호는 비워둠
sslEnabled: connection.sslEnabled,
isActive: connection.isActive,
});
setShowDialog(true);
};
const handleSave = async () => {
try {
if (editingConnection) {
// 수정
await flowExternalDbApi.update(editingConnection.id, formData);
toast({ title: "성공", description: "외부 DB 연결이 수정되었습니다" });
} else {
// 생성
await flowExternalDbApi.create(formData as CreateFlowExternalDbConnectionRequest);
toast({ title: "성공", description: "외부 DB 연결이 생성되었습니다" });
}
setShowDialog(false);
loadConnections();
} catch (error: any) {
toast({
title: "오류",
description: error.message,
variant: "destructive",
});
}
};
const handleDelete = async (id: number, name: string) => {
if (!confirm(`"${name}" 연결을 삭제하시겠습니까?`)) {
return;
}
try {
await flowExternalDbApi.delete(id);
toast({ title: "성공", description: "외부 DB 연결이 삭제되었습니다" });
loadConnections();
} catch (error: any) {
toast({
title: "오류",
description: error.message,
variant: "destructive",
});
}
};
const handleTestConnection = async (id: number, name: string) => {
setTestingId(id);
try {
const result = await flowExternalDbApi.testConnection(id);
toast({
title: result.success ? "연결 성공" : "연결 실패",
description: result.message,
variant: result.success ? "default" : "destructive",
});
} catch (error: any) {
toast({
title: "오류",
description: error.message,
variant: "destructive",
});
} finally {
setTestingId(null);
}
};
return (
<div className="container mx-auto py-6">
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold"> DB </h1>
<p className="text-muted-foreground mt-1 text-sm"> </p>
</div>
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : connections.length === 0 ? (
<div className="bg-muted/50 rounded-lg border py-12 text-center">
<p className="text-muted-foreground"> DB </p>
<Button onClick={handleCreate} className="mt-4">
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
) : (
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>DB </TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{connections.map((conn) => (
<TableRow key={conn.id}>
<TableCell className="font-medium">
<div>
<div>{conn.name}</div>
{conn.description && <div className="text-muted-foreground text-xs">{conn.description}</div>}
</div>
</TableCell>
<TableCell>
<Badge variant="outline">{getDbTypeLabel(conn.dbType)}</Badge>
</TableCell>
<TableCell className="font-mono text-sm">
{conn.host}:{conn.port}
</TableCell>
<TableCell className="font-mono text-sm">{conn.databaseName}</TableCell>
<TableCell>
<Badge variant={conn.isActive ? "default" : "secondary"}>{conn.isActive ? "활성" : "비활성"}</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => handleTestConnection(conn.id, conn.name)}
disabled={testingId === conn.id}
>
{testingId === conn.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube className="h-4 w-4" />
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => handleEdit(conn)}>
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" onClick={() => handleDelete(conn.id, conn.name)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
{/* 생성/수정 다이얼로그 */}
<Dialog open={showDialog} onOpenChange={setShowDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingConnection ? "외부 DB 연결 수정" : "새 외부 DB 연결 추가"}</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<Label htmlFor="name"> *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 운영_PostgreSQL"
/>
</div>
<div className="col-span-2">
<Label htmlFor="description"></Label>
<Input
id="description"
value={formData.description || ""}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="연결에 대한 설명"
/>
</div>
<div>
<Label htmlFor="dbType">DB *</Label>
<Select
value={formData.dbType}
onValueChange={(value: any) => setFormData({ ...formData, dbType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{DB_TYPE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-end gap-2">
<div className="flex-1">
<Label htmlFor="host"> *</Label>
<Input
id="host"
value={formData.host}
onChange={(e) => setFormData({ ...formData, host: e.target.value })}
placeholder="localhost"
/>
</div>
<div className="w-24">
<Label htmlFor="port"> *</Label>
<Input
id="port"
type="number"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="col-span-2">
<Label htmlFor="databaseName"> *</Label>
<Input
id="databaseName"
value={formData.databaseName}
onChange={(e) => setFormData({ ...formData, databaseName: e.target.value })}
placeholder="mydb"
/>
</div>
<div>
<Label htmlFor="username"> *</Label>
<Input
id="username"
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="dbuser"
/>
</div>
<div>
<Label htmlFor="password"> {editingConnection && "(변경 시에만 입력)"}</Label>
<Input
id="password"
type="password"
value={formData.password || ""}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={editingConnection ? "변경하지 않으려면 비워두세요" : "비밀번호"}
/>
</div>
<div className="col-span-2 flex items-center gap-2">
<Switch
id="sslEnabled"
checked={formData.sslEnabled}
onCheckedChange={(checked) => setFormData({ ...formData, sslEnabled: checked })}
/>
<Label htmlFor="sslEnabled">SSL </Label>
</div>
{editingConnection && (
<div className="col-span-2 flex items-center gap-2">
<Switch
id="isActive"
checked={(formData as UpdateFlowExternalDbConnectionRequest).isActive ?? true}
onCheckedChange={(checked) => setFormData({ ...formData, isActive: checked })}
/>
<Label htmlFor="isActive"></Label>
</div>
)}
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setShowDialog(false)}>
</Button>
<Button onClick={handleSave}>{editingConnection ? "수정" : "생성"}</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,323 +0,0 @@
"use client";
/**
*
* - React Flow
* - //
* - /
* -
*/
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import ReactFlow, {
Node,
Edge,
addEdge,
Connection,
useNodesState,
useEdgesState,
Background,
Controls,
MiniMap,
Panel,
} from "reactflow";
import "reactflow/dist/style.css";
import { ArrowLeft, Plus, Save, Play, Settings, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { useToast } from "@/hooks/use-toast";
import {
getFlowDefinition,
getFlowSteps,
getFlowConnections,
createFlowStep,
updateFlowStep,
deleteFlowStep,
createFlowConnection,
deleteFlowConnection,
getAllStepCounts,
} from "@/lib/api/flow";
import { FlowDefinition, FlowStep, FlowStepConnection, FlowNodeData } from "@/types/flow";
import { FlowNodeComponent } from "@/components/flow/FlowNodeComponent";
import { FlowStepPanel } from "@/components/flow/FlowStepPanel";
import { FlowConditionBuilder } from "@/components/flow/FlowConditionBuilder";
// 커스텀 노드 타입 등록
const nodeTypes = {
flowStep: FlowNodeComponent,
};
export default function FlowEditorPage() {
const params = useParams();
const router = useRouter();
const { toast } = useToast();
const flowId = Number(params.id);
// 상태
const [flowDefinition, setFlowDefinition] = useState<FlowDefinition | null>(null);
const [steps, setSteps] = useState<FlowStep[]>([]);
const [connections, setConnections] = useState<FlowStepConnection[]>([]);
const [selectedStep, setSelectedStep] = useState<FlowStep | null>(null);
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
const [loading, setLoading] = useState(true);
// React Flow 상태
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
// 플로우 데이터 로드
const loadFlowData = async () => {
setLoading(true);
try {
// 플로우 정의 로드
const flowRes = await getFlowDefinition(flowId);
if (flowRes.success && flowRes.data) {
setFlowDefinition(flowRes.data);
}
// 단계 로드
const stepsRes = await getFlowSteps(flowId);
if (stepsRes.success && stepsRes.data) {
setSteps(stepsRes.data);
}
// 연결 로드
const connectionsRes = await getFlowConnections(flowId);
if (connectionsRes.success && connectionsRes.data) {
setConnections(connectionsRes.data);
}
// 데이터 카운트 로드
const countsRes = await getAllStepCounts(flowId);
if (countsRes.success && countsRes.data) {
const counts: Record<number, number> = {};
countsRes.data.forEach((item) => {
counts[item.stepId] = item.count;
});
setStepCounts(counts);
}
} catch (error: any) {
toast({
title: "로딩 실패",
description: error.message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFlowData();
}, [flowId]);
// React Flow 노드/엣지 변환
useEffect(() => {
if (steps.length === 0) return;
// 노드 생성
const newNodes: Node<FlowNodeData>[] = steps.map((step) => ({
id: String(step.id),
type: "flowStep",
position: { x: step.positionX, y: step.positionY },
data: {
id: step.id,
label: step.stepName,
stepOrder: step.stepOrder,
tableName: step.tableName,
count: stepCounts[step.id] || 0,
condition: step.conditionJson,
},
}));
// 엣지 생성
const newEdges: Edge[] = connections.map((conn) => ({
id: String(conn.id),
source: String(conn.fromStepId),
target: String(conn.toStepId),
label: conn.label,
type: "smoothstep",
animated: true,
}));
setNodes(newNodes);
setEdges(newEdges);
}, [steps, connections, stepCounts]);
// 노드 추가
const handleAddStep = async () => {
const newStepOrder = steps.length + 1;
const newStep = {
stepName: `단계 ${newStepOrder}`,
stepOrder: newStepOrder,
color: "#3B82F6",
positionX: 100 + newStepOrder * 250,
positionY: 100,
};
try {
const response = await createFlowStep(flowId, newStep);
if (response.success && response.data) {
toast({
title: "단계 추가",
description: "새로운 단계가 추가되었습니다.",
});
loadFlowData();
}
} catch (error: any) {
toast({
title: "추가 실패",
description: error.message,
variant: "destructive",
});
}
};
// 노드 위치 업데이트
const handleNodeDragStop = useCallback(
async (event: any, node: Node) => {
const step = steps.find((s) => s.id === Number(node.id));
if (!step) return;
try {
await updateFlowStep(step.id, {
positionX: Math.round(node.position.x),
positionY: Math.round(node.position.y),
});
} catch (error: any) {
console.error("위치 업데이트 실패:", error);
}
},
[steps],
);
// 연결 생성
const handleConnect = useCallback(
async (connection: Connection) => {
if (!connection.source || !connection.target) return;
try {
const response = await createFlowConnection({
flowDefinitionId: flowId,
fromStepId: Number(connection.source),
toStepId: Number(connection.target),
});
if (response.success) {
toast({
title: "연결 생성",
description: "단계가 연결되었습니다.",
});
loadFlowData();
}
} catch (error: any) {
toast({
title: "연결 실패",
description: error.message,
variant: "destructive",
});
}
},
[flowId],
);
// 노드 클릭
const handleNodeClick = useCallback(
(event: React.MouseEvent, node: Node) => {
const step = steps.find((s) => s.id === Number(node.id));
if (step) {
setSelectedStep(step);
}
},
[steps],
);
if (loading) {
return (
<div className="container mx-auto p-6">
<p> ...</p>
</div>
);
}
if (!flowDefinition) {
return (
<div className="container mx-auto p-6">
<p> .</p>
</div>
);
}
return (
<div className="flex h-screen flex-col">
{/* 헤더 */}
<div className="border-b bg-white p-4">
<div className="container mx-auto flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={() => router.push("/admin/flow-management")}>
<ArrowLeft className="mr-2 h-4 w-4" />
</Button>
<div>
<h1 className="text-xl font-bold">{flowDefinition.name}</h1>
<p className="text-muted-foreground text-sm">: {flowDefinition.tableName}</p>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={handleAddStep}>
<Plus className="mr-2 h-4 w-4" />
</Button>
<Button size="sm" onClick={() => loadFlowData()}>
<Save className="mr-2 h-4 w-4" />
</Button>
</div>
</div>
</div>
{/* 편집기 */}
<div className="relative flex-1">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={handleConnect}
onNodeClick={handleNodeClick}
onNodeDragStop={handleNodeDragStop}
nodeTypes={nodeTypes}
fitView
className="bg-gray-50"
>
<Background />
<Controls />
<MiniMap />
<Panel position="top-right" className="rounded bg-white p-4 shadow">
<div className="space-y-2 text-sm">
<div>
<strong> :</strong> {steps.length}
</div>
<div>
<strong>:</strong> {connections.length}
</div>
</div>
</Panel>
</ReactFlow>
</div>
{/* 사이드 패널 */}
{selectedStep && (
<FlowStepPanel
step={selectedStep}
flowId={flowId}
onClose={() => setSelectedStep(null)}
onUpdate={loadFlowData}
/>
)}
</div>
);
}

View File

@ -1,360 +0,0 @@
"use client";
/**
*
* -
* - //
* -
*/
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow";
import { FlowDefinition } from "@/types/flow";
export default function FlowManagementPage() {
const router = useRouter();
const { toast } = useToast();
// 상태
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null);
// 생성 폼 상태
const [formData, setFormData] = useState({
name: "",
description: "",
tableName: "",
});
// 플로우 목록 조회
const loadFlows = async () => {
setLoading(true);
try {
const response = await getFlowDefinitions({ isActive: true });
if (response.success && response.data) {
setFlows(response.data);
} else {
toast({
title: "조회 실패",
description: response.error || "플로우 목록을 불러올 수 없습니다.",
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFlows();
}, []);
// 플로우 생성
const handleCreate = async () => {
if (!formData.name || !formData.tableName) {
toast({
title: "입력 오류",
description: "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive",
});
return;
}
try {
const response = await createFlowDefinition(formData);
if (response.success && response.data) {
toast({
title: "생성 완료",
description: "플로우가 성공적으로 생성되었습니다.",
});
setIsCreateDialogOpen(false);
setFormData({ name: "", description: "", tableName: "" });
loadFlows();
} else {
toast({
title: "생성 실패",
description: response.error || response.message,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
};
// 플로우 삭제
const handleDelete = async () => {
if (!selectedFlow) return;
try {
const response = await deleteFlowDefinition(selectedFlow.id);
if (response.success) {
toast({
title: "삭제 완료",
description: "플로우가 삭제되었습니다.",
});
setIsDeleteDialogOpen(false);
setSelectedFlow(null);
loadFlows();
} else {
toast({
title: "삭제 실패",
description: response.error,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
};
// 플로우 편집기로 이동
const handleEdit = (flowId: number) => {
router.push(`/admin/flow-management/${flowId}`);
};
return (
<div className="container mx-auto space-y-4 p-3 sm:space-y-6 sm:p-4 lg:p-6">
{/* 헤더 */}
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
<div className="flex-1">
<h1 className="flex items-center gap-2 text-xl font-bold sm:text-2xl lg:text-3xl">
<Workflow className="h-6 w-6 sm:h-7 sm:w-7 lg:h-8 lg:w-8" />
</h1>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"> </p>
</div>
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
<span className="hidden sm:inline"> </span>
<span className="sm:hidden"></span>
</Button>
</div>
{/* 플로우 카드 목록 */}
{loading ? (
<div className="py-8 text-center sm:py-12">
<p className="text-muted-foreground text-sm sm:text-base"> ...</p>
</div>
) : flows.length === 0 ? (
<Card>
<CardContent className="py-8 text-center sm:py-12">
<Workflow className="text-muted-foreground mx-auto mb-3 h-10 w-10 sm:mb-4 sm:h-12 sm:w-12" />
<p className="text-muted-foreground mb-3 text-sm sm:mb-4 sm:text-base"> </p>
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
<Plus className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 gap-4 sm:gap-5 md:grid-cols-2 lg:gap-6 xl:grid-cols-3">
{flows.map((flow) => (
<Card
key={flow.id}
className="cursor-pointer transition-shadow hover:shadow-lg"
onClick={() => handleEdit(flow.id)}
>
<CardHeader className="p-4 sm:p-6">
<div className="flex items-start justify-between">
<div className="min-w-0 flex-1">
<CardTitle className="flex flex-col gap-1 text-base sm:flex-row sm:items-center sm:gap-2 sm:text-lg">
<span className="truncate">{flow.name}</span>
{flow.isActive && (
<Badge variant="success" className="self-start">
</Badge>
)}
</CardTitle>
<CardDescription className="mt-1 line-clamp-2 text-xs sm:mt-2 sm:text-sm">
{flow.description || "설명 없음"}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 sm:p-6">
<div className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm">
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<Table className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">{flow.tableName}</span>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<User className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span className="truncate">: {flow.createdBy}</span>
</div>
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
<Calendar className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
<span>{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}</span>
</div>
</div>
<div className="mt-3 flex gap-2 sm:mt-4">
<Button
variant="outline"
size="sm"
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
onClick={(e) => {
e.stopPropagation();
handleEdit(flow.id);
}}
>
<Edit2 className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
</Button>
<Button
variant="outline"
size="sm"
className="h-8 px-2 text-xs sm:h-9 sm:px-3 sm:text-sm"
onClick={(e) => {
e.stopPropagation();
setSelectedFlow(flow);
setIsDeleteDialogOpen(true);
}}
>
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* 생성 다이얼로그 */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
</DialogDescription>
</DialogHeader>
<div className="space-y-3 sm:space-y-4">
<div>
<Label htmlFor="name" className="text-xs sm:text-sm">
*
</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 제품 수명주기 관리"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
</div>
<div>
<Label htmlFor="tableName" className="text-xs sm:text-sm">
*
</Label>
<Input
id="tableName"
value={formData.tableName}
onChange={(e) => setFormData({ ...formData, tableName: e.target.value })}
placeholder="예: products"
className="h-8 text-xs sm:h-10 sm:text-sm"
/>
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
</p>
</div>
<div>
<Label htmlFor="description" className="text-xs sm:text-sm">
</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="플로우에 대한 설명을 입력하세요"
rows={3}
className="text-xs sm:text-sm"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button onClick={handleCreate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 삭제 확인 다이얼로그 */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-base sm:text-lg"> </DialogTitle>
<DialogDescription className="text-xs sm:text-sm">
"{selectedFlow?.name}" ?
<br /> .
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:gap-0">
<Button
variant="outline"
onClick={() => {
setIsDeleteDialogOpen(false);
setSelectedFlow(null);
}}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
<Button
variant="destructive"
onClick={handleDelete}
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@ -1,73 +0,0 @@
"use client";
import React from "react";
import { FlowWidget } from "@/components/screen/widgets/FlowWidget";
import { FlowComponent } from "@/types/screen-management";
/**
*
* (ID: 8)
*/
export default function TestFlowPage() {
// 문서 승인 플로우
const documentFlow: FlowComponent = {
id: "test-flow-1",
type: "flow",
flowId: 8, // 문서 승인 플로우
flowName: "문서 승인 플로우",
showStepCount: true,
allowDataMove: true,
displayMode: "horizontal",
position: { x: 0, y: 0 },
size: { width: 1200, height: 600 },
style: {},
};
// 작업 요청 워크플로우
const workRequestFlow: FlowComponent = {
id: "test-flow-2",
type: "flow",
flowId: 12, // 작업 요청 워크플로우
flowName: "작업 요청 워크플로우",
showStepCount: true,
allowDataMove: true,
displayMode: "horizontal",
position: { x: 0, y: 0 },
size: { width: 1200, height: 600 },
style: {},
};
return (
<div className="min-h-screen bg-gray-50 p-8">
<div className="mx-auto max-w-7xl space-y-8">
{/* 헤더 */}
<div>
<h1 className="text-3xl font-bold text-gray-900"> </h1>
<p className="mt-2 text-gray-600"> </p>
</div>
{/* 문서 승인 플로우 */}
<div className="rounded-lg bg-white p-6 shadow-lg">
<h2 className="mb-4 text-xl font-semibold text-gray-800"> (4)</h2>
<FlowWidget component={documentFlow} />
</div>
{/* 작업 요청 워크플로우 */}
<div className="rounded-lg bg-white p-6 shadow-lg">
<h2 className="mb-4 text-xl font-semibold text-gray-800"> (6)</h2>
<FlowWidget component={workRequestFlow} />
</div>
{/* 사용 안내 */}
<div className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-6">
<h3 className="mb-2 text-lg font-semibold text-blue-900"> </h3>
<ul className="list-inside list-disc space-y-1 text-blue-800">
<li> </li>
<li> "다음 단계로 이동" </li>
<li> </li>
</ul>
</div>
</div>
</div>
);
}

View File

@ -1,266 +0,0 @@
/**
*
* UI
*/
import { useState, useEffect } from "react";
import { Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { FlowConditionGroup, FlowCondition, ConditionOperator } from "@/types/flow";
import { getTableColumns } from "@/lib/api/tableManagement";
interface FlowConditionBuilderProps {
flowId: number;
tableName?: string; // 조회할 테이블명
condition?: FlowConditionGroup;
onChange: (condition: FlowConditionGroup | undefined) => void;
}
const OPERATORS: { value: ConditionOperator; label: string }[] = [
{ value: "equals", label: "같음 (=)" },
{ value: "not_equals", label: "같지 않음 (!=)" },
{ value: "greater_than", label: "보다 큼 (>)" },
{ value: "less_than", label: "보다 작음 (<)" },
{ value: "greater_than_or_equal", label: "이상 (>=)" },
{ value: "less_than_or_equal", label: "이하 (<=)" },
{ value: "in", label: "포함 (IN)" },
{ value: "not_in", label: "제외 (NOT IN)" },
{ value: "like", label: "유사 (LIKE)" },
{ value: "not_like", label: "유사하지 않음 (NOT LIKE)" },
{ value: "is_null", label: "NULL" },
{ value: "is_not_null", label: "NOT NULL" },
];
export function FlowConditionBuilder({ flowId, tableName, condition, onChange }: FlowConditionBuilderProps) {
const [columns, setColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [conditionType, setConditionType] = useState<"AND" | "OR">(condition?.type || "AND");
const [conditions, setConditions] = useState<FlowCondition[]>(condition?.conditions || []);
// condition prop이 변경될 때 상태 동기화
useEffect(() => {
if (condition) {
setConditionType(condition.type || "AND");
setConditions(condition.conditions || []);
} else {
setConditionType("AND");
setConditions([]);
}
}, [condition]);
// 테이블 컬럼 로드
useEffect(() => {
if (!tableName) {
setColumns([]);
return;
}
const loadColumns = async () => {
try {
setLoadingColumns(true);
console.log("🔍 Loading columns for table:", tableName);
const response = await getTableColumns(tableName);
console.log("📦 Column API response:", response);
if (response.success && response.data?.columns) {
const columnArray = Array.isArray(response.data.columns) ? response.data.columns : [];
console.log("✅ Setting columns:", columnArray.length, "items");
setColumns(columnArray);
} else {
console.error("❌ Failed to load columns:", response.message);
setColumns([]);
}
} catch (error) {
console.error("❌ Exception loading columns:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [tableName]);
// 조건 변경 시 부모에 전달
useEffect(() => {
if (conditions.length === 0) {
onChange(undefined);
} else {
onChange({
type: conditionType,
conditions,
});
}
}, [conditionType, conditions]);
// 조건 추가
const addCondition = () => {
setConditions([
...conditions,
{
column: "",
operator: "equals",
value: "",
},
]);
};
// 조건 수정
const updateCondition = (index: number, field: keyof FlowCondition, value: any) => {
const newConditions = [...conditions];
newConditions[index] = {
...newConditions[index],
[field]: value,
};
setConditions(newConditions);
};
// 조건 삭제
const removeCondition = (index: number) => {
setConditions(conditions.filter((_, i) => i !== index));
};
// value가 필요 없는 연산자 체크
const needsValue = (operator: ConditionOperator) => {
return operator !== "is_null" && operator !== "is_not_null";
};
return (
<div className="space-y-4">
{/* 조건 타입 선택 */}
<div>
<Label> </Label>
<Select value={conditionType} onValueChange={(value) => setConditionType(value as "AND" | "OR")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="AND">AND ( )</SelectItem>
<SelectItem value="OR">OR ( )</SelectItem>
</SelectContent>
</Select>
</div>
{/* 조건 목록 */}
<div className="space-y-3">
{conditions.length === 0 ? (
<div className="text-muted-foreground rounded border-2 border-dashed py-4 text-center text-sm">
<br />
</div>
) : (
conditions.map((cond, index) => (
<div key={index} className="space-y-2 rounded border bg-gray-50 p-3">
{/* 조건 번호 및 삭제 버튼 */}
<div className="flex items-center justify-between">
<Badge variant="outline"> {index + 1}</Badge>
<Button variant="ghost" size="sm" onClick={() => removeCondition(index)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* 컬럼 선택 */}
<div>
<Label className="text-xs"></Label>
{loadingColumns ? (
<Input value="컬럼 로딩 중..." disabled className="h-8" />
) : !Array.isArray(columns) || columns.length === 0 ? (
<Input
value={cond.column}
onChange={(e) => updateCondition(index, "column", e.target.value)}
placeholder="테이블을 먼저 선택하세요"
className="h-8"
/>
) : (
<Select value={cond.column} onValueChange={(value) => updateCondition(index, "column", value)}>
<SelectTrigger className="h-8">
<SelectValue placeholder="컬럼 선택" />
</SelectTrigger>
<SelectContent>
{columns.map((col) => (
<SelectItem key={col.columnName} value={col.columnName}>
<div className="flex items-center gap-2">
<span className="font-medium">{col.displayName || col.columnName}</span>
<span className="text-xs text-gray-500">({col.dataType})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* 연산자 선택 */}
<div>
<Label className="text-xs"></Label>
<Select
value={cond.operator}
onValueChange={(value) => updateCondition(index, "operator", value as ConditionOperator)}
>
<SelectTrigger className="h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATORS.map((op) => (
<SelectItem key={op.value} value={op.value}>
{op.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 값 입력 */}
{needsValue(cond.operator) && (
<div>
<Label className="text-xs"></Label>
<Input
value={cond.value || ""}
onChange={(e) => updateCondition(index, "value", e.target.value)}
placeholder="값 입력"
className="h-8"
/>
{(cond.operator === "in" || cond.operator === "not_in") && (
<p className="text-muted-foreground mt-1 text-xs">(,) </p>
)}
</div>
)}
</div>
))
)}
</div>
{/* 조건 추가 버튼 */}
<Button variant="outline" size="sm" onClick={addCondition} className="w-full">
<Plus className="mr-2 h-4 w-4" />
</Button>
{/* 조건 요약 */}
{conditions.length > 0 && (
<div className="rounded bg-blue-50 p-3 text-sm">
<strong> :</strong>
<div className="mt-2 space-y-1">
{conditions.map((cond, index) => (
<div key={index} className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">
{cond.column}
</Badge>
<span className="text-muted-foreground">
{OPERATORS.find((op) => op.value === cond.operator)?.label}
</span>
{needsValue(cond.operator) && <code className="rounded bg-white px-2 py-1 text-xs">{cond.value}</code>}
{index < conditions.length - 1 && <Badge>{conditionType}</Badge>}
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1,221 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2, AlertCircle, ArrowRight } from "lucide-react";
import { getStepDataList, moveDataToNextStep } from "@/lib/api/flow";
import { toast } from "sonner";
interface FlowDataListModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
flowId: number;
stepId: number;
stepName: string;
allowDataMove?: boolean;
onDataMoved?: () => void; // 데이터 이동 후 리프레시
}
export function FlowDataListModal({
open,
onOpenChange,
flowId,
stepId,
stepName,
allowDataMove = false,
onDataMoved,
}: FlowDataListModalProps) {
const [data, setData] = useState<any[]>([]);
const [columns, setColumns] = useState<string[]>([]);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [movingData, setMovingData] = useState(false);
// 데이터 조회
useEffect(() => {
if (!open) return;
const loadData = async () => {
try {
setLoading(true);
setError(null);
setSelectedRows(new Set());
const response = await getStepDataList(flowId, stepId, 1, 100);
if (!response.success) {
throw new Error(response.message || "데이터를 불러올 수 없습니다");
}
const rows = response.data?.records || [];
setData(rows);
// 컬럼 추출 (첫 번째 행에서)
if (rows.length > 0) {
setColumns(Object.keys(rows[0]));
} else {
setColumns([]);
}
} catch (err: any) {
console.error("Failed to load flow data:", err);
setError(err.message || "데이터를 불러오는데 실패했습니다");
} finally {
setLoading(false);
}
};
loadData();
}, [open, flowId, stepId]);
// 전체 선택/해제
const toggleAllSelection = () => {
if (selectedRows.size === data.length) {
setSelectedRows(new Set());
} else {
setSelectedRows(new Set(data.map((_, index) => index)));
}
};
// 개별 행 선택/해제
const toggleRowSelection = (index: number) => {
const newSelected = new Set(selectedRows);
if (newSelected.has(index)) {
newSelected.delete(index);
} else {
newSelected.add(index);
}
setSelectedRows(newSelected);
};
// 선택된 데이터 이동
const handleMoveData = async () => {
if (selectedRows.size === 0) {
toast.error("이동할 데이터를 선택해주세요");
return;
}
try {
setMovingData(true);
// 선택된 행의 ID 추출 (가정: 각 행에 'id' 필드가 있음)
const selectedDataIds = Array.from(selectedRows).map((index) => data[index].id);
// 데이터 이동 API 호출
for (const dataId of selectedDataIds) {
const response = await moveDataToNextStep(flowId, stepId, dataId);
if (!response.success) {
throw new Error(`데이터 이동 실패: ${response.message}`);
}
}
toast.success(`${selectedRows.size}건의 데이터를 다음 단계로 이동했습니다`);
// 모달 닫고 리프레시
onOpenChange(false);
onDataMoved?.();
} catch (err: any) {
console.error("Failed to move data:", err);
toast.error(err.message || "데이터 이동에 실패했습니다");
} finally {
setMovingData(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] max-w-4xl flex-col overflow-hidden">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{stepName}
<Badge variant="secondary">{data.length}</Badge>
</DialogTitle>
<DialogDescription> </DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : error ? (
<div className="border-destructive/50 bg-destructive/10 flex items-center gap-2 rounded-lg border p-4">
<AlertCircle className="text-destructive h-5 w-5" />
<span className="text-destructive text-sm">{error}</span>
</div>
) : data.length === 0 ? (
<div className="text-muted-foreground flex items-center justify-center py-12 text-sm">
</div>
) : (
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
{allowDataMove && (
<TableHead className="w-12">
<Checkbox
checked={selectedRows.size === data.length && data.length > 0}
onCheckedChange={toggleAllSelection}
/>
</TableHead>
)}
{columns.map((col) => (
<TableHead key={col}>{col}</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{data.map((row, index) => (
<TableRow key={index}>
{allowDataMove && (
<TableCell>
<Checkbox
checked={selectedRows.has(index)}
onCheckedChange={() => toggleRowSelection(index)}
/>
</TableCell>
)}
{columns.map((col) => (
<TableCell key={col}>
{row[col] !== null && row[col] !== undefined ? String(row[col]) : "-"}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
<div className="flex justify-between border-t pt-4">
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
{allowDataMove && data.length > 0 && (
<Button onClick={handleMoveData} disabled={selectedRows.size === 0 || movingData}>
{movingData ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<ArrowRight className="mr-2 h-4 w-4" />
({selectedRows.size})
</>
)}
</Button>
)}
</div>
</DialogContent>
</Dialog>
);
}

View File

@ -1,104 +0,0 @@
/**
*
* React Flow
*/
import { memo } from "react";
import { Handle, Position, NodeProps } from "reactflow";
import { FlowNodeData, FlowCondition, ConditionOperator } from "@/types/flow";
import { Badge } from "@/components/ui/badge";
// 조건을 자연어로 변환하는 헬퍼 함수
const formatCondition = (cond: FlowCondition): string => {
const operatorLabels: Record<ConditionOperator, string> = {
equals: "=",
not_equals: "≠",
greater_than: ">",
less_than: "<",
greater_than_or_equal: "≥",
less_than_or_equal: "≤",
in: "IN",
not_in: "NOT IN",
like: "LIKE",
not_like: "NOT LIKE",
is_null: "IS NULL",
is_not_null: "IS NOT NULL",
};
const operatorLabel = operatorLabels[cond.operator] || cond.operator;
if (cond.operator === "is_null" || cond.operator === "is_not_null") {
return `${cond.column} ${operatorLabel}`;
}
return `${cond.column} ${operatorLabel} "${cond.value || ""}"`;
};
const formatAllConditions = (data: FlowNodeData): string => {
if (!data.condition || data.condition.conditions.length === 0) {
return "조건 없음";
}
const conditions = data.condition.conditions;
const type = data.condition.type;
// 조건이 많으면 간략하게 표시
if (conditions.length > 2) {
return `${conditions.length}개 조건 (${type})`;
}
const connector = type === "AND" ? " AND " : " OR ";
return conditions.map(formatCondition).join(connector);
};
export const FlowNodeComponent = memo(({ data }: NodeProps<FlowNodeData>) => {
return (
<div className="bg-card min-w-[200px] rounded-lg border px-4 py-3 shadow-sm transition-shadow hover:shadow-md">
{/* 입력 핸들 */}
<Handle type="target" position={Position.Left} className="border-primary bg-background h-3 w-3 border-2" />
{/* 노드 내용 */}
<div>
<div className="mb-2 flex items-center justify-between gap-2">
<Badge variant="outline" className="text-xs">
{data.stepOrder}
</Badge>
</div>
<div className="text-foreground mb-2 text-sm font-semibold">{data.label}</div>
{/* 테이블 정보 */}
{data.tableName && (
<div className="bg-muted text-muted-foreground mb-2 flex items-center gap-1 rounded-md px-2 py-1 text-xs">
<span>📊</span>
<span className="truncate">{data.tableName}</span>
</div>
)}
{/* 데이터 건수 */}
{data.count !== undefined && (
<Badge variant="secondary" className="mb-2 text-xs">
{data.count}
</Badge>
)}
{/* 조건 미리보기 */}
{data.condition && data.condition.conditions.length > 0 ? (
<div className="mt-2">
<div className="text-muted-foreground mb-1 text-xs font-medium">:</div>
<div className="text-muted-foreground text-xs break-words" style={{ lineHeight: "1.4" }}>
{formatAllConditions(data)}
</div>
</div>
) : (
<div className="text-muted-foreground mt-2 text-xs"> </div>
)}
</div>
{/* 출력 핸들 */}
<Handle type="source" position={Position.Right} className="border-primary bg-background h-3 w-3 border-2" />
</div>
);
});
FlowNodeComponent.displayName = "FlowNodeComponent";

View File

@ -1,888 +0,0 @@
/**
*
*
*/
import { useState, useEffect, useCallback, useRef } from "react";
import { X, Trash2, Save, Check, ChevronsUpDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { useToast } from "@/hooks/use-toast";
import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow";
import { FlowStep } from "@/types/flow";
import { FlowConditionBuilder } from "./FlowConditionBuilder";
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
import { cn } from "@/lib/utils";
import { flowExternalDbApi } from "@/lib/api/flowExternalDb";
import {
FlowExternalDbConnection,
FlowExternalDbIntegrationConfig,
INTEGRATION_TYPE_OPTIONS,
OPERATION_OPTIONS,
} from "@/types/flowExternalDb";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
interface FlowStepPanelProps {
step: FlowStep;
flowId: number;
onClose: () => void;
onUpdate: () => void;
}
export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanelProps) {
const { toast } = useToast();
const [formData, setFormData] = useState({
stepName: step.stepName,
tableName: step.tableName || "",
conditionJson: step.conditionJson,
// 하이브리드 모드 필드
moveType: step.moveType || "status",
statusColumn: step.statusColumn || "",
statusValue: step.statusValue || "",
targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {},
// 외부 연동 필드
integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
});
const [tableList, setTableList] = useState<any[]>([]);
const [loadingTables, setLoadingTables] = useState(true);
const [openTableCombobox, setOpenTableCombobox] = useState(false);
// 외부 DB 테이블 목록
const [externalTableList, setExternalTableList] = useState<string[]>([]);
const [loadingExternalTables, setLoadingExternalTables] = useState(false);
const [selectedDbSource, setSelectedDbSource] = useState<"internal" | number>("internal"); // "internal" 또는 외부 DB connection ID
// 컬럼 목록 (상태 컬럼 선택용)
const [columns, setColumns] = useState<any[]>([]);
const [loadingColumns, setLoadingColumns] = useState(false);
const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false);
// 외부 DB 연결 목록
const [externalConnections, setExternalConnections] = useState<FlowExternalDbConnection[]>([]);
const [loadingConnections, setLoadingConnections] = useState(false);
// 테이블 목록 조회
useEffect(() => {
const loadTables = async () => {
try {
setLoadingTables(true);
const response = await tableManagementApi.getTableList();
if (response.success && response.data) {
setTableList(response.data);
}
} catch (error) {
console.error("Failed to load tables:", error);
} finally {
setLoadingTables(false);
}
};
loadTables();
}, []);
// 외부 DB 연결 목록 조회 (JWT 토큰 사용)
useEffect(() => {
const loadConnections = async () => {
try {
setLoadingConnections(true);
// localStorage에서 JWT 토큰 가져오기
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("토큰이 없습니다. 외부 DB 연결 목록을 조회할 수 없습니다.");
setExternalConnections([]);
return;
}
const response = await fetch("/api/external-db-connections/control/active", {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).catch((err) => {
console.warn("외부 DB 연결 목록 fetch 실패:", err);
return null;
});
if (response && response.ok) {
const result = await response.json();
if (result.success && result.data) {
// 메인 DB 제외하고 외부 DB만 필터링
const externalOnly = result.data.filter((conn: any) => conn.id !== 0);
setExternalConnections(externalOnly);
} else {
setExternalConnections([]);
}
} else {
// 401 오류 시 빈 배열로 처리 (리다이렉트 방지)
console.warn("외부 DB 연결 목록 조회 실패:", response?.status || "네트워크 오류");
setExternalConnections([]);
}
} catch (error: any) {
console.error("Failed to load external connections:", error);
setExternalConnections([]);
} finally {
setLoadingConnections(false);
}
};
loadConnections();
}, []);
// 외부 DB 선택 시 해당 DB의 테이블 목록 조회 (JWT 토큰 사용)
useEffect(() => {
const loadExternalTables = async () => {
console.log("🔍 loadExternalTables triggered, selectedDbSource:", selectedDbSource);
if (selectedDbSource === "internal" || typeof selectedDbSource !== "number") {
console.log("⚠️ Skipping external table load (internal or not a number)");
setExternalTableList([]);
return;
}
console.log("📡 Loading external tables for connection ID:", selectedDbSource);
try {
setLoadingExternalTables(true);
// localStorage에서 JWT 토큰 가져오기
const token = localStorage.getItem("authToken");
if (!token) {
console.warn("토큰이 없습니다. 외부 DB 테이블 목록을 조회할 수 없습니다.");
setExternalTableList([]);
return;
}
// 기존 multi-connection API 사용 (JWT 토큰 포함)
const response = await fetch(`/api/multi-connection/connections/${selectedDbSource}/tables`, {
credentials: "include",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
}).catch((err) => {
console.warn("외부 DB 테이블 목록 fetch 실패:", err);
return null;
});
if (response && response.ok) {
const result = await response.json();
console.log("✅ External tables API response:", result);
console.log("📊 result.data type:", typeof result.data, "isArray:", Array.isArray(result.data));
console.log("📊 result.data:", JSON.stringify(result.data, null, 2));
if (result.success && result.data) {
// 데이터 형식이 다를 수 있으므로 변환
const tableNames = result.data.map((t: any) => {
console.log("🔍 Processing item:", t, "type:", typeof t);
// tableName (camelCase), table_name, tablename, name 모두 지원
return typeof t === "string" ? t : t.tableName || t.table_name || t.tablename || t.name;
});
console.log("📋 Processed table names:", tableNames);
setExternalTableList(tableNames);
} else {
console.warn("❌ No data in response or success=false");
setExternalTableList([]);
}
} else {
// 인증 오류 시에도 빈 배열로 처리 (리다이렉트 방지)
console.warn(`외부 DB 테이블 목록 조회 실패: ${response?.status || "네트워크 오류"}`);
setExternalTableList([]);
}
} catch (error) {
console.error("외부 DB 테이블 목록 조회 오류:", error);
setExternalTableList([]);
} finally {
setLoadingExternalTables(false);
}
};
loadExternalTables();
}, [selectedDbSource]);
useEffect(() => {
console.log("🔄 Initializing formData from step:", {
id: step.id,
stepName: step.stepName,
statusColumn: step.statusColumn,
statusValue: step.statusValue,
});
const newFormData = {
stepName: step.stepName,
tableName: step.tableName || "",
conditionJson: step.conditionJson,
// 하이브리드 모드 필드
moveType: step.moveType || "status",
statusColumn: step.statusColumn || "",
statusValue: step.statusValue || "",
targetTable: step.targetTable || "",
fieldMappings: step.fieldMappings || {},
// 외부 연동 필드
integrationType: step.integrationType || "internal",
integrationConfig: step.integrationConfig,
};
console.log("✅ Setting formData:", newFormData);
setFormData(newFormData);
}, [step.id]); // step 전체가 아닌 step.id만 의존성으로 설정
// 테이블 선택 시 컬럼 로드
useEffect(() => {
const loadColumns = async () => {
if (!formData.tableName) {
setColumns([]);
return;
}
try {
setLoadingColumns(true);
console.log("🔍 Loading columns for status column selector:", formData.tableName);
const response = await getTableColumns(formData.tableName);
console.log("📦 Columns response:", response);
if (response.success && response.data && response.data.columns) {
console.log("✅ Setting columns:", response.data.columns);
setColumns(response.data.columns);
} else {
console.log("❌ No columns in response");
setColumns([]);
}
} catch (error) {
console.error("Failed to load columns:", error);
setColumns([]);
} finally {
setLoadingColumns(false);
}
};
loadColumns();
}, [formData.tableName]);
// formData의 최신 값을 항상 참조하기 위한 ref
const formDataRef = useRef(formData);
// formData가 변경될 때마다 ref 업데이트
useEffect(() => {
formDataRef.current = formData;
}, [formData]);
// 저장
const handleSave = useCallback(async () => {
const currentFormData = formDataRef.current;
console.log("🚀 handleSave called, formData:", JSON.stringify(currentFormData, null, 2));
try {
const response = await updateFlowStep(step.id, currentFormData);
console.log("📡 API response:", response);
if (response.success) {
toast({
title: "저장 완료",
description: "단계가 수정되었습니다.",
});
onUpdate();
onClose();
} else {
toast({
title: "저장 실패",
description: response.error,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
}, [step.id, onUpdate, onClose, toast]);
// 삭제
const handleDelete = async () => {
if (!confirm(`"${step.stepName}" 단계를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.`)) {
return;
}
try {
const response = await deleteFlowStep(step.id);
if (response.success) {
toast({
title: "삭제 완료",
description: "단계가 삭제되었습니다.",
});
onUpdate();
onClose();
} else {
toast({
title: "삭제 실패",
description: response.error,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
};
return (
<div className="fixed top-0 right-0 z-50 h-full w-96 overflow-y-auto border-l bg-white shadow-xl">
<div className="space-y-6 p-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-xl font-bold"> </h2>
<Button variant="ghost" size="sm" onClick={onClose}>
<X className="h-4 w-4" />
</Button>
</div>
{/* 기본 정보 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Input
value={formData.stepName}
onChange={(e) => setFormData({ ...formData, stepName: e.target.value })}
placeholder="단계 이름 입력"
/>
</div>
<div>
<Label> </Label>
<Input value={step.stepOrder} disabled />
</div>
{/* DB 소스 선택 */}
<div>
<Label> </Label>
<Select
value={selectedDbSource.toString()}
onValueChange={(value) => {
const dbSource = value === "internal" ? "internal" : parseInt(value);
setSelectedDbSource(dbSource);
// DB 소스 변경 시 테이블 선택 초기화
setFormData({ ...formData, tableName: "" });
}}
>
<SelectTrigger>
<SelectValue placeholder="데이터베이스 선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="internal"> </SelectItem>
{externalConnections.map((conn: any) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.connection_name} ({conn.db_type?.toUpperCase()})
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
{/* 테이블 선택 */}
<div>
<Label> </Label>
<Popover open={openTableCombobox} onOpenChange={setOpenTableCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openTableCombobox}
className="w-full justify-between"
disabled={loadingTables || (selectedDbSource !== "internal" && loadingExternalTables)}
>
{formData.tableName
? selectedDbSource === "internal"
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
formData.tableName
: formData.tableName
: loadingTables || loadingExternalTables
? "로딩 중..."
: "테이블 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
<Command>
<CommandInput placeholder="테이블 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{selectedDbSource === "internal"
? // 내부 DB 테이블 목록
tableList.map((table) => (
<CommandItem
key={table.tableName}
value={table.tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === table.tableName ? "opacity-100" : "opacity-0",
)}
/>
<div className="flex flex-col">
<span className="font-medium">{table.displayName || table.tableName}</span>
{table.description && (
<span className="text-xs text-gray-500">{table.description}</span>
)}
</div>
</CommandItem>
))
: // 외부 DB 테이블 목록 (문자열 배열)
externalTableList.map((tableName, index) => (
<CommandItem
key={`external-${selectedDbSource}-${tableName}-${index}`}
value={tableName}
onSelect={(currentValue) => {
setFormData({ ...formData, tableName: currentValue });
setOpenTableCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.tableName === tableName ? "opacity-100" : "opacity-0",
)}
/>
<div>{tableName}</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500">
{selectedDbSource === "internal"
? "이 단계에서 조건을 적용할 테이블을 선택합니다"
: "외부 데이터베이스의 테이블을 선택합니다"}
</p>
</div>
</CardContent>
</Card>
{/* 조건 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent>
{!formData.tableName ? (
<div className="py-8 text-center text-gray-500"> </div>
) : (
<FlowConditionBuilder
flowId={flowId}
tableName={formData.tableName}
condition={formData.conditionJson}
onChange={(condition) => setFormData({ ...formData, conditionJson: condition })}
/>
)}
</CardContent>
</Card>
{/* 데이터 이동 설정 */}
<Card>
<CardHeader>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* 이동 방식 선택 */}
<div>
<Label> </Label>
<Select
value={formData.moveType}
onValueChange={(value: "status" | "table" | "both") => setFormData({ ...formData, moveType: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="status">
<div>
<div className="font-medium"> </div>
<div className="text-xs text-gray-500"> </div>
</div>
</SelectItem>
<SelectItem value="table">
<div>
<div className="font-medium"> </div>
<div className="text-xs text-gray-500"> </div>
</div>
</SelectItem>
<SelectItem value="both">
<div>
<div className="font-medium"></div>
<div className="text-xs text-gray-500"> + </div>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* 상태 변경 설정 (status 또는 both일 때) */}
{(formData.moveType === "status" || formData.moveType === "both") && (
<>
<div>
<Label> </Label>
<Popover open={openStatusColumnCombobox} onOpenChange={setOpenStatusColumnCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openStatusColumnCombobox}
className="w-full justify-between"
disabled={!formData.tableName || loadingColumns}
>
{loadingColumns
? "컬럼 로딩 중..."
: formData.statusColumn
? columns.find((col) => col.columnName === formData.statusColumn)?.columnName ||
formData.statusColumn
: "상태 컬럼 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="컬럼 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.columnName}
value={column.columnName}
onSelect={() => {
setFormData({ ...formData, statusColumn: column.columnName });
setOpenStatusColumnCombobox(false);
}}
>
<Check
className={cn(
"mr-2 h-4 w-4",
formData.statusColumn === column.columnName ? "opacity-100" : "opacity-0",
)}
/>
<div>
<div>{column.columnName}</div>
<div className="text-xs text-gray-500">({column.dataType})</div>
</div>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div>
<Label> </Label>
<Input
value={formData.statusValue}
onChange={(e) => {
const newValue = e.target.value;
console.log("💡 statusValue onChange:", newValue);
setFormData({ ...formData, statusValue: newValue });
console.log("✅ Updated formData:", { ...formData, statusValue: newValue });
}}
placeholder="예: approved"
/>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
</>
)}
{/* 테이블 이동 설정 (table 또는 both일 때) */}
{(formData.moveType === "table" || formData.moveType === "both") && (
<>
<div>
<Label> </Label>
<Select
value={formData.targetTable}
onValueChange={(value) => setFormData({ ...formData, targetTable: value })}
>
<SelectTrigger>
<SelectValue placeholder="테이블 선택" />
</SelectTrigger>
<SelectContent>
{tableList.map((table) => (
<SelectItem key={table.tableName} value={table.tableName}>
{table.displayName || table.tableName}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-gray-500"> </p>
</div>
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 . .
</p>
</div>
</>
)}
</CardContent>
</Card>
{/* 외부 DB 연동 설정 */}
<Card>
<CardHeader>
<CardTitle> DB </CardTitle>
<CardDescription> </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label> </Label>
<Select
value={formData.integrationType}
onValueChange={(value: any) => {
setFormData({ ...formData, integrationType: value });
// 타입 변경 시 config 초기화
if (value === "internal") {
setFormData((prev) => ({ ...prev, integrationConfig: undefined }));
}
}}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{INTEGRATION_TYPE_OPTIONS.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={opt.value !== "internal" && opt.value !== "external_db"}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 외부 DB 연동 설정 */}
{formData.integrationType === "external_db" && (
<div className="space-y-4 rounded-lg border p-4">
{externalConnections.length === 0 ? (
<div className="rounded-md bg-yellow-50 p-3">
<p className="text-sm text-yellow-900">
DB . DB .
</p>
</div>
) : (
<>
<div>
<Label> DB </Label>
<Select
value={formData.integrationConfig?.connectionId?.toString() || ""}
onValueChange={(value) => {
const connectionId = parseInt(value);
setFormData({
...formData,
integrationConfig: {
type: "external_db",
connectionId,
operation: "update",
tableName: "",
updateFields: {},
whereCondition: {},
},
});
}}
>
<SelectTrigger>
<SelectValue placeholder="연결 선택" />
</SelectTrigger>
<SelectContent>
{externalConnections.map((conn) => (
<SelectItem key={conn.id} value={conn.id.toString()}>
{conn.name} ({conn.dbType})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{formData.integrationConfig?.connectionId && (
<>
<div>
<Label> </Label>
<Select
value={formData.integrationConfig.operation}
onValueChange={(value: any) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
operation: value,
},
})
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{OPERATION_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label></Label>
<Input
value={formData.integrationConfig.tableName}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
tableName: e.target.value,
},
})
}
placeholder="예: orders"
/>
</div>
{formData.integrationConfig.operation === "custom" ? (
<div>
<Label> </Label>
<Textarea
value={formData.integrationConfig.customQuery || ""}
onChange={(e) =>
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
customQuery: e.target.value,
},
})
}
placeholder="UPDATE orders SET status = 'approved' WHERE id = {{dataId}}"
rows={4}
className="font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500">
릿 : {`{{dataId}}, {{currentUser}}, {{currentTimestamp}}`}
</p>
</div>
) : (
<>
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "insert") && (
<div>
<Label> (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.updateFields || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
updateFields: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"status": "approved", "updated_by": "{{currentUser}}"}'
rows={4}
className="font-mono text-sm"
/>
</div>
)}
{(formData.integrationConfig.operation === "update" ||
formData.integrationConfig.operation === "delete") && (
<div>
<Label>WHERE (JSON)</Label>
<Textarea
value={JSON.stringify(formData.integrationConfig.whereCondition || {}, null, 2)}
onChange={(e) => {
try {
const parsed = JSON.parse(e.target.value);
setFormData({
...formData,
integrationConfig: {
...formData.integrationConfig!,
whereCondition: parsed,
},
});
} catch (err) {
// JSON 파싱 실패 시 무시
}
}}
placeholder='{"id": "{{dataId}}"}'
rows={3}
className="font-mono text-sm"
/>
</div>
)}
</>
)}
<div className="rounded-md bg-blue-50 p-3">
<p className="text-sm text-blue-900">
💡 릿 :
<br /> {`{{dataId}}`} - ID
<br /> {`{{currentUser}}`} -
<br /> {`{{currentTimestamp}}`} -
</p>
</div>
</>
)}
</>
)}
</div>
)}
</CardContent>
</Card>
{/* 액션 버튼 */}
<div className="flex gap-2">
<Button className="flex-1" onClick={handleSave}>
<Save className="mr-2 h-4 w-4" />
</Button>
<Button variant="destructive" onClick={handleDelete}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -307,39 +307,6 @@ export const InteractiveScreenViewer: React.FC<InteractiveScreenViewerProps> = (
);
}
// 플로우 위젯 컴포넌트 처리
if (comp.type === "flow" || (comp.type === "component" && (comp as any).componentConfig?.type === "flow-widget")) {
const FlowWidget = require("@/components/screen/widgets/FlowWidget").FlowWidget;
// componentConfig에서 flowId 추출
const flowConfig = (comp as any).componentConfig || {};
console.log("🔍 InteractiveScreenViewer 플로우 위젯 변환:", {
compType: comp.type,
hasComponentConfig: !!(comp as any).componentConfig,
flowConfig,
flowConfigFlowId: flowConfig.flowId,
finalFlowId: flowConfig.flowId,
});
const flowComponent = {
...comp,
type: "flow" as const,
flowId: flowConfig.flowId,
flowName: flowConfig.flowName,
showStepCount: flowConfig.showStepCount !== false,
allowDataMove: flowConfig.allowDataMove || false,
displayMode: flowConfig.displayMode || "horizontal",
};
console.log("🔍 InteractiveScreenViewer 최종 flowComponent:", flowComponent);
return (
<div className="h-full w-full">
<FlowWidget component={flowComponent as any} />
</div>
);
}
const { widgetType, label, placeholder, required, readonly, columnName } = comp;
const fieldName = columnName || comp.id;
const currentValue = formData[fieldName] || "";

View File

@ -66,7 +66,7 @@ interface RealtimePreviewProps {
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
switch (layoutDirection) {
case "horizontal":
return <Layout className="text-primary h-4 w-4" />;
return <Layout className="h-4 w-4 text-primary" />;
case "vertical":
return <Columns className="h-4 w-4 text-purple-600" />;
default:
@ -86,7 +86,7 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
<div className="text-center">
{getAreaIcon(layoutDirection)}
<p className="text-muted-foreground mt-2 text-sm">{label || `${layoutDirection || "기본"} 영역`}</p>
<p className="mt-2 text-sm text-muted-foreground">{label || `${layoutDirection || "기본"} 영역`}</p>
<p className="text-xs text-gray-400"> </p>
</div>
</div>
@ -130,12 +130,12 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
if (isFileComponent(widget)) {
// console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
// componentId: widget.id,
// widgetType: widgetType,
// isFileComponent: true
// componentId: widget.id,
// widgetType: widgetType,
// isFileComponent: true
// });
return <div className="p-2 text-xs text-gray-500"> ( )</div>;
return <div className="text-xs text-gray-500 p-2"> ( )</div>;
}
// 동적 웹타입 렌더링 사용
@ -182,7 +182,7 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
case "text":
case "email":
case "tel":
return <Type className="text-primary h-4 w-4" />;
return <Type className="h-4 w-4 text-primary" />;
case "number":
case "decimal":
return <Hash className="h-4 w-4 text-green-600" />;
@ -196,11 +196,11 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
case "boolean":
case "checkbox":
return <CheckSquare className="text-primary h-4 w-4" />;
return <CheckSquare className="h-4 w-4 text-primary" />;
case "radio":
return <Radio className="text-primary h-4 w-4" />;
return <Radio className="h-4 w-4 text-primary" />;
case "code":
return <Code className="text-muted-foreground h-4 w-4" />;
return <Code className="h-4 w-4 text-muted-foreground" />;
case "entity":
return <Building className="h-4 w-4 text-cyan-600" />;
case "file":
@ -227,39 +227,39 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
useEffect(() => {
const handleGlobalFileStateChange = (event: CustomEvent) => {
// console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
// eventComponentId: event.detail.componentId,
// currentComponentId: component.id,
// isMatch: event.detail.componentId === component.id,
// filesCount: event.detail.files?.length || 0,
// action: event.detail.action,
// delayed: event.detail.delayed || false,
// attempt: event.detail.attempt || 1,
// eventDetail: event.detail
// });
if (event.detail.componentId === component.id) {
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
// componentId: component.id,
// eventComponentId: event.detail.componentId,
// currentComponentId: component.id,
// isMatch: event.detail.componentId === component.id,
// filesCount: event.detail.files?.length || 0,
// action: event.detail.action,
// oldTrigger: fileUpdateTrigger,
// delayed: event.detail.delayed || false,
// attempt: event.detail.attempt || 1
// });
setFileUpdateTrigger((prev) => {
const newTrigger = prev + 1;
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
// old: prev,
// new: newTrigger,
// attempt: event.detail.attempt || 1,
// eventDetail: event.detail
// });
if (event.detail.componentId === component.id) {
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
// componentId: component.id,
// filesCount: event.detail.files?.length || 0,
// action: event.detail.action,
// oldTrigger: fileUpdateTrigger,
// delayed: event.detail.delayed || false,
// attempt: event.detail.attempt || 1
// });
setFileUpdateTrigger(prev => {
const newTrigger = prev + 1;
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
// old: prev,
// new: newTrigger,
// componentId: component.id,
// attempt: event.detail.attempt || 1
// });
return newTrigger;
});
} else {
// console.log("❌ 컴포넌트 ID 불일치:", {
// eventComponentId: event.detail.componentId,
// currentComponentId: component.id
// eventComponentId: event.detail.componentId,
// currentComponentId: component.id
// });
}
};
@ -267,34 +267,34 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 강제 업데이트 함수 등록
const forceUpdate = (componentId: string, files: any[]) => {
// console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
// targetComponentId: componentId,
// currentComponentId: component.id,
// isMatch: componentId === component.id,
// filesCount: files.length
// targetComponentId: componentId,
// currentComponentId: component.id,
// isMatch: componentId === component.id,
// filesCount: files.length
// });
if (componentId === component.id) {
// console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
// componentId: component.id,
// filesCount: files.length,
// oldTrigger: fileUpdateTrigger
// componentId: component.id,
// filesCount: files.length,
// oldTrigger: fileUpdateTrigger
// });
setFileUpdateTrigger((prev) => {
setFileUpdateTrigger(prev => {
const newTrigger = prev + 1;
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
// old: prev,
// new: newTrigger,
// componentId: component.id
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
// old: prev,
// new: newTrigger,
// componentId: component.id
// });
return newTrigger;
});
}
};
if (typeof window !== "undefined") {
if (typeof window !== 'undefined') {
try {
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
// 전역 강제 업데이트 함수 등록
if (!(window as any).forceRealtimePreviewUpdate) {
(window as any).forceRealtimePreviewUpdate = forceUpdate;
@ -302,10 +302,10 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
} catch (error) {
// console.warn("RealtimePreview 이벤트 리스너 등록 실패:", error);
}
return () => {
try {
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
} catch (error) {
// console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
}
@ -327,7 +327,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 선택된 컴포넌트 스타일
const selectionStyle = isSelected
? {
outline: "2px solid rgb(59, 130, 246)",
outline: "2px solid #3b82f6",
outlineOffset: "2px",
}
: {};
@ -395,39 +395,6 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
);
})()}
{/* 플로우 위젯 타입 */}
{(type === "flow" || (type === "component" && (component as any).componentConfig?.type === "flow-widget")) &&
(() => {
const FlowWidget = require("@/components/screen/widgets/FlowWidget").FlowWidget;
// componentConfig에서 flowId 추출
const flowConfig = (component as any).componentConfig || {};
console.log("🔍 RealtimePreview 플로우 위젯 변환:", {
compType: component.type,
hasComponentConfig: !!(component as any).componentConfig,
flowConfig,
flowConfigFlowId: flowConfig.flowId,
});
const flowComponent = {
...component,
type: "flow" as const,
flowId: flowConfig.flowId,
flowName: flowConfig.flowName,
showStepCount: flowConfig.showStepCount !== false,
allowDataMove: flowConfig.allowDataMove || false,
displayMode: flowConfig.displayMode || "horizontal",
};
console.log("🔍 RealtimePreview 최종 flowComponent:", flowComponent);
return (
<div className="h-full w-full">
<FlowWidget component={flowComponent as any} />
</div>
);
})()}
{/* 그룹 타입 */}
{type === "group" && (
<div className="relative h-full w-full">
@ -445,19 +412,18 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
)}
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
{isFileComponent(component) &&
(() => {
const fileComponent = component as any;
const uploadedFiles = fileComponent.uploadedFiles || [];
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
{isFileComponent(component) && (() => {
const fileComponent = component as any;
const uploadedFiles = fileComponent.uploadedFiles || [];
// 전역 상태에서 최신 파일 정보 가져오기
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
const globalFiles = globalFileState[component.id] || [];
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
// componentId: component.id,
// uploadedFilesCount: uploadedFiles.length,
// globalFilesCount: globalFiles.length,
@ -466,76 +432,73 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// componentType: component.type,
// fileUpdateTrigger: fileUpdateTrigger,
// timestamp: new Date().toISOString()
// });
return (
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
{currentFiles.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="mb-1 text-xs font-medium text-gray-700">
({currentFiles.length})
</div>
<div className="space-y-1">
{currentFiles.map((file: any, index: number) => {
// 파일 확장자에 따른 아이콘 선택
const getFileIcon = (fileName: string) => {
const ext = fileName.split(".").pop()?.toLowerCase() || "";
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
return <ImageIcon className="h-4 w-4 flex-shrink-0 text-green-500" />;
}
if (["pdf", "doc", "docx", "txt", "rtf", "hwp", "hwpx", "hwpml", "pages"].includes(ext)) {
return <FileText className="h-4 w-4 flex-shrink-0 text-red-500" />;
}
if (["ppt", "pptx", "hpt", "keynote"].includes(ext)) {
return <Presentation className="h-4 w-4 flex-shrink-0 text-orange-600" />;
}
if (["xls", "xlsx", "hcdt", "numbers"].includes(ext)) {
return <FileText className="h-4 w-4 flex-shrink-0 text-green-600" />;
}
if (["mp4", "avi", "mov", "wmv", "webm", "ogg"].includes(ext)) {
return <Video className="h-4 w-4 flex-shrink-0 text-purple-500" />;
}
if (["mp3", "wav", "flac", "aac"].includes(ext)) {
return <Music className="h-4 w-4 flex-shrink-0 text-orange-500" />;
}
if (["zip", "rar", "7z", "tar"].includes(ext)) {
return <Archive className="h-4 w-4 flex-shrink-0 text-yellow-500" />;
}
return <File className="h-4 w-4 flex-shrink-0 text-blue-500" />;
};
return (
<div
key={file.objid || index}
className="flex items-center space-x-2 rounded bg-white p-2 text-xs"
>
{getFileIcon(file.realFileName || file.name || "")}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-gray-900">
{file.realFileName || file.name || `파일 ${index + 1}`}
</p>
<p className="text-gray-500">
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ""}
</p>
</div>
// });
return (
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
{currentFiles.length > 0 ? (
<div className="h-full overflow-y-auto">
<div className="mb-1 text-xs font-medium text-gray-700">
({currentFiles.length})
</div>
<div className="space-y-1">
{currentFiles.map((file: any, index: number) => {
// 파일 확장자에 따른 아이콘 선택
const getFileIcon = (fileName: string) => {
const ext = fileName.split('.').pop()?.toLowerCase() || '';
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
return <ImageIcon className="h-4 w-4 text-green-500 flex-shrink-0" />;
}
if (['pdf', 'doc', 'docx', 'txt', 'rtf', 'hwp', 'hwpx', 'hwpml', 'pages'].includes(ext)) {
return <FileText className="h-4 w-4 text-red-500 flex-shrink-0" />;
}
if (['ppt', 'pptx', 'hpt', 'keynote'].includes(ext)) {
return <Presentation className="h-4 w-4 text-orange-600 flex-shrink-0" />;
}
if (['xls', 'xlsx', 'hcdt', 'numbers'].includes(ext)) {
return <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />;
}
if (['mp4', 'avi', 'mov', 'wmv', 'webm', 'ogg'].includes(ext)) {
return <Video className="h-4 w-4 text-purple-500 flex-shrink-0" />;
}
if (['mp3', 'wav', 'flac', 'aac'].includes(ext)) {
return <Music className="h-4 w-4 text-orange-500 flex-shrink-0" />;
}
if (['zip', 'rar', '7z', 'tar'].includes(ext)) {
return <Archive className="h-4 w-4 text-yellow-500 flex-shrink-0" />;
}
return <File className="h-4 w-4 text-blue-500 flex-shrink-0" />;
};
return (
<div key={file.objid || index} className="flex items-center space-x-2 bg-white rounded p-2 text-xs">
{getFileIcon(file.realFileName || file.name || '')}
<div className="flex-1 min-w-0">
<p className="truncate font-medium text-gray-900">
{file.realFileName || file.name || `파일 ${index + 1}`}
</p>
<p className="text-gray-500">
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ''}
</p>
</div>
);
})}
</div>
</div>
);
})}
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<File className="mb-2 h-8 w-8 text-gray-400" />
<p className="mb-1 text-xs font-medium text-gray-700"> (0)</p>
<p className="text-muted-foreground text-sm"> </p>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
)}
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center text-center">
<File className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-xs font-medium text-gray-700 mb-1"> (0)</p>
<p className="text-sm text-muted-foreground"> </p>
<p className="mt-1 text-xs text-gray-400"> </p>
</div>
)}
</div>
);
})()}
</div>
);
})()}
</div>
{/* 선택된 컴포넌트 정보 표시 */}

View File

@ -83,7 +83,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
const selectionStyle = isSelected
? {
outline: "2px solid rgb(59, 130, 246)",
outline: "2px solid hsl(var(--primary))",
outlineOffset: "2px",
zIndex: 20,
}

View File

@ -1997,7 +1997,6 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
"table-list": 12, // 테이블 리스트 (100%)
"image-display": 4, // 이미지 표시 (33%)
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
"flow-widget": 12, // 플로우 위젯 (100%)
// 액션 컴포넌트 (ACTION 카테고리)
"button-basic": 1, // 버튼 (8.33%)
@ -2017,13 +2016,8 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
"chart-basic": 6, // 차트 (50%)
};
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
if (component.defaultSize?.gridColumnSpan === "full") {
gridColumns = 12;
} else {
// componentId 또는 webType으로 매핑, 없으면 기본값 3
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
}
// componentId 또는 webType으로 매핑, 없으면 기본값 3
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
componentId,

View File

@ -1,158 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
import { getFlowDefinitions } from "@/lib/api/flow";
import type { FlowDefinition } from "@/types/flow";
import { Loader2, Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
interface FlowWidgetConfigPanelProps {
config: Record<string, any>;
onChange: (config: Record<string, any>) => void;
}
export function FlowWidgetConfigPanel({ config = {}, onChange }: FlowWidgetConfigPanelProps) {
const [flowList, setFlowList] = useState<FlowDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [openCombobox, setOpenCombobox] = useState(false);
useEffect(() => {
const loadFlows = async () => {
try {
setLoading(true);
const response = await getFlowDefinitions({ isActive: true });
if (response.success && response.data) {
setFlowList(response.data);
}
} catch (error) {
console.error("Failed to load flows:", error);
} finally {
setLoading(false);
}
};
loadFlows();
}, []);
const selectedFlow = flowList.find((flow) => flow.id === config.flowId);
return (
<div className="space-y-4 p-4">
<div>
<div className="mb-2">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="space-y-4">
<div>
<Label></Label>
{loading ? (
<div className="flex items-center gap-2 rounded-md border px-3 py-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground text-sm"> ...</span>
</div>
) : (
<>
<Popover open={openCombobox} onOpenChange={setOpenCombobox}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={openCombobox}
className="w-full justify-between"
>
{selectedFlow ? selectedFlow.name : "플로우 선택"}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput placeholder="플로우 검색..." />
<CommandList>
<CommandEmpty> .</CommandEmpty>
<CommandGroup>
{flowList.map((flow) => (
<CommandItem
key={flow.id}
value={flow.name}
onSelect={() => {
onChange({
...config,
flowId: flow.id,
flowName: flow.name,
});
setOpenCombobox(false);
}}
>
<Check
className={cn("mr-2 h-4 w-4", config.flowId === flow.id ? "opacity-100" : "opacity-0")}
/>
<span className="font-medium">{flow.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
{selectedFlow && (
<p className="text-muted-foreground mt-1 text-xs">: {selectedFlow.tableName || "없음"}</p>
)}
</>
)}
</div>
</div>
</div>
<div>
<div className="mb-2">
<h3 className="text-sm font-medium"> </h3>
</div>
<div className="space-y-4">
<div>
<Label> </Label>
<Select
value={config.displayMode || "horizontal"}
onValueChange={(value: "horizontal" | "vertical") => onChange({ ...config, displayMode: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="horizontal"> ()</SelectItem>
<SelectItem value="vertical"> ()</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<Label> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
checked={config.showStepCount !== false}
onCheckedChange={(checked) => onChange({ ...config, showStepCount: checked })}
/>
</div>
<div className="flex items-center justify-between">
<div>
<Label> </Label>
<p className="text-muted-foreground text-xs"> </p>
</div>
<Switch
checked={config.allowDataMove || false}
onCheckedChange={(checked) => onChange({ ...config, allowDataMove: checked })}
/>
</div>
</div>
</div>
</div>
);
}

View File

@ -1056,6 +1056,33 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
</div>
</div>
{/* 세부 타입 선택 영역 */}
{webType && availableDetailTypes.length > 1 && (
<div className="border-b border-gray-200 bg-gray-50 p-6 pt-0">
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700"> </label>
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
<SelectTrigger className="w-full bg-white">
<SelectValue placeholder="세부 타입을 선택하세요" />
</SelectTrigger>
<SelectContent>
{availableDetailTypes.map((option) => (
<SelectItem key={option.value} value={option.value}>
<div className="flex flex-col">
<span className="font-medium">{option.label}</span>
<span className="text-xs text-gray-500">{option.description}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-gray-500">
"{currentBaseInputType}"
</p>
</div>
</div>
)}
{/* 컴포넌트 설정 패널 */}
<div className="flex-1 overflow-y-auto px-6 pb-6">
<div className="space-y-6">
@ -1088,6 +1115,23 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
});
}}
/>
{/* 웹타입별 특화 설정 */}
{webType && (
<div className="border-t pt-6">
<h4 className="mb-4 text-sm font-semibold text-gray-900"> </h4>
<WebTypeConfigPanel
webType={webType as any}
config={selectedComponent.componentConfig || {}}
onUpdateConfig={(newConfig) => {
// 기존 설정과 병합하여 업데이트
Object.entries(newConfig).forEach(([key, value]) => {
onUpdateProperty(selectedComponent.id, `componentConfig.${key}`, value);
});
}}
/>
</div>
)}
</div>
</div>
</div>

View File

@ -548,6 +548,22 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
handleUpdate("componentConfig", newConfig);
}}
/>
{/* 웹타입별 특화 설정 */}
{webType && (
<div className="border-t pt-4">
<h4 className="mb-2 text-sm font-semibold"> </h4>
<WebTypeConfigPanel
webType={webType as any}
config={selectedComponent.componentConfig || {}}
onUpdateConfig={(newConfig) => {
Object.entries(newConfig).forEach(([key, value]) => {
handleUpdate(`componentConfig.${key}`, value);
});
}}
/>
</div>
)}
</div>
);
}
@ -576,6 +592,17 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
</SelectContent>
</Select>
</div>
{/* WebType 설정 패널 */}
<WebTypeConfigPanel
webType={widget.webType as any}
config={widget.webTypeConfig || {}}
onUpdateConfig={(newConfig) => {
Object.entries(newConfig).forEach(([key, value]) => {
handleUpdate(`webTypeConfig.${key}`, value);
});
}}
/>
</div>
);
}

View File

@ -1,810 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { FlowComponent } from "@/types/screen-management";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { AlertCircle, Loader2, ChevronDown, ChevronUp, History } from "lucide-react";
import { getFlowById, getAllStepCounts, getStepDataList, moveBatchData, getFlowAuditLogs } from "@/lib/api/flow";
import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { toast } from "sonner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Pagination,
PaginationContent,
PaginationItem,
PaginationLink,
PaginationNext,
PaginationPrevious,
} from "@/components/ui/pagination";
interface FlowWidgetProps {
component: FlowComponent;
onStepClick?: (stepId: number, stepName: string) => void;
}
export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
const [flowData, setFlowData] = useState<FlowDefinition | null>(null);
const [steps, setSteps] = useState<FlowStep[]>([]);
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [connections, setConnections] = useState<any[]>([]); // 플로우 연결 정보
// 선택된 스텝의 데이터 리스트 상태
const [selectedStepId, setSelectedStepId] = useState<number | null>(null);
const [stepData, setStepData] = useState<any[]>([]);
const [stepDataColumns, setStepDataColumns] = useState<string[]>([]);
const [stepDataLoading, setStepDataLoading] = useState(false);
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
const [movingData, setMovingData] = useState(false);
const [selectedNextStepId, setSelectedNextStepId] = useState<number | null>(null); // 선택된 다음 단계
// 오딧 로그 상태
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
const [auditLogsLoading, setAuditLogsLoading] = useState(false);
const [showAuditLogs, setShowAuditLogs] = useState(false);
const [auditPage, setAuditPage] = useState(1);
const [auditPageSize] = useState(10);
// componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨)
const config = (component as any).componentConfig || (component as any).config || {};
const flowId = config.flowId || component.flowId;
const flowName = config.flowName || component.flowName;
const displayMode = config.displayMode || component.displayMode || "horizontal";
const showStepCount = config.showStepCount !== false && component.showStepCount !== false; // 기본값 true
const allowDataMove = config.allowDataMove || component.allowDataMove || false;
console.log("🔍 FlowWidget 렌더링:", {
component,
componentConfig: config,
flowId,
flowName,
displayMode,
showStepCount,
allowDataMove,
});
useEffect(() => {
console.log("🔍 FlowWidget useEffect 실행:", {
flowId,
hasFlowId: !!flowId,
config,
});
if (!flowId) {
setLoading(false);
return;
}
const loadFlowData = async () => {
try {
setLoading(true);
setError(null);
// 플로우 정보 조회
const flowResponse = await getFlowById(flowId!);
if (!flowResponse.success || !flowResponse.data) {
throw new Error("플로우를 찾을 수 없습니다");
}
setFlowData(flowResponse.data);
// 스텝 목록 조회
const stepsResponse = await fetch(`/api/flow/definitions/${flowId}/steps`);
if (!stepsResponse.ok) {
throw new Error("스텝 목록을 불러올 수 없습니다");
}
const stepsData = await stepsResponse.json();
if (stepsData.success && stepsData.data) {
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
setSteps(sortedSteps);
// 연결 정보 조회
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`);
if (connectionsResponse.ok) {
const connectionsData = await connectionsResponse.json();
if (connectionsData.success && connectionsData.data) {
setConnections(connectionsData.data);
}
}
// 스텝별 데이터 건수 조회
if (showStepCount) {
const countsResponse = await getAllStepCounts(flowId!);
if (countsResponse.success && countsResponse.data) {
// 배열을 Record<number, number>로 변환
const countsMap: Record<number, number> = {};
countsResponse.data.forEach((item: any) => {
countsMap[item.stepId] = item.count;
});
setStepCounts(countsMap);
}
}
}
} catch (err: any) {
console.error("Failed to load flow data:", err);
setError(err.message || "플로우 데이터를 불러오는데 실패했습니다");
} finally {
setLoading(false);
}
};
loadFlowData();
}, [flowId, showStepCount]);
// 스텝 클릭 핸들러
const handleStepClick = async (stepId: number, stepName: string) => {
if (onStepClick) {
onStepClick(stepId, stepName);
return;
}
// 같은 스텝을 다시 클릭하면 접기
if (selectedStepId === stepId) {
setSelectedStepId(null);
setStepData([]);
setStepDataColumns([]);
setSelectedRows(new Set());
return;
}
// 새로운 스텝 선택 - 데이터 로드
setSelectedStepId(stepId);
setStepDataLoading(true);
setSelectedRows(new Set());
try {
const response = await getStepDataList(flowId!, stepId, 1, 100);
if (!response.success) {
throw new Error(response.message || "데이터를 불러올 수 없습니다");
}
const rows = response.data?.records || [];
setStepData(rows);
// 컬럼 추출
if (rows.length > 0) {
setStepDataColumns(Object.keys(rows[0]));
} else {
setStepDataColumns([]);
}
} catch (err: any) {
console.error("Failed to load step data:", err);
toast.error(err.message || "데이터를 불러오는데 실패했습니다");
} finally {
setStepDataLoading(false);
}
};
// 체크박스 토글
const toggleRowSelection = (rowIndex: number) => {
const newSelected = new Set(selectedRows);
if (newSelected.has(rowIndex)) {
newSelected.delete(rowIndex);
} else {
newSelected.add(rowIndex);
}
setSelectedRows(newSelected);
};
// 전체 선택/해제
const toggleAllRows = () => {
if (selectedRows.size === stepData.length) {
setSelectedRows(new Set());
} else {
setSelectedRows(new Set(stepData.map((_, index) => index)));
}
};
// 현재 단계에서 가능한 다음 단계들 찾기
const getNextSteps = (currentStepId: number) => {
return connections
.filter((conn) => conn.fromStepId === currentStepId)
.map((conn) => steps.find((s) => s.id === conn.toStepId))
.filter((step) => step !== undefined);
};
// 다음 단계로 이동
const handleMoveToNext = async (targetStepId?: number) => {
if (!flowId || !selectedStepId || selectedRows.size === 0) return;
// 다음 단계 결정
let nextStepId = targetStepId || selectedNextStepId;
if (!nextStepId) {
const nextSteps = getNextSteps(selectedStepId);
if (nextSteps.length === 0) {
toast.error("다음 단계가 없습니다");
return;
}
if (nextSteps.length === 1) {
nextStepId = nextSteps[0].id;
} else {
toast.error("다음 단계를 선택해주세요");
return;
}
}
const selectedData = Array.from(selectedRows).map((index) => stepData[index]);
try {
setMovingData(true);
// Primary Key 컬럼 추출 (첫 번째 컬럼 가정)
const primaryKeyColumn = stepDataColumns[0];
const dataIds = selectedData.map((data) => String(data[primaryKeyColumn]));
// 배치 이동 API 호출
const response = await moveBatchData({
flowId,
fromStepId: selectedStepId,
toStepId: nextStepId,
dataIds,
});
if (!response.success) {
throw new Error(response.message || "데이터 이동에 실패했습니다");
}
const nextStepName = steps.find((s) => s.id === nextStepId)?.stepName;
toast.success(`${selectedRows.size}건의 데이터를 "${nextStepName}"(으)로 이동했습니다`);
// 선택 초기화
setSelectedNextStepId(null);
setSelectedRows(new Set());
// 데이터 새로고침
await handleStepClick(selectedStepId, steps.find((s) => s.id === selectedStepId)?.stepName || "");
// 건수 새로고침
const countsResponse = await getAllStepCounts(flowId);
if (countsResponse.success && countsResponse.data) {
const countsMap: Record<number, number> = {};
countsResponse.data.forEach((item: any) => {
countsMap[item.stepId] = item.count;
});
setStepCounts(countsMap);
}
} catch (err: any) {
console.error("Failed to move data:", err);
toast.error(err.message || "데이터 이동 중 오류가 발생했습니다");
} finally {
setMovingData(false);
}
};
// 오딧 로그 로드
const loadAuditLogs = async () => {
if (!flowId) return;
try {
setAuditLogsLoading(true);
const response = await getFlowAuditLogs(flowId, 100); // 최근 100개
if (response.success && response.data) {
setAuditLogs(response.data);
}
} catch (err: any) {
console.error("Failed to load audit logs:", err);
toast.error("이력 조회 중 오류가 발생했습니다");
} finally {
setAuditLogsLoading(false);
}
};
// 오딧 로그 모달 열기
const handleOpenAuditLogs = () => {
setShowAuditLogs(true);
setAuditPage(1); // 페이지 초기화
loadAuditLogs();
};
// 페이지네이션된 오딧 로그
const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize);
const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize);
if (loading) {
return (
<div className="flex items-center justify-center p-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
);
}
if (error) {
return (
<div className="border-destructive/50 bg-destructive/10 flex items-center gap-2 rounded-lg border p-4">
<AlertCircle className="text-destructive h-5 w-5" />
<span className="text-destructive text-sm">{error}</span>
</div>
);
}
if (!flowId || !flowData) {
return (
<div className="border-muted-foreground/25 flex items-center justify-center rounded-lg border-2 border-dashed p-8">
<span className="text-muted-foreground text-sm"> </span>
</div>
);
}
if (steps.length === 0) {
return (
<div className="border-muted flex items-center justify-center rounded-lg border p-8">
<span className="text-muted-foreground text-sm"> </span>
</div>
);
}
// 반응형 컨테이너 클래스
const containerClass =
displayMode === "horizontal"
? "flex flex-col sm:flex-row sm:flex-wrap items-center justify-center gap-3 sm:gap-4"
: "flex flex-col items-center gap-4";
return (
<div className="@container min-h-full w-full p-2 sm:p-4 lg:p-6">
{/* 플로우 제목 */}
<div className="mb-3 sm:mb-4">
<div className="flex items-center justify-center gap-2">
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
{/* 오딧 로그 버튼 */}
<Dialog open={showAuditLogs} onOpenChange={setShowAuditLogs}>
<DialogTrigger asChild>
<Button variant="outline" size="sm" onClick={handleOpenAuditLogs} className="gap-1.5">
<History className="h-4 w-4" />
<span className="hidden sm:inline"> </span>
</Button>
</DialogTrigger>
<DialogContent className="max-h-[85vh] max-w-[95vw] sm:max-w-[1000px]">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription> ( {auditLogs.length})</DialogDescription>
</DialogHeader>
{auditLogsLoading ? (
<div className="flex items-center justify-center p-8">
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
<span className="text-muted-foreground ml-2 text-sm"> ...</span>
</div>
) : auditLogs.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm"> </div>
) : (
<div className="space-y-4">
{/* 테이블 */}
<div className="bg-card overflow-hidden rounded-lg border">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="bg-muted/50">
<TableHead className="w-[140px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[120px]"> </TableHead>
<TableHead className="w-[120px]"> </TableHead>
<TableHead className="w-[100px]"> ID</TableHead>
<TableHead className="w-[140px]"> </TableHead>
<TableHead className="w-[100px]"></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedAuditLogs.map((log) => {
const fromStep = steps.find((s) => s.id === log.fromStepId);
const toStep = steps.find((s) => s.id === log.toStepId);
return (
<TableRow key={log.id} className="hover:bg-muted/50">
<TableCell className="font-mono text-xs">
{new Date(log.changedAt).toLocaleString("ko-KR", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
})}
</TableCell>
<TableCell>
<Badge variant="outline" className="text-xs">
{log.moveType === "status"
? "상태"
: log.moveType === "table"
? "테이블"
: "하이브리드"}
</Badge>
</TableCell>
<TableCell className="font-medium">
{fromStep?.stepName || `Step ${log.fromStepId}`}
</TableCell>
<TableCell className="font-medium">
{toStep?.stepName || `Step ${log.toStepId}`}
</TableCell>
<TableCell className="font-mono text-xs">
{log.sourceDataId || "-"}
{log.targetDataId && log.targetDataId !== log.sourceDataId && (
<>
<br /> {log.targetDataId}
</>
)}
</TableCell>
<TableCell className="text-xs">
{log.statusFrom && log.statusTo ? (
<span className="font-mono">
{log.statusFrom}
<br /> {log.statusTo}
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
<TableCell className="text-xs">{log.changedBy}</TableCell>
<TableCell className="text-xs">
{log.sourceTable || "-"}
{log.targetTable && log.targetTable !== log.sourceTable && (
<>
<br /> {log.targetTable}
</>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
{/* 페이지네이션 */}
{totalAuditPages > 1 && (
<div className="flex items-center justify-between">
<div className="text-muted-foreground text-sm">
{(auditPage - 1) * auditPageSize + 1}-{Math.min(auditPage * auditPageSize, auditLogs.length)} /{" "}
{auditLogs.length}
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
className={auditPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
/>
</PaginationItem>
{Array.from({ length: totalAuditPages }, (_, i) => i + 1)
.filter((page) => {
// 현재 페이지 주변만 표시
return (
page === 1 ||
page === totalAuditPages ||
(page >= auditPage - 1 && page <= auditPage + 1)
);
})
.map((page, idx, arr) => (
<React.Fragment key={page}>
{idx > 0 && arr[idx - 1] !== page - 1 && (
<PaginationItem>
<span className="text-muted-foreground px-2">...</span>
</PaginationItem>
)}
<PaginationItem>
<PaginationLink
onClick={() => setAuditPage(page)}
isActive={auditPage === page}
className="cursor-pointer"
>
{page}
</PaginationLink>
</PaginationItem>
</React.Fragment>
))}
<PaginationItem>
<PaginationNext
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
className={
auditPage === totalAuditPages ? "pointer-events-none opacity-50" : "cursor-pointer"
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
{flowData.description && (
<p className="text-muted-foreground mt-1 text-center text-xs sm:text-sm">{flowData.description}</p>
)}
</div>
{/* 플로우 스텝 목록 */}
<div className={containerClass}>
{steps.map((step, index) => (
<React.Fragment key={step.id}>
{/* 스텝 카드 */}
<div
className={`group bg-card relative w-full cursor-pointer rounded-lg border-2 p-4 shadow-sm transition-all duration-200 sm:w-auto sm:min-w-[180px] sm:rounded-xl sm:p-5 lg:min-w-[220px] lg:p-6 ${
selectedStepId === step.id
? "border-primary bg-primary/5 shadow-md"
: "border-border hover:border-primary/50 hover:shadow-md"
}`}
onClick={() => handleStepClick(step.id, step.stepName)}
>
{/* 단계 번호 배지 */}
<div className="bg-primary/10 text-primary mb-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium sm:mb-3 sm:px-3">
Step {step.stepOrder}
</div>
{/* 스텝 이름 */}
<h4 className="text-foreground mb-2 pr-8 text-base leading-tight font-semibold sm:text-lg">
{step.stepName}
</h4>
{/* 데이터 건수 */}
{showStepCount && (
<div className="text-muted-foreground mt-2 flex items-center gap-2 text-xs sm:mt-3 sm:text-sm">
<div className="bg-muted flex h-7 items-center rounded-md px-2 sm:h-8 sm:px-3">
<span className="text-foreground text-sm font-semibold sm:text-base">
{stepCounts[step.id] || 0}
</span>
<span className="ml-1"></span>
</div>
</div>
)}
{/* 선택 인디케이터 */}
{selectedStepId === step.id && (
<div className="absolute top-3 right-3 sm:top-4 sm:right-4">
<ChevronUp className="text-primary h-4 w-4 sm:h-5 sm:w-5" />
</div>
)}
</div>
{/* 화살표 (마지막 스텝 제외) */}
{index < steps.length - 1 && (
<div className="text-muted-foreground/40 flex shrink-0 items-center justify-center py-2 sm:py-0">
{displayMode === "horizontal" ? (
<svg
className="h-5 w-5 rotate-90 sm:h-6 sm:w-6 sm:rotate-0"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
) : (
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
)}
</React.Fragment>
))}
</div>
{/* 선택된 스텝의 데이터 리스트 */}
{selectedStepId !== null && (
<div className="bg-muted/30 mt-4 w-full rounded-lg p-4 sm:mt-6 sm:rounded-xl sm:p-5 lg:mt-8 lg:p-6">
{/* 헤더 */}
<div className="mb-4 flex flex-col items-start justify-between gap-3 sm:mb-6 sm:flex-row sm:items-center">
<div className="flex-1">
<h4 className="text-foreground text-base font-semibold sm:text-lg">
{steps.find((s) => s.id === selectedStepId)?.stepName}
</h4>
<p className="text-muted-foreground mt-1 text-xs sm:text-sm"> {stepData.length} </p>
</div>
{allowDataMove &&
selectedRows.size > 0 &&
(() => {
const nextSteps = getNextSteps(selectedStepId);
return nextSteps.length > 1 ? (
// 다음 단계가 여러 개인 경우: 선택 UI 표시
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
<Select
value={selectedNextStepId?.toString() || ""}
onValueChange={(value) => setSelectedNextStepId(Number(value))}
>
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:w-[180px] sm:text-sm">
<SelectValue placeholder="이동할 단계 선택" />
</SelectTrigger>
<SelectContent>
{nextSteps.map((step) => (
<SelectItem key={step.id} value={step.id.toString()}>
{step.stepName}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={() => handleMoveToNext()}
disabled={movingData || !selectedNextStepId}
className="h-8 gap-1 px-3 text-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm"
>
{movingData ? (
<>
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
<span> ...</span>
</>
) : (
<>
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
<span> ({selectedRows.size})</span>
</>
)}
</Button>
</div>
) : (
// 다음 단계가 하나인 경우: 바로 이동 버튼만 표시
<Button
onClick={() => handleMoveToNext()}
disabled={movingData}
className="h-8 w-full gap-1 px-3 text-xs sm:h-10 sm:w-auto sm:gap-2 sm:px-4 sm:text-sm"
>
{movingData ? (
<>
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
<span className="hidden sm:inline"> ...</span>
<span className="sm:hidden"></span>
</>
) : (
<>
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M13 7l5 5m0 0l-5 5m5-5H6"
/>
</svg>
<span className="hidden sm:inline">
{nextSteps.length > 0 ? `${nextSteps[0].stepName}(으)로 이동` : "다음 단계로 이동"} (
{selectedRows.size})
</span>
<span className="sm:hidden"> ({selectedRows.size})</span>
</>
)}
</Button>
);
})()}
</div>
{/* 데이터 테이블 */}
{stepDataLoading ? (
<div className="flex items-center justify-center py-8 sm:py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
<span className="text-muted-foreground ml-2 text-xs sm:ml-3 sm:text-sm"> ...</span>
</div>
) : stepData.length === 0 ? (
<div className="bg-card flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8 sm:py-12">
<svg
className="text-muted-foreground/50 mb-2 h-10 w-10 sm:mb-3 sm:h-12 sm:w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
/>
</svg>
<span className="text-muted-foreground text-xs sm:text-sm"> </span>
</div>
) : (
<>
{/* 모바일: 카드 뷰 (컨테이너 640px 미만) */}
<div className="space-y-3 @sm:hidden">
{stepData.map((row, index) => (
<div
key={index}
className={`bg-card rounded-lg border p-3 transition-colors ${
selectedRows.has(index) ? "border-primary bg-primary/5" : "border-border"
}`}
>
{/* 체크박스 헤더 */}
{allowDataMove && (
<div className="mb-2 flex items-center justify-between border-b pb-2">
<span className="text-muted-foreground text-xs font-medium"></span>
<Checkbox checked={selectedRows.has(index)} onCheckedChange={() => toggleRowSelection(index)} />
</div>
)}
{/* 데이터 필드들 */}
<div className="space-y-2">
{stepDataColumns.map((col) => (
<div key={col} className="flex justify-between gap-2">
<span className="text-muted-foreground text-xs font-medium">{col}:</span>
<span className="text-foreground truncate text-xs">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</span>
</div>
))}
</div>
</div>
))}
</div>
{/* 데스크톱: 테이블 뷰 (컨테이너 640px 이상) */}
<div className="bg-card hidden overflow-x-auto rounded-lg border shadow-sm @sm:block">
<Table>
<TableHeader className="bg-muted/50">
<TableRow className="hover:bg-transparent">
{allowDataMove && (
<TableHead className="w-12">
<Checkbox
checked={selectedRows.size === stepData.length && stepData.length > 0}
onCheckedChange={toggleAllRows}
/>
</TableHead>
)}
{stepDataColumns.map((col) => (
<TableHead key={col} className="text-xs font-semibold whitespace-nowrap sm:text-sm">
{col}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{stepData.map((row, index) => (
<TableRow
key={index}
className={`transition-colors ${selectedRows.has(index) ? "bg-primary/5" : "hover:bg-muted/50"}`}
>
{allowDataMove && (
<TableCell className="w-12">
<Checkbox
checked={selectedRows.has(index)}
onCheckedChange={() => toggleRowSelection(index)}
/>
</TableCell>
)}
{stepDataColumns.map((col) => (
<TableCell key={col} className="font-mono text-xs whitespace-nowrap sm:text-sm">
{row[col] !== null && row[col] !== undefined ? (
String(row[col])
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
)}
</div>
);
}

View File

@ -1,463 +0,0 @@
/**
* API
*/
import {
FlowDefinition,
CreateFlowDefinitionRequest,
UpdateFlowDefinitionRequest,
FlowStep,
CreateFlowStepRequest,
UpdateFlowStepRequest,
FlowStepConnection,
CreateFlowConnectionRequest,
FlowStepDataCount,
FlowStepDataList,
MoveDataRequest,
MoveBatchDataRequest,
FlowAuditLog,
ApiResponse,
} from "@/types/flow";
const API_BASE = process.env.NEXT_PUBLIC_API_URL || "/api";
// ============================================
// 플로우 정의 API
// ============================================
/**
*
*/
export async function getFlowDefinitions(params?: {
tableName?: string;
isActive?: boolean;
}): Promise<ApiResponse<FlowDefinition[]>> {
try {
const queryParams = new URLSearchParams();
if (params?.tableName) queryParams.append("tableName", params.tableName);
if (params?.isActive !== undefined) queryParams.append("isActive", String(params.isActive));
const url = `${API_BASE}/flow/definitions${queryParams.toString() ? `?${queryParams.toString()}` : ""}`;
const response = await fetch(url, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function getFlowDefinition(id: number): Promise<ApiResponse<FlowDefinition>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions/${id}`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
* ()
*/
export const getFlowById = getFlowDefinition;
/**
*
*/
export async function createFlowDefinition(data: CreateFlowDefinitionRequest): Promise<ApiResponse<FlowDefinition>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function updateFlowDefinition(
id: number,
data: UpdateFlowDefinitionRequest,
): Promise<ApiResponse<FlowDefinition>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function deleteFlowDefinition(id: number): Promise<ApiResponse<{ success: boolean }>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions/${id}`, {
method: "DELETE",
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
// ============================================
// 플로우 단계 API
// ============================================
/**
*
*/
export async function getFlowSteps(flowId: number): Promise<ApiResponse<FlowStep[]>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions/${flowId}/steps`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function createFlowStep(flowId: number, data: CreateFlowStepRequest): Promise<ApiResponse<FlowStep>> {
try {
const response = await fetch(`${API_BASE}/flow/definitions/${flowId}/steps`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function updateFlowStep(stepId: number, data: UpdateFlowStepRequest): Promise<ApiResponse<FlowStep>> {
try {
const response = await fetch(`${API_BASE}/flow/steps/${stepId}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function deleteFlowStep(stepId: number): Promise<ApiResponse<{ success: boolean }>> {
try {
const response = await fetch(`${API_BASE}/flow/steps/${stepId}`, {
method: "DELETE",
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
// ============================================
// 플로우 연결 API
// ============================================
/**
*
*/
export async function getFlowConnections(flowId: number): Promise<ApiResponse<FlowStepConnection[]>> {
try {
const response = await fetch(`${API_BASE}/flow/connections/${flowId}`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function createFlowConnection(
data: CreateFlowConnectionRequest,
): Promise<ApiResponse<FlowStepConnection>> {
try {
const response = await fetch(`${API_BASE}/flow/connections`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function deleteFlowConnection(connectionId: number): Promise<ApiResponse<{ success: boolean }>> {
try {
const response = await fetch(`${API_BASE}/flow/connections/${connectionId}`, {
method: "DELETE",
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
// ============================================
// 플로우 실행 API
// ============================================
/**
*
*/
export async function getStepDataCount(flowId: number, stepId: number): Promise<ApiResponse<FlowStepDataCount>> {
try {
const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/count`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
* ()
*/
export async function getStepDataList(
flowId: number,
stepId: number,
page: number = 1,
pageSize: number = 20,
): Promise<ApiResponse<FlowStepDataList>> {
try {
const response = await fetch(`${API_BASE}/flow/${flowId}/step/${stepId}/list?page=${page}&pageSize=${pageSize}`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function getAllStepCounts(flowId: number): Promise<ApiResponse<FlowStepDataCount[]>> {
try {
const response = await fetch(`${API_BASE}/flow/${flowId}/steps/counts`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function moveData(data: MoveDataRequest): Promise<ApiResponse<{ success: boolean }>> {
try {
const response = await fetch(`${API_BASE}/flow/move`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
* ( )
*/
export async function moveDataToNextStep(
flowId: number,
currentStepId: number,
dataId: number,
): Promise<ApiResponse<{ success: boolean }>> {
return moveData({
flowId,
currentStepId,
dataId,
});
}
/**
*
*/
export async function moveBatchData(
data: MoveBatchDataRequest,
): Promise<ApiResponse<{ success: boolean; results: any[] }>> {
try {
const response = await fetch(`${API_BASE}/flow/move-batch`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(data),
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
// ============================================
// 오딧 로그 API
// ============================================
/**
*
*/
export async function getAuditLogs(flowId: number, recordId: string): Promise<ApiResponse<FlowAuditLog[]>> {
try {
const response = await fetch(`${API_BASE}/flow/audit/${flowId}/${recordId}`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
/**
*
*/
export async function getFlowAuditLogs(flowId: number, limit: number = 100): Promise<ApiResponse<FlowAuditLog[]>> {
try {
const response = await fetch(`${API_BASE}/flow/audit/${flowId}?limit=${limit}`, {
credentials: "include",
});
return await response.json();
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}

View File

@ -1,139 +0,0 @@
/**
* DB API
*/
import {
FlowExternalDbConnection,
CreateFlowExternalDbConnectionRequest,
UpdateFlowExternalDbConnectionRequest,
FlowExternalDbConnectionListResponse,
FlowExternalDbConnectionResponse,
FlowExternalDbConnectionTestResponse,
} from "@/types/flowExternalDb";
const API_BASE = "/api/flow-external-db";
/**
* DB API
*/
export const flowExternalDbApi = {
/**
* DB
*/
async getAll(activeOnly: boolean = false): Promise<FlowExternalDbConnectionListResponse> {
const query = activeOnly ? "?activeOnly=true" : "";
try {
const response = await fetch(`${API_BASE}${query}`, {
credentials: "include",
});
if (!response.ok) {
// 오류 발생 시 빈 목록 반환 (조용히 실패)
console.warn(`외부 DB 연결 목록 조회 실패: ${response.status} ${response.statusText}`);
return { success: false, data: [] };
}
return response.json();
} catch (error) {
// 네트워크 오류 등 예외 발생 시에도 빈 목록 반환
console.error("외부 DB 연결 목록 조회 오류:", error);
return { success: false, data: [] };
}
},
/**
* DB
*/
async getById(id: number): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(`${API_BASE}/${id}`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`외부 DB 연결 조회 실패: ${response.statusText}`);
}
return response.json();
},
/**
* DB
*/
async create(request: CreateFlowExternalDbConnectionRequest): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(API_BASE, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 생성 실패");
}
return response.json();
},
/**
* DB
*/
async update(id: number, request: UpdateFlowExternalDbConnectionRequest): Promise<FlowExternalDbConnectionResponse> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
credentials: "include",
body: JSON.stringify(request),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 수정 실패");
}
return response.json();
},
/**
* DB
*/
async delete(id: number): Promise<{ success: boolean; message: string }> {
const response = await fetch(`${API_BASE}/${id}`, {
method: "DELETE",
credentials: "include",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "외부 DB 연결 삭제 실패");
}
return response.json();
},
/**
* DB
*/
async testConnection(id: number): Promise<FlowExternalDbConnectionTestResponse> {
const response = await fetch(`${API_BASE}/${id}/test`, {
method: "POST",
credentials: "include",
});
const result = await response.json();
if (!response.ok) {
return {
success: false,
message: result.message || "연결 테스트 실패",
};
}
return result;
},
};

View File

@ -1,28 +0,0 @@
"use client";
import React from "react";
import { AutoRegisteringComponentRenderer } from "../../AutoRegisteringComponentRenderer";
import { FlowWidgetDefinition } from "./index";
import { FlowWidget } from "@/components/screen/widgets/FlowWidget";
/**
* FlowWidget
*
*/
export class FlowWidgetRenderer extends AutoRegisteringComponentRenderer {
static componentDefinition = FlowWidgetDefinition;
render(): React.ReactElement {
return <FlowWidget component={this.props.component as any} />;
}
}
// 자동 등록 실행
FlowWidgetRenderer.registerSelf();
// Hot Reload 지원 (개발 모드)
if (process.env.NODE_ENV === "development") {
FlowWidgetRenderer.enableHotReload();
}
console.log("✅ FlowWidget 컴포넌트 등록 완료");

View File

@ -1,40 +0,0 @@
"use client";
import { createComponentDefinition } from "../../utils/createComponentDefinition";
import { ComponentCategory } from "@/types/component";
import { FlowWidget } from "@/components/screen/widgets/FlowWidget";
import { FlowWidgetConfigPanel } from "@/components/screen/config-panels/FlowWidgetConfigPanel";
/**
* FlowWidget
*
*/
export const FlowWidgetDefinition = createComponentDefinition({
id: "flow-widget",
name: "플로우 위젯",
nameEng: "Flow Widget",
description: "플로우 관리 시스템의 플로우를 화면에 표시합니다",
category: ComponentCategory.DISPLAY,
webType: "text", // 기본 웹타입 (필수)
component: FlowWidget,
defaultConfig: {
flowId: undefined,
flowName: undefined,
showStepCount: true,
allowDataMove: false,
displayMode: "horizontal",
},
defaultSize: {
width: 1200,
height: 120,
gridColumnSpan: "full", // 전체 너비 사용
},
configPanel: FlowWidgetConfigPanel,
icon: "Workflow",
tags: ["플로우", "워크플로우", "프로세스", "상태"],
version: "1.0.0",
author: "개발팀",
documentation: "",
});
// 컴포넌트는 FlowWidgetRenderer에서 자동 등록됩니다

View File

@ -40,7 +40,6 @@ import "./card-display/CardDisplayRenderer";
import "./split-panel-layout/SplitPanelLayoutRenderer";
import "./map/MapRenderer";
import "./repeater-field-group/RepeaterFieldGroupRenderer";
import "./flow-widget/FlowWidgetRenderer";
/**
*

View File

@ -25,7 +25,6 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
"card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"),
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
"flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"),
};
// ConfigPanel 컴포넌트 캐시
@ -55,7 +54,6 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
const ConfigPanelComponent =
module[`${toPascalCase(componentId)}ConfigPanel`] ||
module.RepeaterConfigPanel || // repeater-field-group의 export명
module.FlowWidgetConfigPanel || // flow-widget의 export명
module.default;
if (!ConfigPanelComponent) {

View File

@ -62,7 +62,7 @@
"react-leaflet": "^5.0.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0",
"reactflow": "^11.11.4",
"reactflow": "^11.10.4",
"recharts": "^3.2.1",
"sheetjs-style": "^0.15.8",
"sonner": "^2.0.7",
@ -2678,12 +2678,12 @@
}
},
"node_modules/@reactflow/background": {
"version": "11.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
"version": "11.3.9",
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz",
"integrity": "sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@reactflow/core": "11.10.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
@ -2693,12 +2693,12 @@
}
},
"node_modules/@reactflow/controls": {
"version": "11.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
"version": "11.2.9",
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.9.tgz",
"integrity": "sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@reactflow/core": "11.10.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
@ -2708,9 +2708,9 @@
}
},
"node_modules/@reactflow/core": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
"version": "11.10.4",
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.10.4.tgz",
"integrity": "sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==",
"license": "MIT",
"dependencies": {
"@types/d3": "^7.4.0",
@ -2729,12 +2729,12 @@
}
},
"node_modules/@reactflow/minimap": {
"version": "11.7.14",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
"version": "11.7.9",
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.9.tgz",
"integrity": "sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@reactflow/core": "11.10.4",
"@types/d3-selection": "^3.0.3",
"@types/d3-zoom": "^3.0.1",
"classcat": "^5.0.3",
@ -2748,12 +2748,12 @@
}
},
"node_modules/@reactflow/node-resizer": {
"version": "2.2.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
"version": "2.2.9",
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.9.tgz",
"integrity": "sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@reactflow/core": "11.10.4",
"classcat": "^5.0.4",
"d3-drag": "^3.0.0",
"d3-selection": "^3.0.0",
@ -2765,12 +2765,12 @@
}
},
"node_modules/@reactflow/node-toolbar": {
"version": "1.3.14",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
"version": "1.3.9",
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.9.tgz",
"integrity": "sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==",
"license": "MIT",
"dependencies": {
"@reactflow/core": "11.11.4",
"@reactflow/core": "11.10.4",
"classcat": "^5.0.3",
"zustand": "^4.4.1"
},
@ -9526,17 +9526,17 @@
}
},
"node_modules/reactflow": {
"version": "11.11.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
"version": "11.10.4",
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.10.4.tgz",
"integrity": "sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==",
"license": "MIT",
"dependencies": {
"@reactflow/background": "11.3.14",
"@reactflow/controls": "11.2.14",
"@reactflow/core": "11.11.4",
"@reactflow/minimap": "11.7.14",
"@reactflow/node-resizer": "2.2.14",
"@reactflow/node-toolbar": "1.3.14"
"@reactflow/background": "11.3.9",
"@reactflow/controls": "11.2.9",
"@reactflow/core": "11.10.4",
"@reactflow/minimap": "11.7.9",
"@reactflow/node-resizer": "2.2.9",
"@reactflow/node-toolbar": "1.3.9"
},
"peerDependencies": {
"react": ">=17",

View File

@ -70,7 +70,7 @@
"react-leaflet": "^5.0.0",
"react-resizable-panels": "^3.0.6",
"react-window": "^2.1.0",
"reactflow": "^11.11.4",
"reactflow": "^11.10.4",
"recharts": "^3.2.1",
"sheetjs-style": "^0.15.8",
"sonner": "^2.0.7",

View File

@ -1,225 +0,0 @@
/**
* -
*/
// ============================================
// 조건 연산자
// ============================================
export type ConditionOperator =
| "equals"
| "not_equals"
| "in"
| "not_in"
| "greater_than"
| "less_than"
| "greater_than_or_equal"
| "less_than_or_equal"
| "is_null"
| "is_not_null"
| "like"
| "not_like";
// ============================================
// 플로우 조건
// ============================================
export interface FlowCondition {
column: string;
operator: ConditionOperator;
value?: any;
}
export interface FlowConditionGroup {
type: "AND" | "OR";
conditions: FlowCondition[];
}
// ============================================
// 플로우 정의
// ============================================
export interface FlowDefinition {
id: number;
name: string;
description?: string;
tableName: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
}
export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
}
export interface UpdateFlowDefinitionRequest {
name?: string;
description?: string;
tableName?: string;
isActive?: boolean;
}
// ============================================
// 플로우 단계
// ============================================
export interface FlowStep {
id: number;
flowDefinitionId: number;
stepName: string;
stepOrder: number;
tableName?: string; // 이 단계에서 조회할 테이블명
conditionJson?: FlowConditionGroup;
color: string;
positionX: number;
positionY: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
}
export interface CreateFlowStepRequest {
stepName: string;
stepOrder: number;
tableName?: string; // 이 단계에서 조회할 테이블명
conditionJson?: FlowConditionGroup;
color?: string;
positionX?: number;
positionY?: number;
}
export interface UpdateFlowStepRequest {
stepName?: string;
tableName?: string; // 이 단계에서 조회할 테이블명
stepOrder?: number;
conditionJson?: FlowConditionGroup;
color?: string;
positionX?: number;
positionY?: number;
}
// ============================================
// 플로우 단계 연결
// ============================================
export interface FlowStepConnection {
id: number;
flowDefinitionId: number;
fromStepId: number;
toStepId: number;
label?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateFlowConnectionRequest {
flowDefinitionId: number;
fromStepId: number;
toStepId: number;
label?: string;
}
// ============================================
// 플로우 데이터 상태
// ============================================
export interface FlowDataStatus {
id: number;
flowDefinitionId: number;
tableName: string;
recordId: string;
currentStepId: number | null;
updatedAt: string;
updatedBy: string;
}
// ============================================
// 플로우 오딧 로그
// ============================================
export interface FlowAuditLog {
id: number;
flowDefinitionId: number;
tableName: string;
recordId: string;
fromStepId: number | null;
toStepId: number | null;
changedAt: string;
changedBy: string;
note?: string;
fromStepName?: string;
toStepName?: string;
}
// ============================================
// 플로우 실행 관련
// ============================================
export interface FlowStepDataCount {
stepId: number;
stepName: string;
count: number;
}
export interface FlowStepDataList {
records: any[];
total: number;
page: number;
pageSize: number;
}
export interface MoveDataRequest {
flowId: number;
recordId: string;
toStepId: number;
note?: string;
}
export interface MoveBatchDataRequest {
flowId: number;
fromStepId: number;
toStepId: number;
dataIds: string[];
note?: string;
}
// ============================================
// API 응답 타입
// ============================================
export interface ApiResponse<T> {
success: boolean;
data?: T;
message?: string;
error?: string;
}
// ============================================
// React Flow 관련 타입 (비주얼 편집기용)
// ============================================
export interface FlowNodeData {
id: number;
label: string;
stepOrder: number;
tableName?: string;
count?: number;
condition?: FlowConditionGroup;
}
export interface FlowEdgeData {
id: number;
label?: string;
}
// ============================================
// UI 컴포넌트용 타입
// ============================================
export interface FlowListItem extends FlowDefinition {
stepsCount?: number;
lastUpdated?: string;
}
export interface FlowEditorState {
flowDefinition: FlowDefinition | null;
steps: FlowStep[];
connections: FlowStepConnection[];
selectedStepId: number | null;
isEditMode: boolean;
}

View File

@ -1,149 +0,0 @@
/**
* DB
* ( DB와 )
*/
// ==================== 연동 타입 ====================
export type FlowIntegrationType = "internal" | "external_db" | "rest_api" | "webhook" | "hybrid";
// ==================== 외부 DB 연결 ====================
export interface FlowExternalDbConnection {
id: number;
name: string;
description?: string;
dbType: "postgresql" | "mysql" | "mssql" | "oracle";
host: string;
port: number;
databaseName: string;
username: string;
passwordEncrypted: string; // 암호화된 비밀번호 (화면에는 표시하지 않음)
sslEnabled: boolean;
connectionOptions?: Record<string, any>;
isActive: boolean;
createdBy?: string;
updatedBy?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateFlowExternalDbConnectionRequest {
name: string;
description?: string;
dbType: "postgresql" | "mysql" | "mssql" | "oracle";
host: string;
port: number;
databaseName: string;
username: string;
password: string; // 평문 비밀번호 (생성 시에만 사용)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
}
export interface UpdateFlowExternalDbConnectionRequest {
name?: string;
description?: string;
host?: string;
port?: number;
databaseName?: string;
username?: string;
password?: string; // 평문 비밀번호 (변경 시에만)
sslEnabled?: boolean;
connectionOptions?: Record<string, any>;
isActive?: boolean;
}
// ==================== 외부 DB 연동 설정 ====================
export interface FlowExternalDbIntegrationConfig {
type: "external_db";
connectionId: number; // 연결 ID
operation: "update" | "insert" | "delete" | "custom";
tableName: string;
updateFields?: Record<string, any>; // 업데이트할 필드
whereCondition?: Record<string, any>; // WHERE 조건
customQuery?: string; // 커스텀 쿼리
}
// 연동 설정 통합 타입
export type FlowIntegrationConfig = FlowExternalDbIntegrationConfig;
// ==================== 연동 로그 ====================
export interface FlowIntegrationLog {
id: number;
flowDefinitionId: number;
stepId: number;
dataId?: string;
integrationType: string;
connectionId?: number;
requestPayload?: Record<string, any>;
responsePayload?: Record<string, any>;
status: "success" | "failed" | "timeout" | "rollback";
errorMessage?: string;
executionTimeMs?: number;
executedBy?: string;
executedAt: string;
}
// ==================== API 응답 ====================
export interface FlowExternalDbConnectionListResponse {
success: boolean;
data: FlowExternalDbConnection[];
message?: string;
}
export interface FlowExternalDbConnectionResponse {
success: boolean;
data?: FlowExternalDbConnection;
message?: string;
error?: string;
}
export interface FlowExternalDbConnectionTestResponse {
success: boolean;
message: string;
}
// ==================== UI 관련 ====================
export const DB_TYPE_OPTIONS = [
{ value: "postgresql", label: "PostgreSQL" },
{ value: "mysql", label: "MySQL" },
{ value: "mssql", label: "MS SQL Server" },
{ value: "oracle", label: "Oracle" },
] as const;
export const OPERATION_OPTIONS = [
{ value: "update", label: "업데이트 (UPDATE)" },
{ value: "insert", label: "삽입 (INSERT)" },
{ value: "delete", label: "삭제 (DELETE)" },
{ value: "custom", label: "커스텀 쿼리" },
] as const;
export const INTEGRATION_TYPE_OPTIONS = [
{ value: "internal", label: "내부 DB (기본)" },
{ value: "external_db", label: "외부 DB 연동" },
{ value: "rest_api", label: "REST API (추후 지원)" },
{ value: "webhook", label: "Webhook (추후 지원)" },
{ value: "hybrid", label: "복합 연동 (추후 지원)" },
] as const;
// ==================== 헬퍼 함수 ====================
export function getDbTypeLabel(dbType: string): string {
const option = DB_TYPE_OPTIONS.find((opt) => opt.value === dbType);
return option?.label || dbType;
}
export function getOperationLabel(operation: string): string {
const option = OPERATION_OPTIONS.find((opt) => opt.value === operation);
return option?.label || operation;
}
export function getIntegrationTypeLabel(type: string): string {
const option = INTEGRATION_TYPE_OPTIONS.find((opt) => opt.value === type);
return option?.label || type;
}

View File

@ -135,18 +135,6 @@ export interface FileComponent extends BaseComponent {
lastFileUpdate?: number;
}
/**
*
*/
export interface FlowComponent extends BaseComponent {
type: "flow";
flowId?: number; // 선택된 플로우 ID
flowName?: string; // 플로우 이름 (표시용)
showStepCount?: boolean; // 각 스텝의 데이터 건수 표시 여부
allowDataMove?: boolean; // 데이터 이동 허용 여부
displayMode?: "horizontal" | "vertical"; // 플로우 표시 방향
}
/**
*
*/
@ -166,7 +154,6 @@ export type ComponentData =
| GroupComponent
| DataTableComponent
| FileComponent
| FlowComponent
| ComponentComponent;
// ===== 웹타입별 설정 인터페이스 =====
@ -619,13 +606,6 @@ export const isFileComponent = (component: ComponentData): component is FileComp
return component.type === "file";
};
/**
* FlowComponent
*/
export const isFlowComponent = (component: ComponentData): component is FlowComponent => {
return component.type === "flow";
};
// ===== 안전한 타입 캐스팅 유틸리티 =====
/**
@ -677,13 +657,3 @@ export const asFileComponent = (component: ComponentData): FileComponent => {
}
return component;
};
/**
* ComponentData를 FlowComponent로
*/
export const asFlowComponent = (component: ComponentData): FlowComponent => {
if (!isFlowComponent(component)) {
throw new Error(`Expected FlowComponent, got ${component.type}`);
}
return component;
};

View File

@ -81,9 +81,7 @@ export type ComponentType =
| "datatable"
| "file"
| "area"
| "layout"
| "flow"
| "component";
| "layout";
/**
*