Merge branch 'main' into lhj - Flow management system integration
This commit is contained in:
commit
12e5d99339
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,174 @@
|
||||||
|
/**
|
||||||
|
* 외부 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);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
/**
|
||||||
|
* 비밀번호 암호화 유틸리티
|
||||||
|
*/
|
||||||
|
|
||||||
|
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");
|
||||||
|
|
@ -56,6 +56,9 @@ import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
||||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
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 workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
|
|
@ -207,6 +210,9 @@ app.use("/api/todos", todoRoutes); // To-Do 관리
|
||||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
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/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,676 @@
|
||||||
|
/**
|
||||||
|
* 플로우 관리 컨트롤러
|
||||||
|
*/
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,328 @@
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
/**
|
||||||
|
* 플로우 관리 라우터
|
||||||
|
*/
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
@ -0,0 +1,215 @@
|
||||||
|
/**
|
||||||
|
* 플로우 조건 파서
|
||||||
|
* 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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
/**
|
||||||
|
* 플로우 연결 서비스
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,593 @@
|
||||||
|
/**
|
||||||
|
* 플로우 데이터 이동 서비스 (하이브리드 방식 지원)
|
||||||
|
* - 상태 변경 방식: 같은 테이블 내에서 상태 컬럼 업데이트
|
||||||
|
* - 테이블 이동 방식: 다른 테이블로 데이터 복사 및 매핑
|
||||||
|
* - 하이브리드 방식: 두 가지 모두 수행
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
/**
|
||||||
|
* 플로우 정의 서비스
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
/**
|
||||||
|
* 플로우 실행 서비스
|
||||||
|
* 단계별 데이터 카운트 및 리스트 조회
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,436 @@
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,353 @@
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,289 @@
|
||||||
|
/**
|
||||||
|
* 플로우 단계 서비스
|
||||||
|
*/
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,345 @@
|
||||||
|
/**
|
||||||
|
* 플로우 관리 시스템 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 플로우 정의
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
# syntax=docker/dockerfile:1
|
|
||||||
|
|
||||||
# Base image (Debian-based for glibc + OpenSSL compatibility)
|
# Base image (Debian-based for glibc + OpenSSL compatibility)
|
||||||
FROM node:20-bookworm-slim AS base
|
FROM node:20-bookworm-slim AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
# 플로우 데이터 구조 설계 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
플로우 관리 시스템에서 각 단계별로 테이블 구조가 다른 경우의 데이터 관리 방법
|
||||||
|
|
||||||
|
## 추천 아키텍처: 하이브리드 접근
|
||||||
|
|
||||||
|
### 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`로 구분
|
||||||
|
- 단계별 추가 정보는 별도 상세 테이블에 저장 (선택적)
|
||||||
|
- 데이터 이동은 상태값 업데이트만으로 간단하게 처리
|
||||||
|
- 완전한 감사 로그와 이력 추적 가능
|
||||||
|
|
@ -0,0 +1,762 @@
|
||||||
|
# 플로우 관리 시스템 - 외부 연동 확장 계획
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
현재 플로우 관리 시스템은 내부 데이터베이스의 상태 변경만 지원합니다.
|
||||||
|
실제 업무 환경에서는 다음과 같은 외부 연동이 필요합니다:
|
||||||
|
|
||||||
|
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. 참고사항
|
||||||
|
|
||||||
|
- 외부 연동 설정은 관리자 권한 필요
|
||||||
|
- 모든 외부 호출은 로그 기록
|
||||||
|
- 타임아웃 및 재시도 정책 필수 설정
|
||||||
|
- 정기적인 연결 상태 모니터링 필요
|
||||||
|
- 보안을 위해 자격 증명은 반드시 암호화
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
# 플로우 하이브리드 모드 사용 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
플로우 관리 시스템은 세 가지 데이터 이동 방식을 지원합니다:
|
||||||
|
|
||||||
|
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 = ?));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
하이브리드 플로우 시스템은 다양한 비즈니스 요구사항에 유연하게 대응할 수 있습니다:
|
||||||
|
|
||||||
|
- 간단한 상태 관리부터
|
||||||
|
- 복잡한 다단계 프로세스까지
|
||||||
|
- 하나의 시스템으로 통합 관리 가능
|
||||||
|
|
@ -0,0 +1,646 @@
|
||||||
|
# 플로우 관리 시스템 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 설계를 도식화했습니다!
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
# 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
|
||||||
|
**검토 상태**: 대기 중
|
||||||
|
|
@ -0,0 +1,384 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,323 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,360 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState, useEffect, use } from "react";
|
import React, { useState, useEffect, use } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { DashboardViewer } from "@/components/dashboard/DashboardViewer";
|
import { DashboardViewer } from "@/components/dashboard/DashboardViewer";
|
||||||
import { DashboardElement } from "@/components/admin/dashboard/types";
|
import { DashboardElement } from "@/components/admin/dashboard/types";
|
||||||
|
|
||||||
|
|
@ -18,7 +17,6 @@ interface DashboardViewPageProps {
|
||||||
* - 전체화면 모드 지원
|
* - 전체화면 모드 지원
|
||||||
*/
|
*/
|
||||||
export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
const router = useRouter();
|
|
||||||
const resolvedParams = use(params);
|
const resolvedParams = use(params);
|
||||||
const [dashboard, setDashboard] = useState<{
|
const [dashboard, setDashboard] = useState<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -35,12 +33,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// 대시보드 데이터 로딩
|
const loadDashboard = React.useCallback(async () => {
|
||||||
useEffect(() => {
|
|
||||||
loadDashboard();
|
|
||||||
}, [resolvedParams.dashboardId]);
|
|
||||||
|
|
||||||
const loadDashboard = async () => {
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
|
@ -50,13 +43,16 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId);
|
const dashboardData = await dashboardApi.getDashboard(resolvedParams.dashboardId);
|
||||||
setDashboard(dashboardData);
|
setDashboard({
|
||||||
|
...dashboardData,
|
||||||
|
elements: dashboardData.elements || [],
|
||||||
|
});
|
||||||
} catch (apiError) {
|
} catch (apiError) {
|
||||||
console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError);
|
console.warn("API 호출 실패, 로컬 스토리지 확인:", apiError);
|
||||||
|
|
||||||
// API 실패 시 로컬 스토리지에서 찾기
|
// API 실패 시 로컬 스토리지에서 찾기
|
||||||
const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
const savedDashboards = JSON.parse(localStorage.getItem("savedDashboards") || "[]");
|
||||||
const savedDashboard = savedDashboards.find((d: any) => d.id === resolvedParams.dashboardId);
|
const savedDashboard = savedDashboards.find((d: { id: string }) => d.id === resolvedParams.dashboardId);
|
||||||
|
|
||||||
if (savedDashboard) {
|
if (savedDashboard) {
|
||||||
setDashboard(savedDashboard);
|
setDashboard(savedDashboard);
|
||||||
|
|
@ -72,7 +68,12 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [resolvedParams.dashboardId]);
|
||||||
|
|
||||||
|
// 대시보드 데이터 로딩
|
||||||
|
useEffect(() => {
|
||||||
|
loadDashboard();
|
||||||
|
}, [loadDashboard]);
|
||||||
|
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
|
|
@ -163,6 +164,7 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
elements={dashboard.elements}
|
elements={dashboard.elements}
|
||||||
dashboardId={dashboard.id}
|
dashboardId={dashboard.id}
|
||||||
backgroundColor={dashboard.settings?.backgroundColor}
|
backgroundColor={dashboard.settings?.backgroundColor}
|
||||||
|
resolution={dashboard.settings?.resolution}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -171,8 +173,33 @@ export default function DashboardViewPage({ params }: DashboardViewPageProps) {
|
||||||
/**
|
/**
|
||||||
* 샘플 대시보드 생성 함수
|
* 샘플 대시보드 생성 함수
|
||||||
*/
|
*/
|
||||||
function generateSampleDashboard(dashboardId: string) {
|
function generateSampleDashboard(dashboardId: string): {
|
||||||
const dashboards: Record<string, any> = {
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
elements: DashboardElement[];
|
||||||
|
settings?: {
|
||||||
|
backgroundColor?: string;
|
||||||
|
resolution?: string;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
} {
|
||||||
|
const dashboards: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
elements: DashboardElement[];
|
||||||
|
settings?: {
|
||||||
|
backgroundColor?: string;
|
||||||
|
resolution?: string;
|
||||||
|
};
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
"sales-overview": {
|
"sales-overview": {
|
||||||
id: "sales-overview",
|
id: "sales-overview",
|
||||||
title: "📊 매출 현황 대시보드",
|
title: "📊 매출 현황 대시보드",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,73 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -141,18 +141,38 @@ export default function DashboardDesigner({ dashboardId: initialDashboardId }: D
|
||||||
const { dashboardApi } = await import("@/lib/api/dashboard");
|
const { dashboardApi } = await import("@/lib/api/dashboard");
|
||||||
const dashboard = await dashboardApi.getDashboard(id);
|
const dashboard = await dashboardApi.getDashboard(id);
|
||||||
|
|
||||||
|
console.log("📊 대시보드 로드:", {
|
||||||
|
id: dashboard.id,
|
||||||
|
title: dashboard.title,
|
||||||
|
settings: dashboard.settings,
|
||||||
|
settingsType: typeof dashboard.settings,
|
||||||
|
});
|
||||||
|
|
||||||
// 대시보드 정보 설정
|
// 대시보드 정보 설정
|
||||||
setDashboardId(dashboard.id);
|
setDashboardId(dashboard.id);
|
||||||
setDashboardTitle(dashboard.title);
|
setDashboardTitle(dashboard.title);
|
||||||
|
|
||||||
// 저장된 설정 복원
|
// 저장된 설정 복원
|
||||||
const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings;
|
const settings = (dashboard as { settings?: { resolution?: Resolution; backgroundColor?: string } }).settings;
|
||||||
|
console.log("🎨 설정 복원:", {
|
||||||
|
settings,
|
||||||
|
resolution: settings?.resolution,
|
||||||
|
backgroundColor: settings?.backgroundColor,
|
||||||
|
currentResolution: resolution,
|
||||||
|
});
|
||||||
|
|
||||||
if (settings?.resolution) {
|
if (settings?.resolution) {
|
||||||
setResolution(settings.resolution);
|
setResolution(settings.resolution);
|
||||||
|
console.log("✅ Resolution 설정됨:", settings.resolution);
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ Resolution 없음, 기본값 유지:", resolution);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (settings?.backgroundColor) {
|
if (settings?.backgroundColor) {
|
||||||
setCanvasBackgroundColor(settings.backgroundColor);
|
setCanvasBackgroundColor(settings.backgroundColor);
|
||||||
|
console.log("✅ BackgroundColor 설정됨:", settings.backgroundColor);
|
||||||
|
} else {
|
||||||
|
console.log("⚠️ BackgroundColor 없음, 기본값 유지:", canvasBackgroundColor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 요소들 설정
|
// 요소들 설정
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,12 @@ export function BarChart({ data, config, width = 600, height = 400 }: BarChartPr
|
||||||
// X축 스케일 (카테고리)
|
// X축 스케일 (카테고리)
|
||||||
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2);
|
const xScale = d3.scaleBand().domain(data.labels).range([0, chartWidth]).padding(0.2);
|
||||||
|
|
||||||
// Y축 스케일 (값)
|
// Y축 스케일 (값) - 절대값 기준
|
||||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
const allValues = data.datasets.flatMap((ds) => ds.data);
|
||||||
|
const maxAbsValue = d3.max(allValues.map((v) => Math.abs(v))) || 0;
|
||||||
const yScale = d3
|
const yScale = d3
|
||||||
.scaleLinear()
|
.scaleLinear()
|
||||||
.domain([0, maxValue * 1.1])
|
.domain([0, maxAbsValue * 1.1])
|
||||||
.range([chartHeight, 0])
|
.range([chartHeight, 0])
|
||||||
.nice();
|
.nice();
|
||||||
|
|
||||||
|
|
@ -49,23 +50,12 @@ export function BarChart({ data, config, width = 600, height = 400 }: BarChartPr
|
||||||
.style("text-anchor", "end")
|
.style("text-anchor", "end")
|
||||||
.style("font-size", "12px");
|
.style("font-size", "12px");
|
||||||
|
|
||||||
// Y축 그리기
|
// Y축 그리기 (값 표시 제거)
|
||||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px");
|
g.append("g")
|
||||||
|
.call(d3.axisLeft(yScale).tickFormat(() => ""))
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
// 그리드 라인
|
// 그리드 라인 제거됨
|
||||||
if (config.showGrid !== false) {
|
|
||||||
g.append("g")
|
|
||||||
.attr("class", "grid")
|
|
||||||
.call(
|
|
||||||
d3
|
|
||||||
.axisLeft(yScale)
|
|
||||||
.tickSize(-chartWidth)
|
|
||||||
.tickFormat(() => ""),
|
|
||||||
)
|
|
||||||
.style("stroke-dasharray", "3,3")
|
|
||||||
.style("stroke", "#e0e0e0")
|
|
||||||
.style("opacity", 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 색상 팔레트
|
// 색상 팔레트
|
||||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||||
|
|
@ -84,18 +74,48 @@ export function BarChart({ data, config, width = 600, height = 400 }: BarChartPr
|
||||||
.attr("y", chartHeight)
|
.attr("y", chartHeight)
|
||||||
.attr("width", barWidth)
|
.attr("width", barWidth)
|
||||||
.attr("height", 0)
|
.attr("height", 0)
|
||||||
.attr("fill", dataset.color || colors[i % colors.length])
|
.attr("fill", (d) => {
|
||||||
|
// 음수면 빨간색 계열, 양수면 원래 색상
|
||||||
|
if (d < 0) {
|
||||||
|
return "#EF4444";
|
||||||
|
}
|
||||||
|
return dataset.color || colors[i % colors.length];
|
||||||
|
})
|
||||||
.attr("rx", 4);
|
.attr("rx", 4);
|
||||||
|
|
||||||
// 애니메이션
|
// 애니메이션 - 절대값 기준으로 위쪽으로만 렌더링
|
||||||
if (config.enableAnimation !== false) {
|
if (config.enableAnimation !== false) {
|
||||||
bars
|
bars
|
||||||
.transition()
|
.transition()
|
||||||
.duration(config.animationDuration || 750)
|
.duration(config.animationDuration || 750)
|
||||||
.attr("y", (d) => yScale(d))
|
.attr("y", (d) => yScale(Math.abs(d)))
|
||||||
.attr("height", (d) => chartHeight - yScale(d));
|
.attr("height", (d) => chartHeight - yScale(Math.abs(d)));
|
||||||
} else {
|
} else {
|
||||||
bars.attr("y", (d) => yScale(d)).attr("height", (d) => chartHeight - yScale(d));
|
bars.attr("y", (d) => yScale(Math.abs(d))).attr("height", (d) => chartHeight - yScale(Math.abs(d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 막대 위에 값 표시 (음수는 - 부호 포함)
|
||||||
|
const labels = g
|
||||||
|
.selectAll(`.label-${i}`)
|
||||||
|
.data(dataset.data)
|
||||||
|
.enter()
|
||||||
|
.append("text")
|
||||||
|
.attr("class", `label-${i}`)
|
||||||
|
.attr("x", (_, j) => (xScale(data.labels[j]) || 0) + barWidth * i + barWidth / 2)
|
||||||
|
.attr("y", (d) => yScale(Math.abs(d)) - 5)
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.style("font-size", "11px")
|
||||||
|
.style("font-weight", "500")
|
||||||
|
.style("fill", (d) => (d < 0 ? "#EF4444" : "#333"))
|
||||||
|
.text((d) => (d < 0 ? "-" : "") + Math.abs(d).toLocaleString());
|
||||||
|
|
||||||
|
// 애니메이션 (라벨)
|
||||||
|
if (config.enableAnimation !== false) {
|
||||||
|
labels
|
||||||
|
.style("opacity", 0)
|
||||||
|
.transition()
|
||||||
|
.duration(config.animationDuration || 750)
|
||||||
|
.style("opacity", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 툴팁
|
// 툴팁
|
||||||
|
|
|
||||||
|
|
@ -32,37 +32,25 @@ export function HorizontalBarChart({ data, config, width = 600, height = 400 }:
|
||||||
// Y축 스케일 (카테고리) - 수평이므로 Y축이 카테고리
|
// Y축 스케일 (카테고리) - 수평이므로 Y축이 카테고리
|
||||||
const yScale = d3.scaleBand().domain(data.labels).range([0, chartHeight]).padding(0.2);
|
const yScale = d3.scaleBand().domain(data.labels).range([0, chartHeight]).padding(0.2);
|
||||||
|
|
||||||
// X축 스케일 (값) - 수평이므로 X축이 값
|
// X축 스케일 (값) - 수평이므로 X축이 값, 절대값 기준
|
||||||
const maxValue = d3.max(data.datasets.flatMap((ds) => ds.data)) || 0;
|
const allValues = data.datasets.flatMap((ds) => ds.data);
|
||||||
|
const maxAbsValue = d3.max(allValues.map((v) => Math.abs(v))) || 0;
|
||||||
const xScale = d3
|
const xScale = d3
|
||||||
.scaleLinear()
|
.scaleLinear()
|
||||||
.domain([0, maxValue * 1.1])
|
.domain([0, maxAbsValue * 1.1])
|
||||||
.range([0, chartWidth])
|
.range([0, chartWidth])
|
||||||
.nice();
|
.nice();
|
||||||
|
|
||||||
// Y축 그리기 (카테고리)
|
// Y축 그리기 (카테고리)
|
||||||
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px").selectAll("text").style("text-anchor", "end");
|
g.append("g").call(d3.axisLeft(yScale)).style("font-size", "12px").selectAll("text").style("text-anchor", "end");
|
||||||
|
|
||||||
// X축 그리기 (값)
|
// X축 그리기 (값 표시 제거)
|
||||||
g.append("g")
|
g.append("g")
|
||||||
.attr("transform", `translate(0,${chartHeight})`)
|
.attr("transform", `translate(0,${chartHeight})`)
|
||||||
.call(d3.axisBottom(xScale))
|
.call(d3.axisBottom(xScale).tickFormat(() => ""))
|
||||||
.style("font-size", "12px");
|
.style("font-size", "12px");
|
||||||
|
|
||||||
// 그리드 라인
|
// 그리드 라인 제거됨
|
||||||
if (config.showGrid !== false) {
|
|
||||||
g.append("g")
|
|
||||||
.attr("class", "grid")
|
|
||||||
.call(
|
|
||||||
d3
|
|
||||||
.axisBottom(xScale)
|
|
||||||
.tickSize(chartHeight)
|
|
||||||
.tickFormat(() => ""),
|
|
||||||
)
|
|
||||||
.style("stroke-dasharray", "3,3")
|
|
||||||
.style("stroke", "#e0e0e0")
|
|
||||||
.style("opacity", 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 색상 팔레트
|
// 색상 팔레트
|
||||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||||
|
|
@ -81,17 +69,49 @@ export function HorizontalBarChart({ data, config, width = 600, height = 400 }:
|
||||||
.attr("y", (_, j) => (yScale(data.labels[j]) || 0) + barHeight * i)
|
.attr("y", (_, j) => (yScale(data.labels[j]) || 0) + barHeight * i)
|
||||||
.attr("width", 0)
|
.attr("width", 0)
|
||||||
.attr("height", barHeight)
|
.attr("height", barHeight)
|
||||||
.attr("fill", dataset.color || colors[i % colors.length])
|
.attr("fill", (d) => {
|
||||||
|
// 음수면 빨간색 계열, 양수면 원래 색상
|
||||||
|
if (d < 0) {
|
||||||
|
return "#EF4444";
|
||||||
|
}
|
||||||
|
return dataset.color || colors[i % colors.length];
|
||||||
|
})
|
||||||
.attr("ry", 4);
|
.attr("ry", 4);
|
||||||
|
|
||||||
// 애니메이션
|
// 애니메이션 - 절대값 기준으로 오른쪽으로만 렌더링
|
||||||
if (config.enableAnimation !== false) {
|
if (config.enableAnimation !== false) {
|
||||||
bars
|
bars
|
||||||
.transition()
|
.transition()
|
||||||
.duration(config.animationDuration || 750)
|
.duration(config.animationDuration || 750)
|
||||||
.attr("width", (d) => xScale(d));
|
.attr("x", 0)
|
||||||
|
.attr("width", (d) => xScale(Math.abs(d)));
|
||||||
} else {
|
} else {
|
||||||
bars.attr("width", (d) => xScale(d));
|
bars.attr("x", 0).attr("width", (d) => xScale(Math.abs(d)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 막대 끝에 값 표시 (음수는 - 부호 포함)
|
||||||
|
const labels = g
|
||||||
|
.selectAll(`.label-${i}`)
|
||||||
|
.data(dataset.data)
|
||||||
|
.enter()
|
||||||
|
.append("text")
|
||||||
|
.attr("class", `label-${i}`)
|
||||||
|
.attr("x", (d) => xScale(Math.abs(d)) + 5)
|
||||||
|
.attr("y", (_, j) => (yScale(data.labels[j]) || 0) + barHeight * i + barHeight / 2)
|
||||||
|
.attr("text-anchor", "start")
|
||||||
|
.attr("dominant-baseline", "middle")
|
||||||
|
.style("font-size", "11px")
|
||||||
|
.style("font-weight", "500")
|
||||||
|
.style("fill", (d) => (d < 0 ? "#EF4444" : "#333"))
|
||||||
|
.text((d) => (d < 0 ? "-" : "") + Math.abs(d).toLocaleString());
|
||||||
|
|
||||||
|
// 애니메이션 (라벨)
|
||||||
|
if (config.enableAnimation !== false) {
|
||||||
|
labels
|
||||||
|
.style("opacity", 0)
|
||||||
|
.transition()
|
||||||
|
.duration(config.animationDuration || 750)
|
||||||
|
.style("opacity", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 툴팁
|
// 툴팁
|
||||||
|
|
|
||||||
|
|
@ -66,24 +66,12 @@ export function StackedBarChart({ data, config, width = 600, height = 400 }: Sta
|
||||||
.style("text-anchor", "end")
|
.style("text-anchor", "end")
|
||||||
.style("font-size", "12px");
|
.style("font-size", "12px");
|
||||||
|
|
||||||
// Y축 그리기
|
// Y축 그리기 (값 표시 제거)
|
||||||
const yAxis = config.stackMode === "percent" ? d3.axisLeft(yScale).tickFormat((d) => `${d}%`) : d3.axisLeft(yScale);
|
g.append("g")
|
||||||
g.append("g").call(yAxis).style("font-size", "12px");
|
.call(d3.axisLeft(yScale).tickFormat(() => ""))
|
||||||
|
.style("font-size", "12px");
|
||||||
|
|
||||||
// 그리드 라인
|
// 그리드 라인 제거됨
|
||||||
if (config.showGrid !== false) {
|
|
||||||
g.append("g")
|
|
||||||
.attr("class", "grid")
|
|
||||||
.call(
|
|
||||||
d3
|
|
||||||
.axisLeft(yScale)
|
|
||||||
.tickSize(-chartWidth)
|
|
||||||
.tickFormat(() => ""),
|
|
||||||
)
|
|
||||||
.style("stroke-dasharray", "3,3")
|
|
||||||
.style("stroke", "#e0e0e0")
|
|
||||||
.style("opacity", 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 색상 팔레트
|
// 색상 팔레트
|
||||||
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
const colors = config.colors || ["#3B82F6", "#EF4444", "#10B981", "#F59E0B"];
|
||||||
|
|
@ -131,6 +119,47 @@ export function StackedBarChart({ data, config, width = 600, height = 400 }: Sta
|
||||||
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
.attr("height", (d) => yScale(d[0] as number) - yScale(d[1] as number));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 각 세그먼트에 값 표시
|
||||||
|
layers.each(function (layerData, layerIndex) {
|
||||||
|
d3.select(this)
|
||||||
|
.selectAll("text")
|
||||||
|
.data(layerData)
|
||||||
|
.enter()
|
||||||
|
.append("text")
|
||||||
|
.attr("x", (d) => (xScale((d.data as any).label) || 0) + xScale.bandwidth() / 2)
|
||||||
|
.attr("y", (d) => {
|
||||||
|
const segmentHeight = yScale(d[0] as number) - yScale(d[1] as number);
|
||||||
|
const segmentMiddle = yScale(d[1] as number) + segmentHeight / 2;
|
||||||
|
return segmentMiddle;
|
||||||
|
})
|
||||||
|
.attr("text-anchor", "middle")
|
||||||
|
.attr("dominant-baseline", "middle")
|
||||||
|
.style("font-size", "11px")
|
||||||
|
.style("font-weight", "500")
|
||||||
|
.style("fill", "white")
|
||||||
|
.style("pointer-events", "none")
|
||||||
|
.text((d) => {
|
||||||
|
const value = (d[1] as number) - (d[0] as number);
|
||||||
|
if (config.stackMode === "percent") {
|
||||||
|
return value > 5 ? `${value.toFixed(0)}%` : "";
|
||||||
|
}
|
||||||
|
return value > 0 ? value.toLocaleString() : "";
|
||||||
|
})
|
||||||
|
.style("opacity", 0);
|
||||||
|
|
||||||
|
// 애니메이션 (라벨)
|
||||||
|
if (config.enableAnimation !== false) {
|
||||||
|
d3.select(this)
|
||||||
|
.selectAll("text")
|
||||||
|
.transition()
|
||||||
|
.delay(config.animationDuration || 750)
|
||||||
|
.duration(300)
|
||||||
|
.style("opacity", 1);
|
||||||
|
} else {
|
||||||
|
d3.select(this).selectAll("text").style("opacity", 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 툴팁
|
// 툴팁
|
||||||
if (config.showTooltip !== false) {
|
if (config.showTooltip !== false) {
|
||||||
bars
|
bars
|
||||||
|
|
|
||||||
|
|
@ -95,24 +95,21 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 쿼리 실행 결과 처리
|
// 쿼리 실행 결과 처리
|
||||||
const handleQueryTest = useCallback(
|
const handleQueryTest = useCallback((result: QueryResult) => {
|
||||||
(result: QueryResult) => {
|
setQueryResult(result);
|
||||||
setQueryResult(result);
|
|
||||||
|
|
||||||
// 자동 모드이고 기존 컬럼이 없을 때만 자동 생성
|
// 쿼리 실행할 때마다 컬럼 초기화 후 자동 생성
|
||||||
if (listConfig.columnMode === "auto" && result.columns.length > 0 && listConfig.columns.length === 0) {
|
if (result.columns.length > 0) {
|
||||||
const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({
|
const autoColumns: ListColumn[] = result.columns.map((col, idx) => ({
|
||||||
id: `col_${idx}`,
|
id: `col_${idx}`,
|
||||||
label: col,
|
label: col,
|
||||||
field: col,
|
field: col,
|
||||||
align: "left",
|
align: "left",
|
||||||
visible: true,
|
visible: true,
|
||||||
}));
|
}));
|
||||||
setListConfig((prev) => ({ ...prev, columns: autoColumns }));
|
setListConfig((prev) => ({ ...prev, columns: autoColumns }));
|
||||||
}
|
}
|
||||||
},
|
}, []);
|
||||||
[listConfig.columnMode, listConfig.columns.length],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 다음 단계
|
// 다음 단계
|
||||||
const handleNext = () => {
|
const handleNext = () => {
|
||||||
|
|
@ -176,9 +173,7 @@ export function ListWidgetConfigModal({ isOpen, element, onClose, onSave }: List
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
|
{/* 참고: 리스트 위젯은 제목이 항상 표시됩니다 */}
|
||||||
<div className="rounded bg-blue-50 p-2 text-xs text-blue-700">
|
<div className="rounded bg-blue-50 p-2 text-xs text-blue-700">💡 리스트 위젯은 제목이 항상 표시됩니다</div>
|
||||||
💡 리스트 위젯은 제목이 항상 표시됩니다
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 진행 상태 표시 */}
|
{/* 진행 상태 표시 */}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { ListColumn } from "../../types";
|
import { ListColumn } from "../../types";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -21,8 +21,12 @@ interface ColumnSelectorProps {
|
||||||
* - 쿼리 결과에서 컬럼 선택
|
* - 쿼리 결과에서 컬럼 선택
|
||||||
* - 컬럼명 변경
|
* - 컬럼명 변경
|
||||||
* - 정렬, 너비, 정렬 방향 설정
|
* - 정렬, 너비, 정렬 방향 설정
|
||||||
|
* - 드래그 앤 드롭으로 순서 변경
|
||||||
*/
|
*/
|
||||||
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
export function ColumnSelector({ availableColumns, selectedColumns, sampleData, onChange }: ColumnSelectorProps) {
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// 컬럼 선택/해제
|
// 컬럼 선택/해제
|
||||||
const handleToggle = (field: string) => {
|
const handleToggle = (field: string) => {
|
||||||
const exists = selectedColumns.find((col) => col.field === field);
|
const exists = selectedColumns.find((col) => col.field === field);
|
||||||
|
|
@ -50,17 +54,53 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
||||||
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
onChange(selectedColumns.map((col) => (col.field === field ? { ...col, align } : col)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그 시작
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 오버 - 실시간으로 순서 변경하여 UI 업데이트
|
||||||
|
const handleDragOver = (e: React.DragEvent, hoverIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||||
|
|
||||||
|
setDragOverIndex(hoverIndex);
|
||||||
|
|
||||||
|
const newColumns = [...selectedColumns];
|
||||||
|
const draggedItem = newColumns[draggedIndex];
|
||||||
|
newColumns.splice(draggedIndex, 1);
|
||||||
|
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
setDraggedIndex(hoverIndex);
|
||||||
|
onChange(newColumns);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드롭
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 종료
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
<h3 className="text-lg font-semibold text-gray-800">컬럼 선택 및 설정</h3>
|
||||||
<p className="text-sm text-gray-600">표시할 컬럼을 선택하고 이름을 변경하세요</p>
|
<p className="text-sm text-gray-600">
|
||||||
|
표시할 컬럼을 선택하고 이름을 변경하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{availableColumns.map((field) => {
|
{/* 선택된 컬럼을 먼저 순서대로 표시 */}
|
||||||
const selectedCol = selectedColumns.find((col) => col.field === field);
|
{selectedColumns.map((selectedCol, columnIndex) => {
|
||||||
const isSelected = !!selectedCol;
|
const field = selectedCol.field;
|
||||||
const preview = sampleData[field];
|
const preview = sampleData[field];
|
||||||
const previewText =
|
const previewText =
|
||||||
preview !== undefined && preview !== null
|
preview !== undefined && preview !== null
|
||||||
|
|
@ -68,19 +108,36 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
||||||
? JSON.stringify(preview).substring(0, 30)
|
? JSON.stringify(preview).substring(0, 30)
|
||||||
: String(preview).substring(0, 30)
|
: String(preview).substring(0, 30)
|
||||||
: "";
|
: "";
|
||||||
|
const isSelected = true;
|
||||||
|
const isDraggable = true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={field}
|
key={field}
|
||||||
className={`rounded-lg border p-4 transition-colors ${
|
draggable={isDraggable}
|
||||||
|
onDragStart={(e) => {
|
||||||
|
if (isDraggable) {
|
||||||
|
handleDragStart(columnIndex);
|
||||||
|
e.currentTarget.style.cursor = "grabbing";
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => isDraggable && handleDragOver(e, columnIndex)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
handleDragEnd();
|
||||||
|
e.currentTarget.style.cursor = "grab";
|
||||||
|
}}
|
||||||
|
className={`rounded-lg border p-4 transition-all ${
|
||||||
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
isSelected ? "border-blue-300 bg-blue-50" : "border-gray-200"
|
||||||
|
} ${isDraggable ? "cursor-grab active:cursor-grabbing" : ""} ${
|
||||||
|
draggedIndex === columnIndex ? "opacity-50" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="mb-3 flex items-start gap-3">
|
<div className="mb-3 flex items-start gap-3">
|
||||||
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
<Checkbox checked={isSelected} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
<GripVertical className={`h-4 w-4 ${isDraggable ? "text-blue-500" : "text-gray-400"}`} />
|
||||||
<span className="font-medium text-gray-700">{field}</span>
|
<span className="font-medium text-gray-700">{field}</span>
|
||||||
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -122,6 +179,36 @@ export function ColumnSelector({ availableColumns, selectedColumns, sampleData,
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* 선택되지 않은 컬럼들을 아래에 표시 */}
|
||||||
|
{availableColumns
|
||||||
|
.filter((field) => !selectedColumns.find((col) => col.field === field))
|
||||||
|
.map((field) => {
|
||||||
|
const preview = sampleData[field];
|
||||||
|
const previewText =
|
||||||
|
preview !== undefined && preview !== null
|
||||||
|
? typeof preview === "object"
|
||||||
|
? JSON.stringify(preview).substring(0, 30)
|
||||||
|
: String(preview).substring(0, 30)
|
||||||
|
: "";
|
||||||
|
const isSelected = false;
|
||||||
|
const isDraggable = false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={field} className={`rounded-lg border border-gray-200 p-4 transition-all`}>
|
||||||
|
<div className="mb-3 flex items-start gap-3">
|
||||||
|
<Checkbox checked={false} onCheckedChange={() => handleToggle(field)} className="mt-1" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<GripVertical className="h-4 w-4 text-gray-400" />
|
||||||
|
<span className="font-medium text-gray-700">{field}</span>
|
||||||
|
{previewText && <span className="text-xs text-gray-500">(예: {previewText})</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{selectedColumns.length === 0 && (
|
{selectedColumns.length === 0 && (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React from "react";
|
import React, { useState } from "react";
|
||||||
import { ListColumn } from "../../types";
|
import { ListColumn } from "../../types";
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
@ -19,8 +19,12 @@ interface ManualColumnEditorProps {
|
||||||
* 수동 컬럼 편집 컴포넌트
|
* 수동 컬럼 편집 컴포넌트
|
||||||
* - 사용자가 직접 컬럼 추가/삭제
|
* - 사용자가 직접 컬럼 추가/삭제
|
||||||
* - 컬럼명과 데이터 필드 직접 매핑
|
* - 컬럼명과 데이터 필드 직접 매핑
|
||||||
|
* - 드래그 앤 드롭으로 순서 변경
|
||||||
*/
|
*/
|
||||||
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
export function ManualColumnEditor({ availableFields, columns, onChange }: ManualColumnEditorProps) {
|
||||||
|
const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
// 새 컬럼 추가
|
// 새 컬럼 추가
|
||||||
const handleAddColumn = () => {
|
const handleAddColumn = () => {
|
||||||
const newCol: ListColumn = {
|
const newCol: ListColumn = {
|
||||||
|
|
@ -43,12 +47,48 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
||||||
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
onChange(columns.map((col) => (col.id === id ? { ...col, ...updates } : col)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 드래그 시작
|
||||||
|
const handleDragStart = (index: number) => {
|
||||||
|
setDraggedIndex(index);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 오버 - 실시간으로 순서 변경하여 UI 업데이트
|
||||||
|
const handleDragOver = (e: React.DragEvent, hoverIndex: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (draggedIndex === null || draggedIndex === hoverIndex) return;
|
||||||
|
|
||||||
|
setDragOverIndex(hoverIndex);
|
||||||
|
|
||||||
|
const newColumns = [...columns];
|
||||||
|
const draggedItem = newColumns[draggedIndex];
|
||||||
|
newColumns.splice(draggedIndex, 1);
|
||||||
|
newColumns.splice(hoverIndex, 0, draggedItem);
|
||||||
|
|
||||||
|
setDraggedIndex(hoverIndex);
|
||||||
|
onChange(newColumns);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드롭
|
||||||
|
const handleDrop = (e: React.DragEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 드래그 종료
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggedIndex(null);
|
||||||
|
setDragOverIndex(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="p-4">
|
<Card className="p-4">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
<h3 className="text-lg font-semibold text-gray-800">수동 컬럼 편집</h3>
|
||||||
<p className="text-sm text-gray-600">직접 컬럼을 추가하고 데이터 필드를 매핑하세요</p>
|
<p className="text-sm text-gray-600">
|
||||||
|
직접 컬럼을 추가하고 데이터 필드를 매핑하세요. 드래그하여 순서를 변경할 수 있습니다.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
<Button onClick={handleAddColumn} size="sm" className="gap-2">
|
||||||
<Plus className="h-4 w-4" />
|
<Plus className="h-4 w-4" />
|
||||||
|
|
@ -58,9 +98,25 @@ export function ManualColumnEditor({ availableFields, columns, onChange }: Manua
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{columns.map((col, index) => (
|
{columns.map((col, index) => (
|
||||||
<div key={col.id} className="rounded-lg border border-gray-200 bg-gray-50 p-4">
|
<div
|
||||||
|
key={col.id}
|
||||||
|
draggable
|
||||||
|
onDragStart={(e) => {
|
||||||
|
handleDragStart(index);
|
||||||
|
e.currentTarget.style.cursor = "grabbing";
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => handleDragOver(e, index)}
|
||||||
|
onDrop={handleDrop}
|
||||||
|
onDragEnd={(e) => {
|
||||||
|
handleDragEnd();
|
||||||
|
e.currentTarget.style.cursor = "grab";
|
||||||
|
}}
|
||||||
|
className={`cursor-grab rounded-lg border border-gray-200 bg-gray-50 p-4 transition-all active:cursor-grabbing ${
|
||||||
|
draggedIndex === index ? "opacity-50" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="mb-3 flex items-center gap-2">
|
<div className="mb-3 flex items-center gap-2">
|
||||||
<GripVertical className="h-4 w-4 text-gray-400" />
|
<GripVertical className="h-4 w-4 text-blue-500" />
|
||||||
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
<span className="font-medium text-gray-700">컬럼 {index + 1}</span>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRemove(col.id)}
|
onClick={() => handleRemove(col.id)}
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,15 @@
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ArrowLeft, Save, Loader2, Plus, Settings, Trash2 } from "lucide-react";
|
import { ArrowLeft, Save, Loader2, Plus, Settings, Trash2, Edit2 } from "lucide-react";
|
||||||
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
import { yardLayoutApi } from "@/lib/api/yardLayoutApi";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { YardLayout, YardPlacement } from "./types";
|
import { YardLayout, YardPlacement } from "./types";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { AlertCircle, CheckCircle } from "lucide-react";
|
import { AlertCircle, CheckCircle } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
const Yard3DCanvas = dynamic(() => import("./Yard3DCanvas"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
|
|
@ -45,6 +47,14 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
}>({ open: false, success: false, message: "" });
|
}>({ open: false, success: false, message: "" });
|
||||||
|
const [deleteConfirmDialog, setDeleteConfirmDialog] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
placementId: number | null;
|
||||||
|
}>({ open: false, placementId: null });
|
||||||
|
const [editLayoutDialog, setEditLayoutDialog] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
name: string;
|
||||||
|
}>({ open: false, name: "" });
|
||||||
|
|
||||||
// 배치 목록 로드
|
// 배치 목록 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -110,11 +120,15 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
setShowConfigPanel(true);
|
setShowConfigPanel(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 요소 삭제 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
|
// 요소 삭제 확인 Dialog 열기
|
||||||
const handleDeletePlacement = (placementId: number) => {
|
const handleDeletePlacement = (placementId: number) => {
|
||||||
if (!confirm("이 요소를 삭제하시겠습니까?")) {
|
setDeleteConfirmDialog({ open: true, placementId });
|
||||||
return;
|
};
|
||||||
}
|
|
||||||
|
// 요소 삭제 확정 (로컬 상태에서만 삭제, 저장 시 서버에 반영)
|
||||||
|
const confirmDeletePlacement = () => {
|
||||||
|
const { placementId } = deleteConfirmDialog;
|
||||||
|
if (placementId === null) return;
|
||||||
|
|
||||||
setPlacements((prev) => prev.filter((p) => p.id !== placementId));
|
setPlacements((prev) => prev.filter((p) => p.id !== placementId));
|
||||||
if (selectedPlacement?.id === placementId) {
|
if (selectedPlacement?.id === placementId) {
|
||||||
|
|
@ -122,6 +136,7 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
setShowConfigPanel(false);
|
setShowConfigPanel(false);
|
||||||
}
|
}
|
||||||
setHasUnsavedChanges(true);
|
setHasUnsavedChanges(true);
|
||||||
|
setDeleteConfirmDialog({ open: false, placementId: null });
|
||||||
};
|
};
|
||||||
|
|
||||||
// 자재 드래그 (3D 캔버스에서, 로컬 상태에만 반영)
|
// 자재 드래그 (3D 캔버스에서, 로컬 상태에만 반영)
|
||||||
|
|
@ -257,6 +272,32 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
return !!(placement.material_name && placement.quantity && placement.unit);
|
return !!(placement.material_name && placement.quantity && placement.unit);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 레이아웃 편집 Dialog 열기
|
||||||
|
const handleEditLayout = () => {
|
||||||
|
setEditLayoutDialog({
|
||||||
|
open: true,
|
||||||
|
name: layout.name,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 레이아웃 정보 저장
|
||||||
|
const handleSaveLayoutInfo = async () => {
|
||||||
|
try {
|
||||||
|
const response = await yardLayoutApi.updateLayout(layout.id, {
|
||||||
|
name: editLayoutDialog.name,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.success) {
|
||||||
|
// 레이아웃 정보 업데이트
|
||||||
|
layout.name = editLayoutDialog.name;
|
||||||
|
setEditLayoutDialog({ open: false, name: "" });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("레이아웃 정보 수정 실패:", error);
|
||||||
|
setError("레이아웃 정보 수정에 실패했습니다.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col bg-white">
|
<div className="flex h-full flex-col bg-white">
|
||||||
{/* 상단 툴바 */}
|
{/* 상단 툴바 */}
|
||||||
|
|
@ -266,9 +307,14 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="mr-2 h-4 w-4" />
|
||||||
목록으로
|
목록으로
|
||||||
</Button>
|
</Button>
|
||||||
<div>
|
<div className="flex items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">{layout.name}</h2>
|
<div>
|
||||||
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
|
<h2 className="text-lg font-semibold">{layout.name}</h2>
|
||||||
|
{layout.description && <p className="text-sm text-gray-500">{layout.description}</p>}
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" onClick={handleEditLayout} className="h-8 w-8 p-0">
|
||||||
|
<Edit2 className="h-4 w-4 text-gray-500" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -442,6 +488,68 @@ export default function YardEditor({ layout, onBack }: YardEditorProps) {
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 삭제 확인 Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={deleteConfirmDialog.open}
|
||||||
|
onOpenChange={(open) => !open && setDeleteConfirmDialog({ open: false, placementId: null })}
|
||||||
|
>
|
||||||
|
<DialogContent onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="h-5 w-5 text-orange-600" />
|
||||||
|
요소 삭제 확인
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="pt-2">
|
||||||
|
이 요소를 삭제하시겠습니까?
|
||||||
|
<br />
|
||||||
|
<span className="font-semibold text-orange-600">저장 버튼을 눌러야 최종적으로 삭제됩니다.</span>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirmDialog({ open: false, placementId: null })}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={confirmDeletePlacement} className="bg-red-600 hover:bg-red-700">
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* 레이아웃 편집 Dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={editLayoutDialog.open}
|
||||||
|
onOpenChange={(open) => !open && setEditLayoutDialog({ open: false, name: "" })}
|
||||||
|
>
|
||||||
|
<DialogContent onPointerDown={(e) => e.stopPropagation()}>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Edit2 className="h-5 w-5 text-blue-600" />
|
||||||
|
야드 레이아웃 정보 수정
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="layout-name">레이아웃 이름</Label>
|
||||||
|
<Input
|
||||||
|
id="layout-name"
|
||||||
|
value={editLayoutDialog.name}
|
||||||
|
onChange={(e) => setEditLayoutDialog((prev) => ({ ...prev, name: e.target.value }))}
|
||||||
|
placeholder="레이아웃 이름을 입력하세요"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={() => setEditLayoutDialog({ open: false, name: "" })}>
|
||||||
|
취소
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSaveLayoutInfo} disabled={!editLayoutDialog.name.trim()}>
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -284,11 +284,11 @@ export function DashboardViewer({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DashboardProvider>
|
<DashboardProvider>
|
||||||
{/* overflow-auto 제거 - 외부 페이지 스크롤 사용 */}
|
{/* 스크롤 가능한 컨테이너 */}
|
||||||
<div className="flex h-full items-start justify-center bg-gray-100 p-8">
|
<div className="flex min-h-screen items-start justify-center bg-gray-100 p-8">
|
||||||
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
|
{/* 고정 크기 캔버스 (편집 화면과 동일한 레이아웃) */}
|
||||||
<div
|
<div
|
||||||
className="relative overflow-hidden rounded-lg"
|
className="relative rounded-lg"
|
||||||
style={{
|
style={{
|
||||||
width: `${canvasConfig.width}px`,
|
width: `${canvasConfig.width}px`,
|
||||||
minHeight: `${canvasConfig.height}px`,
|
minHeight: `${canvasConfig.height}px`,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,266 @@
|
||||||
|
/**
|
||||||
|
* 플로우 조건 빌더
|
||||||
|
* 동적 조건 생성 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,221 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* 플로우 노드 컴포넌트
|
||||||
|
* 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";
|
||||||
|
|
@ -0,0 +1,888 @@
|
||||||
|
/**
|
||||||
|
* 플로우 단계 설정 패널
|
||||||
|
* 선택된 단계의 속성 편집
|
||||||
|
*/
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -307,6 +307,39 @@ 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 { widgetType, label, placeholder, required, readonly, columnName } = comp;
|
||||||
const fieldName = columnName || comp.id;
|
const fieldName = columnName || comp.id;
|
||||||
const currentValue = formData[fieldName] || "";
|
const currentValue = formData[fieldName] || "";
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ interface RealtimePreviewProps {
|
||||||
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
|
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
|
||||||
switch (layoutDirection) {
|
switch (layoutDirection) {
|
||||||
case "horizontal":
|
case "horizontal":
|
||||||
return <Layout className="h-4 w-4 text-primary" />;
|
return <Layout className="text-primary h-4 w-4" />;
|
||||||
case "vertical":
|
case "vertical":
|
||||||
return <Columns className="h-4 w-4 text-purple-600" />;
|
return <Columns className="h-4 w-4 text-purple-600" />;
|
||||||
default:
|
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="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
{getAreaIcon(layoutDirection)}
|
{getAreaIcon(layoutDirection)}
|
||||||
<p className="mt-2 text-sm text-muted-foreground">{label || `${layoutDirection || "기본"} 영역`}</p>
|
<p className="text-muted-foreground mt-2 text-sm">{label || `${layoutDirection || "기본"} 영역`}</p>
|
||||||
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
<p className="text-xs text-gray-400">컴포넌트를 드래그해서 추가하세요</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -130,12 +130,12 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
||||||
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
||||||
if (isFileComponent(widget)) {
|
if (isFileComponent(widget)) {
|
||||||
// console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
// console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
||||||
// componentId: widget.id,
|
// componentId: widget.id,
|
||||||
// widgetType: widgetType,
|
// widgetType: widgetType,
|
||||||
// isFileComponent: true
|
// isFileComponent: true
|
||||||
// });
|
// });
|
||||||
|
|
||||||
return <div className="text-xs text-gray-500 p-2">파일 컴포넌트 (별도 렌더링)</div>;
|
return <div className="p-2 text-xs text-gray-500">파일 컴포넌트 (별도 렌더링)</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 동적 웹타입 렌더링 사용
|
// 동적 웹타입 렌더링 사용
|
||||||
|
|
@ -182,7 +182,7 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
|
||||||
case "text":
|
case "text":
|
||||||
case "email":
|
case "email":
|
||||||
case "tel":
|
case "tel":
|
||||||
return <Type className="h-4 w-4 text-primary" />;
|
return <Type className="text-primary h-4 w-4" />;
|
||||||
case "number":
|
case "number":
|
||||||
case "decimal":
|
case "decimal":
|
||||||
return <Hash className="h-4 w-4 text-green-600" />;
|
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" />;
|
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
||||||
case "boolean":
|
case "boolean":
|
||||||
case "checkbox":
|
case "checkbox":
|
||||||
return <CheckSquare className="h-4 w-4 text-primary" />;
|
return <CheckSquare className="text-primary h-4 w-4" />;
|
||||||
case "radio":
|
case "radio":
|
||||||
return <Radio className="h-4 w-4 text-primary" />;
|
return <Radio className="text-primary h-4 w-4" />;
|
||||||
case "code":
|
case "code":
|
||||||
return <Code className="h-4 w-4 text-muted-foreground" />;
|
return <Code className="text-muted-foreground h-4 w-4" />;
|
||||||
case "entity":
|
case "entity":
|
||||||
return <Building className="h-4 w-4 text-cyan-600" />;
|
return <Building className="h-4 w-4 text-cyan-600" />;
|
||||||
case "file":
|
case "file":
|
||||||
|
|
@ -227,39 +227,39 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||||
// console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
// console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
||||||
// eventComponentId: event.detail.componentId,
|
// eventComponentId: event.detail.componentId,
|
||||||
// currentComponentId: component.id,
|
// currentComponentId: component.id,
|
||||||
// isMatch: event.detail.componentId === component.id,
|
// isMatch: event.detail.componentId === component.id,
|
||||||
// filesCount: event.detail.files?.length || 0,
|
// filesCount: event.detail.files?.length || 0,
|
||||||
// action: event.detail.action,
|
// action: event.detail.action,
|
||||||
// delayed: event.detail.delayed || false,
|
// delayed: event.detail.delayed || false,
|
||||||
// attempt: event.detail.attempt || 1,
|
// attempt: event.detail.attempt || 1,
|
||||||
// eventDetail: event.detail
|
// eventDetail: event.detail
|
||||||
// });
|
// });
|
||||||
|
|
||||||
if (event.detail.componentId === component.id) {
|
if (event.detail.componentId === component.id) {
|
||||||
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
||||||
// componentId: component.id,
|
// componentId: component.id,
|
||||||
// filesCount: event.detail.files?.length || 0,
|
// filesCount: event.detail.files?.length || 0,
|
||||||
// action: event.detail.action,
|
// action: event.detail.action,
|
||||||
// oldTrigger: fileUpdateTrigger,
|
// oldTrigger: fileUpdateTrigger,
|
||||||
// delayed: event.detail.delayed || false,
|
// delayed: event.detail.delayed || false,
|
||||||
// attempt: event.detail.attempt || 1
|
// attempt: event.detail.attempt || 1
|
||||||
// });
|
// });
|
||||||
setFileUpdateTrigger(prev => {
|
setFileUpdateTrigger((prev) => {
|
||||||
const newTrigger = prev + 1;
|
const newTrigger = prev + 1;
|
||||||
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
||||||
// old: prev,
|
// old: prev,
|
||||||
// new: newTrigger,
|
// new: newTrigger,
|
||||||
// componentId: component.id,
|
// componentId: component.id,
|
||||||
// attempt: event.detail.attempt || 1
|
// attempt: event.detail.attempt || 1
|
||||||
// });
|
// });
|
||||||
return newTrigger;
|
return newTrigger;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// console.log("❌ 컴포넌트 ID 불일치:", {
|
// console.log("❌ 컴포넌트 ID 불일치:", {
|
||||||
// eventComponentId: event.detail.componentId,
|
// eventComponentId: event.detail.componentId,
|
||||||
// currentComponentId: component.id
|
// currentComponentId: component.id
|
||||||
// });
|
// });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -267,33 +267,33 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
// 강제 업데이트 함수 등록
|
// 강제 업데이트 함수 등록
|
||||||
const forceUpdate = (componentId: string, files: any[]) => {
|
const forceUpdate = (componentId: string, files: any[]) => {
|
||||||
// console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
// console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
||||||
// targetComponentId: componentId,
|
// targetComponentId: componentId,
|
||||||
// currentComponentId: component.id,
|
// currentComponentId: component.id,
|
||||||
// isMatch: componentId === component.id,
|
// isMatch: componentId === component.id,
|
||||||
// filesCount: files.length
|
// filesCount: files.length
|
||||||
// });
|
// });
|
||||||
|
|
||||||
if (componentId === component.id) {
|
if (componentId === component.id) {
|
||||||
// console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
// console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
||||||
// componentId: component.id,
|
// componentId: component.id,
|
||||||
// filesCount: files.length,
|
// filesCount: files.length,
|
||||||
// oldTrigger: fileUpdateTrigger
|
// oldTrigger: fileUpdateTrigger
|
||||||
// });
|
// });
|
||||||
setFileUpdateTrigger(prev => {
|
setFileUpdateTrigger((prev) => {
|
||||||
const newTrigger = prev + 1;
|
const newTrigger = prev + 1;
|
||||||
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
||||||
// old: prev,
|
// old: prev,
|
||||||
// new: newTrigger,
|
// new: newTrigger,
|
||||||
// componentId: component.id
|
// componentId: component.id
|
||||||
// });
|
// });
|
||||||
return newTrigger;
|
return newTrigger;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== "undefined") {
|
||||||
try {
|
try {
|
||||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||||
|
|
||||||
// 전역 강제 업데이트 함수 등록
|
// 전역 강제 업데이트 함수 등록
|
||||||
if (!(window as any).forceRealtimePreviewUpdate) {
|
if (!(window as any).forceRealtimePreviewUpdate) {
|
||||||
|
|
@ -305,7 +305,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
try {
|
try {
|
||||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
// console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -327,7 +327,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
// 선택된 컴포넌트 스타일
|
// 선택된 컴포넌트 스타일
|
||||||
const selectionStyle = isSelected
|
const selectionStyle = isSelected
|
||||||
? {
|
? {
|
||||||
outline: "2px solid #3b82f6",
|
outline: "2px solid rgb(59, 130, 246)",
|
||||||
outlineOffset: "2px",
|
outlineOffset: "2px",
|
||||||
}
|
}
|
||||||
: {};
|
: {};
|
||||||
|
|
@ -395,6 +395,39 @@ 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" && (
|
{type === "group" && (
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full">
|
||||||
|
|
@ -412,18 +445,19 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
||||||
{isFileComponent(component) && (() => {
|
{isFileComponent(component) &&
|
||||||
const fileComponent = component as any;
|
(() => {
|
||||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
const fileComponent = component as any;
|
||||||
|
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||||
|
|
||||||
// 전역 상태에서 최신 파일 정보 가져오기
|
// 전역 상태에서 최신 파일 정보 가져오기
|
||||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||||
const globalFiles = globalFileState[component.id] || [];
|
const globalFiles = globalFileState[component.id] || [];
|
||||||
|
|
||||||
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
// 최신 파일 정보 사용 (전역 상태 > 컴포넌트 속성)
|
||||||
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
const currentFiles = globalFiles.length > 0 ? globalFiles : uploadedFiles;
|
||||||
|
|
||||||
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
// console.log("🔍 RealtimePreview 파일 컴포넌트 렌더링:", {
|
||||||
// componentId: component.id,
|
// componentId: component.id,
|
||||||
// uploadedFilesCount: uploadedFiles.length,
|
// uploadedFilesCount: uploadedFiles.length,
|
||||||
// globalFilesCount: globalFiles.length,
|
// globalFilesCount: globalFiles.length,
|
||||||
|
|
@ -432,73 +466,76 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
// componentType: component.type,
|
// componentType: component.type,
|
||||||
// fileUpdateTrigger: fileUpdateTrigger,
|
// fileUpdateTrigger: fileUpdateTrigger,
|
||||||
// timestamp: new Date().toISOString()
|
// timestamp: new Date().toISOString()
|
||||||
// });
|
// });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
<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">
|
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||||
{currentFiles.length > 0 ? (
|
{currentFiles.length > 0 ? (
|
||||||
<div className="h-full overflow-y-auto">
|
<div className="h-full overflow-y-auto">
|
||||||
<div className="mb-1 text-xs font-medium text-gray-700">
|
<div className="mb-1 text-xs font-medium text-gray-700">
|
||||||
업로드된 파일 ({currentFiles.length})
|
업로드된 파일 ({currentFiles.length})
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{currentFiles.map((file: any, index: number) => {
|
{currentFiles.map((file: any, index: number) => {
|
||||||
// 파일 확장자에 따른 아이콘 선택
|
// 파일 확장자에 따른 아이콘 선택
|
||||||
const getFileIcon = (fileName: string) => {
|
const getFileIcon = (fileName: string) => {
|
||||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
|
||||||
return <ImageIcon className="h-4 w-4 text-green-500 flex-shrink-0" />;
|
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)) {
|
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" />;
|
return <FileText className="h-4 w-4 flex-shrink-0 text-red-500" />;
|
||||||
}
|
}
|
||||||
if (['ppt', 'pptx', 'hpt', 'keynote'].includes(ext)) {
|
if (["ppt", "pptx", "hpt", "keynote"].includes(ext)) {
|
||||||
return <Presentation className="h-4 w-4 text-orange-600 flex-shrink-0" />;
|
return <Presentation className="h-4 w-4 flex-shrink-0 text-orange-600" />;
|
||||||
}
|
}
|
||||||
if (['xls', 'xlsx', 'hcdt', 'numbers'].includes(ext)) {
|
if (["xls", "xlsx", "hcdt", "numbers"].includes(ext)) {
|
||||||
return <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />;
|
return <FileText className="h-4 w-4 flex-shrink-0 text-green-600" />;
|
||||||
}
|
}
|
||||||
if (['mp4', 'avi', 'mov', 'wmv', 'webm', 'ogg'].includes(ext)) {
|
if (["mp4", "avi", "mov", "wmv", "webm", "ogg"].includes(ext)) {
|
||||||
return <Video className="h-4 w-4 text-purple-500 flex-shrink-0" />;
|
return <Video className="h-4 w-4 flex-shrink-0 text-purple-500" />;
|
||||||
}
|
}
|
||||||
if (['mp3', 'wav', 'flac', 'aac'].includes(ext)) {
|
if (["mp3", "wav", "flac", "aac"].includes(ext)) {
|
||||||
return <Music className="h-4 w-4 text-orange-500 flex-shrink-0" />;
|
return <Music className="h-4 w-4 flex-shrink-0 text-orange-500" />;
|
||||||
}
|
}
|
||||||
if (['zip', 'rar', '7z', 'tar'].includes(ext)) {
|
if (["zip", "rar", "7z", "tar"].includes(ext)) {
|
||||||
return <Archive className="h-4 w-4 text-yellow-500 flex-shrink-0" />;
|
return <Archive className="h-4 w-4 flex-shrink-0 text-yellow-500" />;
|
||||||
}
|
}
|
||||||
return <File className="h-4 w-4 text-blue-500 flex-shrink-0" />;
|
return <File className="h-4 w-4 flex-shrink-0 text-blue-500" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={file.objid || index} className="flex items-center space-x-2 bg-white rounded p-2 text-xs">
|
<div
|
||||||
{getFileIcon(file.realFileName || file.name || '')}
|
key={file.objid || index}
|
||||||
<div className="flex-1 min-w-0">
|
className="flex items-center space-x-2 rounded bg-white p-2 text-xs"
|
||||||
<p className="truncate font-medium text-gray-900">
|
>
|
||||||
{file.realFileName || file.name || `파일 ${index + 1}`}
|
{getFileIcon(file.realFileName || file.name || "")}
|
||||||
</p>
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-gray-500">
|
<p className="truncate font-medium text-gray-900">
|
||||||
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ''}
|
{file.realFileName || file.name || `파일 ${index + 1}`}
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
) : (
|
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
<File className="mb-2 h-8 w-8 text-gray-400" />
|
||||||
<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-xs font-medium text-gray-700 mb-1">업로드된 파일 (0)</p>
|
<p className="text-muted-foreground text-sm">파일 업로드 영역</p>
|
||||||
<p className="text-sm text-muted-foreground">파일 업로드 영역</p>
|
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
||||||
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})()}
|
||||||
})()}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 선택된 컴포넌트 정보 표시 */}
|
{/* 선택된 컴포넌트 정보 표시 */}
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
||||||
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
|
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
|
||||||
const selectionStyle = isSelected
|
const selectionStyle = isSelected
|
||||||
? {
|
? {
|
||||||
outline: "2px solid hsl(var(--primary))",
|
outline: "2px solid rgb(59, 130, 246)",
|
||||||
outlineOffset: "2px",
|
outlineOffset: "2px",
|
||||||
zIndex: 20,
|
zIndex: 20,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1997,6 +1997,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
"table-list": 12, // 테이블 리스트 (100%)
|
"table-list": 12, // 테이블 리스트 (100%)
|
||||||
"image-display": 4, // 이미지 표시 (33%)
|
"image-display": 4, // 이미지 표시 (33%)
|
||||||
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
||||||
|
"flow-widget": 12, // 플로우 위젯 (100%)
|
||||||
|
|
||||||
// 액션 컴포넌트 (ACTION 카테고리)
|
// 액션 컴포넌트 (ACTION 카테고리)
|
||||||
"button-basic": 1, // 버튼 (8.33%)
|
"button-basic": 1, // 버튼 (8.33%)
|
||||||
|
|
@ -2016,8 +2017,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
||||||
"chart-basic": 6, // 차트 (50%)
|
"chart-basic": 6, // 차트 (50%)
|
||||||
};
|
};
|
||||||
|
|
||||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
|
||||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
if (component.defaultSize?.gridColumnSpan === "full") {
|
||||||
|
gridColumns = 12;
|
||||||
|
} else {
|
||||||
|
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
||||||
|
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
||||||
|
}
|
||||||
|
|
||||||
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
||||||
componentId,
|
componentId,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1056,33 +1056,6 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
</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="flex-1 overflow-y-auto px-6 pb-6">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
@ -1115,23 +1088,6 @@ 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -548,22 +548,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
handleUpdate("componentConfig", newConfig);
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -592,17 +576,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* WebType 설정 패널 */}
|
|
||||||
<WebTypeConfigPanel
|
|
||||||
webType={widget.webType as any}
|
|
||||||
config={widget.webTypeConfig || {}}
|
|
||||||
onUpdateConfig={(newConfig) => {
|
|
||||||
Object.entries(newConfig).forEach(([key, value]) => {
|
|
||||||
handleUpdate(`webTypeConfig.${key}`, value);
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,810 @@
|
||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,463 @@
|
||||||
|
/**
|
||||||
|
* 플로우 관리 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,139 @@
|
||||||
|
/**
|
||||||
|
* 플로우 전용 외부 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;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
"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 컴포넌트 등록 완료");
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
"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에서 자동 등록됩니다
|
||||||
|
|
@ -40,6 +40,7 @@ import "./card-display/CardDisplayRenderer";
|
||||||
import "./split-panel-layout/SplitPanelLayoutRenderer";
|
import "./split-panel-layout/SplitPanelLayoutRenderer";
|
||||||
import "./map/MapRenderer";
|
import "./map/MapRenderer";
|
||||||
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
import "./repeater-field-group/RepeaterFieldGroupRenderer";
|
||||||
|
import "./flow-widget/FlowWidgetRenderer";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 컴포넌트 초기화 함수
|
* 컴포넌트 초기화 함수
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ const CONFIG_PANEL_MAP: Record<string, () => Promise<any>> = {
|
||||||
"card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"),
|
"card-display": () => import("@/lib/registry/components/card-display/CardDisplayConfigPanel"),
|
||||||
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
|
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
|
||||||
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
|
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
|
||||||
|
"flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"),
|
||||||
};
|
};
|
||||||
|
|
||||||
// ConfigPanel 컴포넌트 캐시
|
// ConfigPanel 컴포넌트 캐시
|
||||||
|
|
@ -54,6 +55,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
||||||
const ConfigPanelComponent =
|
const ConfigPanelComponent =
|
||||||
module[`${toPascalCase(componentId)}ConfigPanel`] ||
|
module[`${toPascalCase(componentId)}ConfigPanel`] ||
|
||||||
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
||||||
|
module.FlowWidgetConfigPanel || // flow-widget의 export명
|
||||||
module.default;
|
module.default;
|
||||||
|
|
||||||
if (!ConfigPanelComponent) {
|
if (!ConfigPanelComponent) {
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-window": "^2.1.0",
|
"react-window": "^2.1.0",
|
||||||
"reactflow": "^11.10.4",
|
"reactflow": "^11.11.4",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"sheetjs-style": "^0.15.8",
|
"sheetjs-style": "^0.15.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
@ -2678,12 +2678,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reactflow/background": {
|
"node_modules/@reactflow/background": {
|
||||||
"version": "11.3.9",
|
"version": "11.3.14",
|
||||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
||||||
"integrity": "sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==",
|
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reactflow/core": "11.10.4",
|
"@reactflow/core": "11.11.4",
|
||||||
"classcat": "^5.0.3",
|
"classcat": "^5.0.3",
|
||||||
"zustand": "^4.4.1"
|
"zustand": "^4.4.1"
|
||||||
},
|
},
|
||||||
|
|
@ -2693,12 +2693,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reactflow/controls": {
|
"node_modules/@reactflow/controls": {
|
||||||
"version": "11.2.9",
|
"version": "11.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
|
||||||
"integrity": "sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==",
|
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reactflow/core": "11.10.4",
|
"@reactflow/core": "11.11.4",
|
||||||
"classcat": "^5.0.3",
|
"classcat": "^5.0.3",
|
||||||
"zustand": "^4.4.1"
|
"zustand": "^4.4.1"
|
||||||
},
|
},
|
||||||
|
|
@ -2708,9 +2708,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reactflow/core": {
|
"node_modules/@reactflow/core": {
|
||||||
"version": "11.10.4",
|
"version": "11.11.4",
|
||||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
|
||||||
"integrity": "sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==",
|
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/d3": "^7.4.0",
|
"@types/d3": "^7.4.0",
|
||||||
|
|
@ -2729,12 +2729,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reactflow/minimap": {
|
"node_modules/@reactflow/minimap": {
|
||||||
"version": "11.7.9",
|
"version": "11.7.14",
|
||||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
|
||||||
"integrity": "sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==",
|
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reactflow/core": "11.10.4",
|
"@reactflow/core": "11.11.4",
|
||||||
"@types/d3-selection": "^3.0.3",
|
"@types/d3-selection": "^3.0.3",
|
||||||
"@types/d3-zoom": "^3.0.1",
|
"@types/d3-zoom": "^3.0.1",
|
||||||
"classcat": "^5.0.3",
|
"classcat": "^5.0.3",
|
||||||
|
|
@ -2748,12 +2748,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reactflow/node-resizer": {
|
"node_modules/@reactflow/node-resizer": {
|
||||||
"version": "2.2.9",
|
"version": "2.2.14",
|
||||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.9.tgz",
|
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
|
||||||
"integrity": "sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==",
|
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reactflow/core": "11.10.4",
|
"@reactflow/core": "11.11.4",
|
||||||
"classcat": "^5.0.4",
|
"classcat": "^5.0.4",
|
||||||
"d3-drag": "^3.0.0",
|
"d3-drag": "^3.0.0",
|
||||||
"d3-selection": "^3.0.0",
|
"d3-selection": "^3.0.0",
|
||||||
|
|
@ -2765,12 +2765,12 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@reactflow/node-toolbar": {
|
"node_modules/@reactflow/node-toolbar": {
|
||||||
"version": "1.3.9",
|
"version": "1.3.14",
|
||||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.9.tgz",
|
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
|
||||||
"integrity": "sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==",
|
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reactflow/core": "11.10.4",
|
"@reactflow/core": "11.11.4",
|
||||||
"classcat": "^5.0.3",
|
"classcat": "^5.0.3",
|
||||||
"zustand": "^4.4.1"
|
"zustand": "^4.4.1"
|
||||||
},
|
},
|
||||||
|
|
@ -9526,17 +9526,17 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/reactflow": {
|
"node_modules/reactflow": {
|
||||||
"version": "11.10.4",
|
"version": "11.11.4",
|
||||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.10.4.tgz",
|
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||||
"integrity": "sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==",
|
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@reactflow/background": "11.3.9",
|
"@reactflow/background": "11.3.14",
|
||||||
"@reactflow/controls": "11.2.9",
|
"@reactflow/controls": "11.2.14",
|
||||||
"@reactflow/core": "11.10.4",
|
"@reactflow/core": "11.11.4",
|
||||||
"@reactflow/minimap": "11.7.9",
|
"@reactflow/minimap": "11.7.14",
|
||||||
"@reactflow/node-resizer": "2.2.9",
|
"@reactflow/node-resizer": "2.2.14",
|
||||||
"@reactflow/node-toolbar": "1.3.9"
|
"@reactflow/node-toolbar": "1.3.14"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": ">=17",
|
"react": ">=17",
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@
|
||||||
"react-leaflet": "^5.0.0",
|
"react-leaflet": "^5.0.0",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"react-window": "^2.1.0",
|
"react-window": "^2.1.0",
|
||||||
"reactflow": "^11.10.4",
|
"reactflow": "^11.11.4",
|
||||||
"recharts": "^3.2.1",
|
"recharts": "^3.2.1",
|
||||||
"sheetjs-style": "^0.15.8",
|
"sheetjs-style": "^0.15.8",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,225 @@
|
||||||
|
/**
|
||||||
|
* 플로우 관리 시스템 - 프론트엔드 타입 정의
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// 조건 연산자
|
||||||
|
// ============================================
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
/**
|
||||||
|
* 플로우 전용 외부 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;
|
||||||
|
}
|
||||||
|
|
@ -135,6 +135,18 @@ export interface FileComponent extends BaseComponent {
|
||||||
lastFileUpdate?: number;
|
lastFileUpdate?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 컴포넌트
|
||||||
|
*/
|
||||||
|
export interface FlowComponent extends BaseComponent {
|
||||||
|
type: "flow";
|
||||||
|
flowId?: number; // 선택된 플로우 ID
|
||||||
|
flowName?: string; // 플로우 이름 (표시용)
|
||||||
|
showStepCount?: boolean; // 각 스텝의 데이터 건수 표시 여부
|
||||||
|
allowDataMove?: boolean; // 데이터 이동 허용 여부
|
||||||
|
displayMode?: "horizontal" | "vertical"; // 플로우 표시 방향
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 새로운 컴포넌트 시스템 컴포넌트
|
* 새로운 컴포넌트 시스템 컴포넌트
|
||||||
*/
|
*/
|
||||||
|
|
@ -154,6 +166,7 @@ export type ComponentData =
|
||||||
| GroupComponent
|
| GroupComponent
|
||||||
| DataTableComponent
|
| DataTableComponent
|
||||||
| FileComponent
|
| FileComponent
|
||||||
|
| FlowComponent
|
||||||
| ComponentComponent;
|
| ComponentComponent;
|
||||||
|
|
||||||
// ===== 웹타입별 설정 인터페이스 =====
|
// ===== 웹타입별 설정 인터페이스 =====
|
||||||
|
|
@ -606,6 +619,13 @@ export const isFileComponent = (component: ComponentData): component is FileComp
|
||||||
return component.type === "file";
|
return component.type === "file";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FlowComponent 타입 가드
|
||||||
|
*/
|
||||||
|
export const isFlowComponent = (component: ComponentData): component is FlowComponent => {
|
||||||
|
return component.type === "flow";
|
||||||
|
};
|
||||||
|
|
||||||
// ===== 안전한 타입 캐스팅 유틸리티 =====
|
// ===== 안전한 타입 캐스팅 유틸리티 =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -657,3 +677,13 @@ export const asFileComponent = (component: ComponentData): FileComponent => {
|
||||||
}
|
}
|
||||||
return component;
|
return component;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ComponentData를 FlowComponent로 안전하게 캐스팅
|
||||||
|
*/
|
||||||
|
export const asFlowComponent = (component: ComponentData): FlowComponent => {
|
||||||
|
if (!isFlowComponent(component)) {
|
||||||
|
throw new Error(`Expected FlowComponent, got ${component.type}`);
|
||||||
|
}
|
||||||
|
return component;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,9 @@ export type ComponentType =
|
||||||
| "datatable"
|
| "datatable"
|
||||||
| "file"
|
| "file"
|
||||||
| "area"
|
| "area"
|
||||||
| "layout";
|
| "layout"
|
||||||
|
| "flow"
|
||||||
|
| "component";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 기본 위치 정보
|
* 기본 위치 정보
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue