플로우 구현
This commit is contained in:
parent
6603ff81fe
commit
f9c171c513
File diff suppressed because it is too large
Load Diff
|
|
@ -57,6 +57,7 @@ import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
|||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||
import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||
|
|
@ -208,6 +209,7 @@ app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
|||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||
app.use("/api/materials", materialRoutes); // 자재 관리
|
||||
app.use("/api/flow", flowRoutes); // 플로우 관리
|
||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||
// app.use('/api/users', userRoutes);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,658 @@
|
|||
/**
|
||||
* 플로우 관리 컨트롤러
|
||||
*/
|
||||
|
||||
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, recordIds, toStepId, note } = req.body;
|
||||
const userId = (req as any).user?.userId || "system";
|
||||
|
||||
if (!flowId || !recordIds || !Array.isArray(recordIds) || !toStepId) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
message: "flowId, recordIds (array), and toStepId are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await this.flowDataMoveService.moveBatchData(
|
||||
flowId,
|
||||
recordIds,
|
||||
toStepId,
|
||||
userId,
|
||||
note
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `${recordIds.length} records moved successfully`,
|
||||
});
|
||||
} 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,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,203 @@
|
|||
/**
|
||||
* 플로우 조건 파서
|
||||
* 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":
|
||||
conditions.push(`${column} = $${paramIndex}`);
|
||||
params.push(condition.value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "not_equals":
|
||||
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":
|
||||
conditions.push(`${column} > $${paramIndex}`);
|
||||
params.push(condition.value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "less_than":
|
||||
conditions.push(`${column} < $${paramIndex}`);
|
||||
params.push(condition.value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "greater_than_or_equal":
|
||||
conditions.push(`${column} >= $${paramIndex}`);
|
||||
params.push(condition.value);
|
||||
paramIndex++;
|
||||
break;
|
||||
|
||||
case "less_than_or_equal":
|
||||
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,181 @@
|
|||
/**
|
||||
* 플로우 데이터 이동 서비스
|
||||
* 데이터의 플로우 단계 이동 및 오딧 로그 관리
|
||||
*/
|
||||
|
||||
import db from "../database/db";
|
||||
import { FlowAuditLog } from "../types/flow";
|
||||
import { FlowDefinitionService } from "./flowDefinitionService";
|
||||
|
||||
export class FlowDataMoveService {
|
||||
private flowDefinitionService: FlowDefinitionService;
|
||||
|
||||
constructor() {
|
||||
this.flowDefinitionService = new FlowDefinitionService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터를 다음 플로우 단계로 이동
|
||||
*/
|
||||
async moveDataToStep(
|
||||
flowId: number,
|
||||
recordId: string,
|
||||
toStepId: number,
|
||||
userId: string,
|
||||
note?: string
|
||||
): Promise<void> {
|
||||
await db.transaction(async (client) => {
|
||||
// 1. 플로우 정의 조회
|
||||
const flowDef = await this.flowDefinitionService.findById(flowId);
|
||||
if (!flowDef) {
|
||||
throw new Error(`Flow definition not found: ${flowId}`);
|
||||
}
|
||||
|
||||
// 2. 현재 상태 조회
|
||||
const currentStatusQuery = `
|
||||
SELECT current_step_id, table_name
|
||||
FROM flow_data_status
|
||||
WHERE flow_definition_id = $1 AND record_id = $2
|
||||
`;
|
||||
const currentStatusResult = await client.query(currentStatusQuery, [
|
||||
flowId,
|
||||
recordId,
|
||||
]);
|
||||
const currentStatus =
|
||||
currentStatusResult.rows.length > 0
|
||||
? {
|
||||
currentStepId: currentStatusResult.rows[0].current_step_id,
|
||||
tableName: currentStatusResult.rows[0].table_name,
|
||||
}
|
||||
: null;
|
||||
const fromStepId = currentStatus?.currentStepId || null;
|
||||
|
||||
// 3. flow_data_status 업데이트 또는 삽입
|
||||
if (currentStatus) {
|
||||
await client.query(
|
||||
`
|
||||
UPDATE flow_data_status
|
||||
SET current_step_id = $1, updated_by = $2, updated_at = NOW()
|
||||
WHERE flow_definition_id = $3 AND record_id = $4
|
||||
`,
|
||||
[toStepId, userId, flowId, recordId]
|
||||
);
|
||||
} else {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO flow_data_status
|
||||
(flow_definition_id, table_name, record_id, current_step_id, updated_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
`,
|
||||
[flowId, flowDef.tableName, recordId, toStepId, userId]
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 오딧 로그 기록
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO flow_audit_log
|
||||
(flow_definition_id, table_name, record_id, from_step_id, to_step_id, changed_by, note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
[
|
||||
flowId,
|
||||
flowDef.tableName,
|
||||
recordId,
|
||||
fromStepId,
|
||||
toStepId,
|
||||
userId,
|
||||
note || null,
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 여러 데이터를 동시에 다음 단계로 이동
|
||||
*/
|
||||
async moveBatchData(
|
||||
flowId: number,
|
||||
recordIds: string[],
|
||||
toStepId: number,
|
||||
userId: string,
|
||||
note?: string
|
||||
): Promise<void> {
|
||||
for (const recordId of recordIds) {
|
||||
await this.moveDataToStep(flowId, recordId, toStepId, userId, note);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 데이터의 플로우 이력 조회
|
||||
*/
|
||||
async getAuditLogs(
|
||||
flowId: number,
|
||||
recordId: 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.record_id = $2
|
||||
ORDER BY fal.changed_at DESC
|
||||
`;
|
||||
|
||||
const result = await db.query(query, [flowId, recordId]);
|
||||
|
||||
return result.map((row) => ({
|
||||
id: row.id,
|
||||
flowDefinitionId: row.flow_definition_id,
|
||||
tableName: row.table_name,
|
||||
recordId: row.record_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,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 플로우의 모든 이력 조회
|
||||
*/
|
||||
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,
|
||||
recordId: row.record_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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,202 @@
|
|||
/**
|
||||
* 플로우 단계 서비스
|
||||
*/
|
||||
|
||||
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
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
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,
|
||||
]);
|
||||
|
||||
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> {
|
||||
// 조건 검증
|
||||
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 (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,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* 플로우 관리 시스템 타입 정의
|
||||
*/
|
||||
|
||||
// 플로우 정의
|
||||
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;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
// 플로우 단계 생성 요청
|
||||
export interface CreateFlowStepRequest {
|
||||
flowDefinitionId: number;
|
||||
stepName: string;
|
||||
stepOrder: number;
|
||||
tableName?: string; // 이 단계에서 조회할 테이블명
|
||||
conditionJson?: FlowConditionGroup;
|
||||
color?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
}
|
||||
|
||||
// 플로우 단계 수정 요청
|
||||
export interface UpdateFlowStepRequest {
|
||||
stepName?: string;
|
||||
stepOrder?: number;
|
||||
tableName?: string; // 이 단계에서 조회할 테이블명
|
||||
conditionJson?: FlowConditionGroup;
|
||||
color?: string;
|
||||
positionX?: number;
|
||||
positionY?: number;
|
||||
}
|
||||
|
||||
// 플로우 단계 연결
|
||||
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;
|
||||
// 조인 필드
|
||||
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[];
|
||||
}
|
||||
|
|
@ -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,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,327 @@
|
|||
"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-6 p-6">
|
||||
{/* 헤더 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="flex items-center gap-2 text-3xl font-bold">
|
||||
<Workflow className="h-8 w-8" />
|
||||
플로우 관리
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
||||
</div>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />새 플로우 생성
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 플로우 카드 목록 */}
|
||||
{loading ? (
|
||||
<div className="py-12 text-center">
|
||||
<p className="text-muted-foreground">로딩 중...</p>
|
||||
</div>
|
||||
) : flows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<Workflow className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
||||
<p className="text-muted-foreground mb-4">생성된 플로우가 없습니다</p>
|
||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />첫 플로우 만들기
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{flows.map((flow) => (
|
||||
<Card
|
||||
key={flow.id}
|
||||
className="cursor-pointer transition-shadow hover:shadow-lg"
|
||||
onClick={() => handleEdit(flow.id)}
|
||||
>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{flow.name}
|
||||
{flow.isActive && <Badge variant="success">활성</Badge>}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-2">{flow.description || "설명 없음"}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Table className="h-4 w-4" />
|
||||
<span>{flow.tableName}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
<span>생성자: {flow.createdBy}</span>
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span>{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="flex-1"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(flow.id);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" />
|
||||
편집
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedFlow(flow);
|
||||
setIsDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 생성 다이얼로그 */}
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>새 플로우 생성</DialogTitle>
|
||||
<DialogDescription>새로운 업무 프로세스 플로우를 생성합니다</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="name">플로우 이름 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="예: 제품 수명주기 관리"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="tableName">연결 테이블 *</Label>
|
||||
<Input
|
||||
id="tableName"
|
||||
value={formData.tableName}
|
||||
onChange={(e) => setFormData({ ...formData, tableName: e.target.value })}
|
||||
placeholder="예: products"
|
||||
/>
|
||||
<p className="text-muted-foreground mt-1 text-xs">플로우가 관리할 데이터 테이블 이름을 입력하세요</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="description">설명</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="플로우에 대한 설명을 입력하세요"
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleCreate}>생성</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>플로우 삭제</DialogTitle>
|
||||
<DialogDescription>
|
||||
정말로 "{selectedFlow?.name}" 플로우를 삭제하시겠습니까?
|
||||
<br />이 작업은 되돌릴 수 없습니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setIsDeleteDialogOpen(false);
|
||||
setSelectedFlow(null);
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* 플로우 조건 빌더
|
||||
* 동적 조건 생성 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 || []);
|
||||
|
||||
// 테이블 컬럼 로드
|
||||
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,246 @@
|
|||
/**
|
||||
* 플로우 단계 설정 패널
|
||||
* 선택된 단계의 속성 편집
|
||||
*/
|
||||
|
||||
import { useState, useEffect } 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 { 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 } from "@/lib/api/tableManagement";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const [tableList, setTableList] = useState<any[]>([]);
|
||||
const [loadingTables, setLoadingTables] = useState(true);
|
||||
const [openTableCombobox, setOpenTableCombobox] = 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();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setFormData({
|
||||
stepName: step.stepName,
|
||||
tableName: step.tableName || "",
|
||||
conditionJson: step.conditionJson,
|
||||
});
|
||||
}, [step]);
|
||||
|
||||
// 저장
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const response = await updateFlowStep(step.id, formData);
|
||||
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",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 삭제
|
||||
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>
|
||||
|
||||
<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}
|
||||
>
|
||||
{formData.tableName
|
||||
? tableList.find((table) => table.tableName === formData.tableName)?.displayName ||
|
||||
formData.tableName
|
||||
: loadingTables
|
||||
? "로딩 중..."
|
||||
: "테이블 선택"}
|
||||
<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>
|
||||
{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>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<p className="mt-1 text-xs text-gray-500">이 단계에서 조건을 적용할 테이블을 선택합니다</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>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<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 fieldName = columnName || comp.id;
|
||||
const currentValue = formData[fieldName] || "";
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ interface RealtimePreviewProps {
|
|||
const getAreaIcon = (layoutDirection?: "horizontal" | "vertical") => {
|
||||
switch (layoutDirection) {
|
||||
case "horizontal":
|
||||
return <Layout className="h-4 w-4 text-primary" />;
|
||||
return <Layout className="text-primary h-4 w-4" />;
|
||||
case "vertical":
|
||||
return <Columns className="h-4 w-4 text-purple-600" />;
|
||||
default:
|
||||
|
|
@ -86,7 +86,7 @@ const renderArea = (component: ComponentData, children?: React.ReactNode) => {
|
|||
<div className="flex h-full w-full items-center justify-center rounded border-2 border-dashed border-gray-300 bg-gray-50">
|
||||
<div className="text-center">
|
||||
{getAreaIcon(layoutDirection)}
|
||||
<p className="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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -130,12 +130,12 @@ const WidgetRenderer: React.FC<{ component: ComponentData }> = ({ component }) =
|
|||
// 파일 컴포넌트는 별도 로직에서 처리하므로 여기서는 제외
|
||||
if (isFileComponent(widget)) {
|
||||
// console.log("🎯 RealtimePreview - 파일 컴포넌트 감지 (별도 처리):", {
|
||||
// componentId: widget.id,
|
||||
// widgetType: widgetType,
|
||||
// isFileComponent: true
|
||||
// componentId: widget.id,
|
||||
// widgetType: widgetType,
|
||||
// isFileComponent: true
|
||||
// });
|
||||
|
||||
return <div className="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 "email":
|
||||
case "tel":
|
||||
return <Type className="h-4 w-4 text-primary" />;
|
||||
return <Type className="text-primary h-4 w-4" />;
|
||||
case "number":
|
||||
case "decimal":
|
||||
return <Hash className="h-4 w-4 text-green-600" />;
|
||||
|
|
@ -196,11 +196,11 @@ const getWidgetIcon = (widgetType: WebType | undefined) => {
|
|||
return <AlignLeft className="h-4 w-4 text-indigo-600" />;
|
||||
case "boolean":
|
||||
case "checkbox":
|
||||
return <CheckSquare className="h-4 w-4 text-primary" />;
|
||||
return <CheckSquare className="text-primary h-4 w-4" />;
|
||||
case "radio":
|
||||
return <Radio className="h-4 w-4 text-primary" />;
|
||||
return <Radio className="text-primary h-4 w-4" />;
|
||||
case "code":
|
||||
return <Code className="h-4 w-4 text-muted-foreground" />;
|
||||
return <Code className="text-muted-foreground h-4 w-4" />;
|
||||
case "entity":
|
||||
return <Building className="h-4 w-4 text-cyan-600" />;
|
||||
case "file":
|
||||
|
|
@ -227,39 +227,39 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
useEffect(() => {
|
||||
const handleGlobalFileStateChange = (event: CustomEvent) => {
|
||||
// console.log("🎯🎯🎯 RealtimePreview 이벤트 수신:", {
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: event.detail.componentId === component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1,
|
||||
// eventDetail: event.detail
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: event.detail.componentId === component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1,
|
||||
// eventDetail: event.detail
|
||||
// });
|
||||
|
||||
if (event.detail.componentId === component.id) {
|
||||
// console.log("✅✅✅ RealtimePreview 파일 상태 변경 감지 - 리렌더링 시작:", {
|
||||
// componentId: component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// oldTrigger: fileUpdateTrigger,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// componentId: component.id,
|
||||
// filesCount: event.detail.files?.length || 0,
|
||||
// action: event.detail.action,
|
||||
// oldTrigger: fileUpdateTrigger,
|
||||
// delayed: event.detail.delayed || false,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// });
|
||||
setFileUpdateTrigger(prev => {
|
||||
setFileUpdateTrigger((prev) => {
|
||||
const newTrigger = prev + 1;
|
||||
// console.log("🔄🔄🔄 fileUpdateTrigger 업데이트:", {
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id,
|
||||
// attempt: event.detail.attempt || 1
|
||||
// });
|
||||
return newTrigger;
|
||||
});
|
||||
} else {
|
||||
// console.log("❌ 컴포넌트 ID 불일치:", {
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id
|
||||
// eventComponentId: event.detail.componentId,
|
||||
// currentComponentId: component.id
|
||||
// });
|
||||
}
|
||||
};
|
||||
|
|
@ -267,33 +267,33 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
// 강제 업데이트 함수 등록
|
||||
const forceUpdate = (componentId: string, files: any[]) => {
|
||||
// console.log("🔥🔥🔥 RealtimePreview 강제 업데이트 호출:", {
|
||||
// targetComponentId: componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: componentId === component.id,
|
||||
// filesCount: files.length
|
||||
// targetComponentId: componentId,
|
||||
// currentComponentId: component.id,
|
||||
// isMatch: componentId === component.id,
|
||||
// filesCount: files.length
|
||||
// });
|
||||
|
||||
if (componentId === component.id) {
|
||||
// console.log("✅✅✅ RealtimePreview 강제 업데이트 적용:", {
|
||||
// componentId: component.id,
|
||||
// filesCount: files.length,
|
||||
// oldTrigger: fileUpdateTrigger
|
||||
// componentId: component.id,
|
||||
// filesCount: files.length,
|
||||
// oldTrigger: fileUpdateTrigger
|
||||
// });
|
||||
setFileUpdateTrigger(prev => {
|
||||
setFileUpdateTrigger((prev) => {
|
||||
const newTrigger = prev + 1;
|
||||
// console.log("🔄🔄🔄 강제 fileUpdateTrigger 업데이트:", {
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id
|
||||
// old: prev,
|
||||
// new: newTrigger,
|
||||
// componentId: component.id
|
||||
// });
|
||||
return newTrigger;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
window.addEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
window.addEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
|
||||
// 전역 강제 업데이트 함수 등록
|
||||
if (!(window as any).forceRealtimePreviewUpdate) {
|
||||
|
|
@ -305,7 +305,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
|
||||
return () => {
|
||||
try {
|
||||
window.removeEventListener('globalFileStateChanged', handleGlobalFileStateChange as EventListener);
|
||||
window.removeEventListener("globalFileStateChanged", handleGlobalFileStateChange as EventListener);
|
||||
} catch (error) {
|
||||
// console.warn("RealtimePreview 이벤트 리스너 제거 실패:", error);
|
||||
}
|
||||
|
|
@ -327,7 +327,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
// 선택된 컴포넌트 스타일
|
||||
const selectionStyle = isSelected
|
||||
? {
|
||||
outline: "2px solid #3b82f6",
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
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" && (
|
||||
<div className="relative h-full w-full">
|
||||
|
|
@ -412,18 +445,19 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
)}
|
||||
|
||||
{/* 파일 타입 - 레거시 및 신규 타입 지원 */}
|
||||
{isFileComponent(component) && (() => {
|
||||
const fileComponent = component as any;
|
||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||
{isFileComponent(component) &&
|
||||
(() => {
|
||||
const fileComponent = component as any;
|
||||
const uploadedFiles = fileComponent.uploadedFiles || [];
|
||||
|
||||
// 전역 상태에서 최신 파일 정보 가져오기
|
||||
const globalFileState = typeof window !== 'undefined' ? (window as any).globalFileState || {} : {};
|
||||
const globalFiles = globalFileState[component.id] || [];
|
||||
// 전역 상태에서 최신 파일 정보 가져오기
|
||||
const globalFileState = typeof window !== "undefined" ? (window as any).globalFileState || {} : {};
|
||||
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,
|
||||
// uploadedFilesCount: uploadedFiles.length,
|
||||
// globalFilesCount: globalFiles.length,
|
||||
|
|
@ -432,73 +466,76 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
// componentType: component.type,
|
||||
// fileUpdateTrigger: fileUpdateTrigger,
|
||||
// timestamp: new Date().toISOString()
|
||||
// });
|
||||
// });
|
||||
|
||||
return (
|
||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||
{currentFiles.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mb-1 text-xs font-medium text-gray-700">
|
||||
업로드된 파일 ({currentFiles.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentFiles.map((file: any, index: number) => {
|
||||
// 파일 확장자에 따른 아이콘 선택
|
||||
const getFileIcon = (fileName: string) => {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase() || '';
|
||||
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) {
|
||||
return <ImageIcon className="h-4 w-4 text-green-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['pdf', 'doc', 'docx', 'txt', 'rtf', 'hwp', 'hwpx', 'hwpml', 'pages'].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 text-red-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['ppt', 'pptx', 'hpt', 'keynote'].includes(ext)) {
|
||||
return <Presentation className="h-4 w-4 text-orange-600 flex-shrink-0" />;
|
||||
}
|
||||
if (['xls', 'xlsx', 'hcdt', 'numbers'].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 text-green-600 flex-shrink-0" />;
|
||||
}
|
||||
if (['mp4', 'avi', 'mov', 'wmv', 'webm', 'ogg'].includes(ext)) {
|
||||
return <Video className="h-4 w-4 text-purple-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['mp3', 'wav', 'flac', 'aac'].includes(ext)) {
|
||||
return <Music className="h-4 w-4 text-orange-500 flex-shrink-0" />;
|
||||
}
|
||||
if (['zip', 'rar', '7z', 'tar'].includes(ext)) {
|
||||
return <Archive className="h-4 w-4 text-yellow-500 flex-shrink-0" />;
|
||||
}
|
||||
return <File className="h-4 w-4 text-blue-500 flex-shrink-0" />;
|
||||
};
|
||||
return (
|
||||
<div key={`file-component-${component.id}-${fileUpdateTrigger}`} className="flex h-full flex-col">
|
||||
<div className="pointer-events-none flex-1 rounded border-2 border-dashed border-gray-300 bg-gray-50 p-2">
|
||||
{currentFiles.length > 0 ? (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="mb-1 text-xs font-medium text-gray-700">
|
||||
업로드된 파일 ({currentFiles.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{currentFiles.map((file: any, index: number) => {
|
||||
// 파일 확장자에 따른 아이콘 선택
|
||||
const getFileIcon = (fileName: string) => {
|
||||
const ext = fileName.split(".").pop()?.toLowerCase() || "";
|
||||
if (["jpg", "jpeg", "png", "gif", "webp", "svg"].includes(ext)) {
|
||||
return <ImageIcon className="h-4 w-4 flex-shrink-0 text-green-500" />;
|
||||
}
|
||||
if (["pdf", "doc", "docx", "txt", "rtf", "hwp", "hwpx", "hwpml", "pages"].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 flex-shrink-0 text-red-500" />;
|
||||
}
|
||||
if (["ppt", "pptx", "hpt", "keynote"].includes(ext)) {
|
||||
return <Presentation className="h-4 w-4 flex-shrink-0 text-orange-600" />;
|
||||
}
|
||||
if (["xls", "xlsx", "hcdt", "numbers"].includes(ext)) {
|
||||
return <FileText className="h-4 w-4 flex-shrink-0 text-green-600" />;
|
||||
}
|
||||
if (["mp4", "avi", "mov", "wmv", "webm", "ogg"].includes(ext)) {
|
||||
return <Video className="h-4 w-4 flex-shrink-0 text-purple-500" />;
|
||||
}
|
||||
if (["mp3", "wav", "flac", "aac"].includes(ext)) {
|
||||
return <Music className="h-4 w-4 flex-shrink-0 text-orange-500" />;
|
||||
}
|
||||
if (["zip", "rar", "7z", "tar"].includes(ext)) {
|
||||
return <Archive className="h-4 w-4 flex-shrink-0 text-yellow-500" />;
|
||||
}
|
||||
return <File className="h-4 w-4 flex-shrink-0 text-blue-500" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={file.objid || index} className="flex items-center space-x-2 bg-white rounded p-2 text-xs">
|
||||
{getFileIcon(file.realFileName || file.name || '')}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate font-medium text-gray-900">
|
||||
{file.realFileName || file.name || `파일 ${index + 1}`}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ''}
|
||||
</p>
|
||||
return (
|
||||
<div
|
||||
key={file.objid || index}
|
||||
className="flex items-center space-x-2 rounded bg-white p-2 text-xs"
|
||||
>
|
||||
{getFileIcon(file.realFileName || file.name || "")}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium text-gray-900">
|
||||
{file.realFileName || file.name || `파일 ${index + 1}`}
|
||||
</p>
|
||||
<p className="text-gray-500">
|
||||
{file.fileSize ? `${Math.round(file.fileSize / 1024)} KB` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<File className="mb-2 h-8 w-8 text-gray-400" />
|
||||
<p className="text-xs font-medium text-gray-700 mb-1">업로드된 파일 (0)</p>
|
||||
<p className="text-sm text-muted-foreground">파일 업로드 영역</p>
|
||||
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center text-center">
|
||||
<File className="mb-2 h-8 w-8 text-gray-400" />
|
||||
<p className="mb-1 text-xs font-medium text-gray-700">업로드된 파일 (0)</p>
|
||||
<p className="text-muted-foreground text-sm">파일 업로드 영역</p>
|
||||
<p className="mt-1 text-xs text-gray-400">상세설정에서 파일을 업로드하세요</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* 선택된 컴포넌트 정보 표시 */}
|
||||
|
|
|
|||
|
|
@ -83,7 +83,7 @@ export const RealtimePreviewDynamic: React.FC<RealtimePreviewProps> = ({
|
|||
// 선택 상태에 따른 스타일 (z-index 낮춤 - 패널과 모달보다 아래)
|
||||
const selectionStyle = isSelected
|
||||
? {
|
||||
outline: "2px solid hsl(var(--primary))",
|
||||
outline: "2px solid rgb(59, 130, 246)",
|
||||
outlineOffset: "2px",
|
||||
zIndex: 20,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1997,6 +1997,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
"table-list": 12, // 테이블 리스트 (100%)
|
||||
"image-display": 4, // 이미지 표시 (33%)
|
||||
"split-panel-layout": 6, // 분할 패널 레이아웃 (50%)
|
||||
"flow-widget": 12, // 플로우 위젯 (100%)
|
||||
|
||||
// 액션 컴포넌트 (ACTION 카테고리)
|
||||
"button-basic": 1, // 버튼 (8.33%)
|
||||
|
|
@ -2016,8 +2017,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList }: ScreenD
|
|||
"chart-basic": 6, // 차트 (50%)
|
||||
};
|
||||
|
||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
||||
// defaultSize에 gridColumnSpan이 "full"이면 12컬럼 사용
|
||||
if (component.defaultSize?.gridColumnSpan === "full") {
|
||||
gridColumns = 12;
|
||||
} else {
|
||||
// componentId 또는 webType으로 매핑, 없으면 기본값 3
|
||||
gridColumns = gridColumnsMap[componentId] || gridColumnsMap[webType] || 3;
|
||||
}
|
||||
|
||||
console.log("🎯 컴포넌트 타입별 gridColumns 설정:", {
|
||||
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>
|
||||
|
||||
{/* 세부 타입 선택 영역 */}
|
||||
{webType && availableDetailTypes.length > 1 && (
|
||||
<div className="border-b border-gray-200 bg-gray-50 p-6 pt-0">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700">세부 타입 선택</label>
|
||||
<Select value={localComponentDetailType || webType} onValueChange={handleDetailTypeChange}>
|
||||
<SelectTrigger className="w-full bg-white">
|
||||
<SelectValue placeholder="세부 타입을 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableDetailTypes.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
<div className="flex flex-col">
|
||||
<span className="font-medium">{option.label}</span>
|
||||
<span className="text-xs text-gray-500">{option.description}</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-gray-500">
|
||||
입력 타입 "{currentBaseInputType}"에 사용할 구체적인 형태를 선택하세요
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 컴포넌트 설정 패널 */}
|
||||
<div className="flex-1 overflow-y-auto px-6 pb-6">
|
||||
<div className="space-y-6">
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -548,22 +548,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
handleUpdate("componentConfig", newConfig);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 웹타입별 특화 설정 */}
|
||||
{webType && (
|
||||
<div className="border-t pt-4">
|
||||
<h4 className="mb-2 text-sm font-semibold">웹타입 설정</h4>
|
||||
<WebTypeConfigPanel
|
||||
webType={webType as any}
|
||||
config={selectedComponent.componentConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`componentConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -592,17 +576,6 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
|||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* WebType 설정 패널 */}
|
||||
<WebTypeConfigPanel
|
||||
webType={widget.webType as any}
|
||||
config={widget.webTypeConfig || {}}
|
||||
onUpdateConfig={(newConfig) => {
|
||||
Object.entries(newConfig).forEach(([key, value]) => {
|
||||
handleUpdate(`webTypeConfig.${key}`, value);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
"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 } from "lucide-react";
|
||||
import { getFlowById, getAllStepCounts } from "@/lib/api/flow";
|
||||
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
||||
import { FlowDataListModal } from "@/components/flow/FlowDataListModal";
|
||||
|
||||
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 [modalOpen, setModalOpen] = useState(false);
|
||||
const [selectedStep, setSelectedStep] = useState<{ id: number; name: string } | null>(null);
|
||||
|
||||
// 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);
|
||||
|
||||
// 스텝별 데이터 건수 조회
|
||||
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 = (stepId: number, stepName: string) => {
|
||||
if (onStepClick) {
|
||||
onStepClick(stepId, stepName);
|
||||
} else {
|
||||
// 기본 동작: 모달 열기
|
||||
setSelectedStep({ id: stepId, name: stepName });
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
// 데이터 이동 후 리프레시
|
||||
const handleDataMoved = async () => {
|
||||
if (!flowId) return;
|
||||
|
||||
try {
|
||||
// 스텝별 데이터 건수 다시 조회
|
||||
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) {
|
||||
console.error("Failed to refresh step counts:", err);
|
||||
}
|
||||
};
|
||||
|
||||
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-wrap items-center justify-center gap-3"
|
||||
: "flex flex-col items-center gap-4";
|
||||
|
||||
return (
|
||||
<div className="min-h-full w-full p-4">
|
||||
{/* 플로우 제목 */}
|
||||
<div className="mb-4 text-center">
|
||||
<h3 className="text-foreground text-lg font-semibold">{flowData.name}</h3>
|
||||
{flowData.description && <p className="text-muted-foreground mt-1 text-sm">{flowData.description}</p>}
|
||||
</div>
|
||||
|
||||
{/* 플로우 스텝 목록 */}
|
||||
<div className={containerClass}>
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.id}>
|
||||
{/* 스텝 카드 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hover:border-primary hover:bg-accent flex shrink-0 flex-col items-start gap-3 p-5"
|
||||
onClick={() => handleStepClick(step.id, step.stepName)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between gap-2">
|
||||
<Badge variant="outline" className="text-sm">
|
||||
단계 {step.stepOrder}
|
||||
</Badge>
|
||||
{showStepCount && (
|
||||
<Badge variant="secondary" className="text-sm font-semibold">
|
||||
{stepCounts[step.id] || 0}건
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full text-left">
|
||||
<div className="text-foreground text-base font-semibold">{step.stepName}</div>
|
||||
{step.tableName && (
|
||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-sm">
|
||||
<span>📊</span>
|
||||
<span>{step.tableName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* 화살표 (마지막 스텝 제외) */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="text-muted-foreground flex shrink-0 items-center justify-center text-2xl font-bold">
|
||||
{displayMode === "horizontal" ? "→" : "↓"}
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 데이터 목록 모달 */}
|
||||
{selectedStep && flowId && (
|
||||
<FlowDataListModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
flowId={flowId}
|
||||
stepId={selectedStep.id}
|
||||
stepName={selectedStep.name}
|
||||
allowDataMove={allowDataMove}
|
||||
onDataMoved={handleDataMoved}
|
||||
/>
|
||||
)}
|
||||
</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,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 "./map/MapRenderer";
|
||||
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"),
|
||||
"split-panel-layout": () => import("@/lib/registry/components/split-panel-layout/SplitPanelLayoutConfigPanel"),
|
||||
"repeater-field-group": () => import("@/components/webtypes/config/RepeaterConfigPanel"),
|
||||
"flow-widget": () => import("@/components/screen/config-panels/FlowWidgetConfigPanel"),
|
||||
};
|
||||
|
||||
// ConfigPanel 컴포넌트 캐시
|
||||
|
|
@ -54,6 +55,7 @@ export async function getComponentConfigPanel(componentId: string): Promise<Reac
|
|||
const ConfigPanelComponent =
|
||||
module[`${toPascalCase(componentId)}ConfigPanel`] ||
|
||||
module.RepeaterConfigPanel || // repeater-field-group의 export명
|
||||
module.FlowWidgetConfigPanel || // flow-widget의 export명
|
||||
module.default;
|
||||
|
||||
if (!ConfigPanelComponent) {
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
"react-leaflet": "^5.0.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.10.4",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
@ -2678,12 +2678,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@reactflow/background": {
|
||||
"version": "11.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.9.tgz",
|
||||
"integrity": "sha512-byj/G9pEC8tN0wT/ptcl/LkEP/BBfa33/SvBkqE4XwyofckqF87lKp573qGlisfnsijwAbpDlf81PuFL41So4Q==",
|
||||
"version": "11.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/background/-/background-11.3.14.tgz",
|
||||
"integrity": "sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
|
|
@ -2693,12 +2693,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@reactflow/controls": {
|
||||
"version": "11.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.9.tgz",
|
||||
"integrity": "sha512-e8nWplbYfOn83KN1BrxTXS17+enLyFnjZPbyDgHSRLtI5ZGPKF/8iRXV+VXb2LFVzlu4Wh3la/pkxtfP/0aguA==",
|
||||
"version": "11.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/controls/-/controls-11.2.14.tgz",
|
||||
"integrity": "sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
|
|
@ -2708,9 +2708,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@reactflow/core": {
|
||||
"version": "11.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.10.4.tgz",
|
||||
"integrity": "sha512-j3i9b2fsTX/sBbOm+RmNzYEFWbNx4jGWGuGooh2r1jQaE2eV+TLJgiG/VNOp0q5mBl9f6g1IXs3Gm86S9JfcGw==",
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/core/-/core-11.11.4.tgz",
|
||||
"integrity": "sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3": "^7.4.0",
|
||||
|
|
@ -2729,12 +2729,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@reactflow/minimap": {
|
||||
"version": "11.7.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.9.tgz",
|
||||
"integrity": "sha512-le95jyTtt3TEtJ1qa7tZ5hyM4S7gaEQkW43cixcMOZLu33VAdc2aCpJg/fXcRrrf7moN2Mbl9WIMNXUKsp5ILA==",
|
||||
"version": "11.7.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/minimap/-/minimap-11.7.14.tgz",
|
||||
"integrity": "sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@types/d3-selection": "^3.0.3",
|
||||
"@types/d3-zoom": "^3.0.1",
|
||||
"classcat": "^5.0.3",
|
||||
|
|
@ -2748,12 +2748,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-resizer": {
|
||||
"version": "2.2.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.9.tgz",
|
||||
"integrity": "sha512-HfickMm0hPDIHt9qH997nLdgLt0kayQyslKE0RS/GZvZ4UMQJlx/NRRyj5y47Qyg0NnC66KYOQWDM9LLzRTnUg==",
|
||||
"version": "2.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-resizer/-/node-resizer-2.2.14.tgz",
|
||||
"integrity": "sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.4",
|
||||
"d3-drag": "^3.0.0",
|
||||
"d3-selection": "^3.0.0",
|
||||
|
|
@ -2765,12 +2765,12 @@
|
|||
}
|
||||
},
|
||||
"node_modules/@reactflow/node-toolbar": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.9.tgz",
|
||||
"integrity": "sha512-VmgxKmToax4sX1biZ9LXA7cj/TBJ+E5cklLGwquCCVVxh+lxpZGTBF3a5FJGVHiUNBBtFsC8ldcSZIK4cAlQww==",
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@reactflow/node-toolbar/-/node-toolbar-1.3.14.tgz",
|
||||
"integrity": "sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"classcat": "^5.0.3",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
|
|
@ -9526,17 +9526,17 @@
|
|||
}
|
||||
},
|
||||
"node_modules/reactflow": {
|
||||
"version": "11.10.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.10.4.tgz",
|
||||
"integrity": "sha512-0CApYhtYicXEDg/x2kvUHiUk26Qur8lAtTtiSlptNKuyEuGti6P1y5cS32YGaUoDMoCqkm/m+jcKkfMOvSCVRA==",
|
||||
"version": "11.11.4",
|
||||
"resolved": "https://registry.npmjs.org/reactflow/-/reactflow-11.11.4.tgz",
|
||||
"integrity": "sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@reactflow/background": "11.3.9",
|
||||
"@reactflow/controls": "11.2.9",
|
||||
"@reactflow/core": "11.10.4",
|
||||
"@reactflow/minimap": "11.7.9",
|
||||
"@reactflow/node-resizer": "2.2.9",
|
||||
"@reactflow/node-toolbar": "1.3.9"
|
||||
"@reactflow/background": "11.3.14",
|
||||
"@reactflow/controls": "11.2.14",
|
||||
"@reactflow/core": "11.11.4",
|
||||
"@reactflow/minimap": "11.7.14",
|
||||
"@reactflow/node-resizer": "2.2.14",
|
||||
"@reactflow/node-toolbar": "1.3.14"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=17",
|
||||
|
|
|
|||
|
|
@ -70,7 +70,7 @@
|
|||
"react-leaflet": "^5.0.0",
|
||||
"react-resizable-panels": "^3.0.6",
|
||||
"react-window": "^2.1.0",
|
||||
"reactflow": "^11.10.4",
|
||||
"reactflow": "^11.11.4",
|
||||
"recharts": "^3.2.1",
|
||||
"sheetjs-style": "^0.15.8",
|
||||
"sonner": "^2.0.7",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* 플로우 관리 시스템 - 프론트엔드 타입 정의
|
||||
*/
|
||||
|
||||
// ============================================
|
||||
// 조건 연산자
|
||||
// ============================================
|
||||
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;
|
||||
recordIds: string[];
|
||||
toStepId: number;
|
||||
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;
|
||||
}
|
||||
|
|
@ -135,6 +135,18 @@ export interface FileComponent extends BaseComponent {
|
|||
lastFileUpdate?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 플로우 컴포넌트
|
||||
*/
|
||||
export interface FlowComponent extends BaseComponent {
|
||||
type: "flow";
|
||||
flowId?: number; // 선택된 플로우 ID
|
||||
flowName?: string; // 플로우 이름 (표시용)
|
||||
showStepCount?: boolean; // 각 스텝의 데이터 건수 표시 여부
|
||||
allowDataMove?: boolean; // 데이터 이동 허용 여부
|
||||
displayMode?: "horizontal" | "vertical"; // 플로우 표시 방향
|
||||
}
|
||||
|
||||
/**
|
||||
* 새로운 컴포넌트 시스템 컴포넌트
|
||||
*/
|
||||
|
|
@ -154,6 +166,7 @@ export type ComponentData =
|
|||
| GroupComponent
|
||||
| DataTableComponent
|
||||
| FileComponent
|
||||
| FlowComponent
|
||||
| ComponentComponent;
|
||||
|
||||
// ===== 웹타입별 설정 인터페이스 =====
|
||||
|
|
@ -606,6 +619,13 @@ export const isFileComponent = (component: ComponentData): component is FileComp
|
|||
return component.type === "file";
|
||||
};
|
||||
|
||||
/**
|
||||
* FlowComponent 타입 가드
|
||||
*/
|
||||
export const isFlowComponent = (component: ComponentData): component is FlowComponent => {
|
||||
return component.type === "flow";
|
||||
};
|
||||
|
||||
// ===== 안전한 타입 캐스팅 유틸리티 =====
|
||||
|
||||
/**
|
||||
|
|
@ -657,3 +677,13 @@ export const asFileComponent = (component: ComponentData): FileComponent => {
|
|||
}
|
||||
return component;
|
||||
};
|
||||
|
||||
/**
|
||||
* ComponentData를 FlowComponent로 안전하게 캐스팅
|
||||
*/
|
||||
export const asFlowComponent = (component: ComponentData): FlowComponent => {
|
||||
if (!isFlowComponent(component)) {
|
||||
throw new Error(`Expected FlowComponent, got ${component.type}`);
|
||||
}
|
||||
return component;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -81,7 +81,9 @@ export type ComponentType =
|
|||
| "datatable"
|
||||
| "file"
|
||||
| "area"
|
||||
| "layout";
|
||||
| "layout"
|
||||
| "flow"
|
||||
| "component";
|
||||
|
||||
/**
|
||||
* 기본 위치 정보
|
||||
|
|
|
|||
Loading…
Reference in New Issue