Compare commits

..

No commits in common. "f9c171c5135c0b990bee091539274205e7f404de" and "52c7391cf5059d796b42cbd857adef0db7eaa6cd" have entirely different histories.

37 changed files with 238 additions and 6881 deletions

File diff suppressed because it is too large Load Diff

View File

@ -57,7 +57,6 @@ 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"; // 임시 주석
@ -209,7 +208,6 @@ 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);

View File

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

View File

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

View File

@ -1,203 +0,0 @@
/**
*
* JSON SQL WHERE
*/
import {
FlowCondition,
FlowConditionGroup,
SqlWhereResult,
} from "../types/flow";
export class FlowConditionParser {
/**
* JSON을 SQL WHERE
*/
static toSqlWhere(
conditionGroup: FlowConditionGroup | null | undefined
): SqlWhereResult {
if (
!conditionGroup ||
!conditionGroup.conditions ||
conditionGroup.conditions.length === 0
) {
return { where: "1=1", params: [] };
}
const conditions: string[] = [];
const params: any[] = [];
let paramIndex = 1;
for (const condition of conditionGroup.conditions) {
const column = this.sanitizeColumnName(condition.column);
switch (condition.operator) {
case "equals":
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`
);
}
}
}
}

View File

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

View File

@ -1,181 +0,0 @@
/**
*
*
*/
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,
}));
}
}

View File

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

View File

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

View File

@ -1,202 +0,0 @@
/**
*
*/
import db from "../database/db";
import {
FlowStep,
CreateFlowStepRequest,
UpdateFlowStepRequest,
FlowConditionGroup,
} from "../types/flow";
import { FlowConditionParser } from "./flowConditionParser";
export class FlowStepService {
/**
*
*/
async create(request: CreateFlowStepRequest): Promise<FlowStep> {
// 조건 검증
if (request.conditionJson) {
FlowConditionParser.validateConditionGroup(request.conditionJson);
}
const query = `
INSERT INTO flow_step (
flow_definition_id, step_name, step_order, table_name, condition_json,
color, position_x, position_y
)
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,
};
}
}

View File

@ -1,175 +0,0 @@
/**
*
*/
// 플로우 정의
export interface FlowDefinition {
id: number;
name: string;
description?: string;
tableName: string;
isActive: boolean;
createdBy?: string;
createdAt: Date;
updatedAt: Date;
}
// 플로우 정의 생성 요청
export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
}
// 플로우 정의 수정 요청
export interface UpdateFlowDefinitionRequest {
name?: string;
description?: string;
isActive?: boolean;
}
// 조건 연산자
export type ConditionOperator =
| "equals"
| "not_equals"
| "in"
| "not_in"
| "greater_than"
| "less_than"
| "greater_than_or_equal"
| "less_than_or_equal"
| "is_null"
| "is_not_null"
| "like"
| "not_like";
// 플로우 조건
export interface FlowCondition {
column: string;
operator: ConditionOperator;
value: any;
}
// 플로우 조건 그룹
export interface FlowConditionGroup {
type: "AND" | "OR";
conditions: FlowCondition[];
}
// 플로우 단계
export interface FlowStep {
id: number;
flowDefinitionId: number;
stepName: string;
stepOrder: number;
tableName?: string; // 이 단계에서 조회할 테이블명 (NULL이면 flow_definition의 tableName 사용)
conditionJson?: FlowConditionGroup;
color: string;
positionX: number;
positionY: number;
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[];
}

View File

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

View File

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

View File

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

View File

@ -1,327 +0,0 @@
"use client";
/**
*
* -
* - //
* -
*/
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Plus, Edit2, Trash2, Play, Workflow, Table, Calendar, User } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { useToast } from "@/hooks/use-toast";
import { getFlowDefinitions, createFlowDefinition, deleteFlowDefinition } from "@/lib/api/flow";
import { FlowDefinition } from "@/types/flow";
export default function FlowManagementPage() {
const router = useRouter();
const { toast } = useToast();
// 상태
const [flows, setFlows] = useState<FlowDefinition[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [selectedFlow, setSelectedFlow] = useState<FlowDefinition | null>(null);
// 생성 폼 상태
const [formData, setFormData] = useState({
name: "",
description: "",
tableName: "",
});
// 플로우 목록 조회
const loadFlows = async () => {
setLoading(true);
try {
const response = await getFlowDefinitions({ isActive: true });
if (response.success && response.data) {
setFlows(response.data);
} else {
toast({
title: "조회 실패",
description: response.error || "플로우 목록을 불러올 수 없습니다.",
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
loadFlows();
}, []);
// 플로우 생성
const handleCreate = async () => {
if (!formData.name || !formData.tableName) {
toast({
title: "입력 오류",
description: "플로우 이름과 테이블 이름은 필수입니다.",
variant: "destructive",
});
return;
}
try {
const response = await createFlowDefinition(formData);
if (response.success && response.data) {
toast({
title: "생성 완료",
description: "플로우가 성공적으로 생성되었습니다.",
});
setIsCreateDialogOpen(false);
setFormData({ name: "", description: "", tableName: "" });
loadFlows();
} else {
toast({
title: "생성 실패",
description: response.error || response.message,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
};
// 플로우 삭제
const handleDelete = async () => {
if (!selectedFlow) return;
try {
const response = await deleteFlowDefinition(selectedFlow.id);
if (response.success) {
toast({
title: "삭제 완료",
description: "플로우가 삭제되었습니다.",
});
setIsDeleteDialogOpen(false);
setSelectedFlow(null);
loadFlows();
} else {
toast({
title: "삭제 실패",
description: response.error,
variant: "destructive",
});
}
} catch (error: any) {
toast({
title: "오류 발생",
description: error.message,
variant: "destructive",
});
}
};
// 플로우 편집기로 이동
const handleEdit = (flowId: number) => {
router.push(`/admin/flow-management/${flowId}`);
};
return (
<div className="container mx-auto space-y-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>
);
}

View File

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

View File

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

View File

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

View File

@ -1,246 +0,0 @@
/**
*
*
*/
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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,238 +0,0 @@
"use client";
import React, { useEffect, useState } from "react";
import { FlowComponent } from "@/types/screen-management";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { AlertCircle, Loader2 } 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>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,224 +0,0 @@
/**
* -
*/
// ============================================
// 조건 연산자
// ============================================
export type ConditionOperator =
| "equals"
| "not_equals"
| "in"
| "not_in"
| "greater_than"
| "less_than"
| "greater_than_or_equal"
| "less_than_or_equal"
| "is_null"
| "is_not_null"
| "like"
| "not_like";
// ============================================
// 플로우 조건
// ============================================
export interface FlowCondition {
column: string;
operator: ConditionOperator;
value?: any;
}
export interface FlowConditionGroup {
type: "AND" | "OR";
conditions: FlowCondition[];
}
// ============================================
// 플로우 정의
// ============================================
export interface FlowDefinition {
id: number;
name: string;
description?: string;
tableName: string;
isActive: boolean;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
}
export interface CreateFlowDefinitionRequest {
name: string;
description?: string;
tableName: string;
}
export interface UpdateFlowDefinitionRequest {
name?: string;
description?: string;
tableName?: string;
isActive?: boolean;
}
// ============================================
// 플로우 단계
// ============================================
export interface FlowStep {
id: number;
flowDefinitionId: number;
stepName: string;
stepOrder: number;
tableName?: string; // 이 단계에서 조회할 테이블명
conditionJson?: FlowConditionGroup;
color: string;
positionX: number;
positionY: number;
createdAt: string;
updatedAt: string;
createdBy: string;
updatedBy: string;
}
export interface CreateFlowStepRequest {
stepName: string;
stepOrder: number;
tableName?: string; // 이 단계에서 조회할 테이블명
conditionJson?: FlowConditionGroup;
color?: string;
positionX?: number;
positionY?: number;
}
export interface UpdateFlowStepRequest {
stepName?: string;
tableName?: string; // 이 단계에서 조회할 테이블명
stepOrder?: number;
conditionJson?: FlowConditionGroup;
color?: string;
positionX?: number;
positionY?: number;
}
// ============================================
// 플로우 단계 연결
// ============================================
export interface FlowStepConnection {
id: number;
flowDefinitionId: number;
fromStepId: number;
toStepId: number;
label?: string;
createdAt: string;
updatedAt: string;
}
export interface CreateFlowConnectionRequest {
flowDefinitionId: number;
fromStepId: number;
toStepId: number;
label?: string;
}
// ============================================
// 플로우 데이터 상태
// ============================================
export interface FlowDataStatus {
id: number;
flowDefinitionId: number;
tableName: string;
recordId: string;
currentStepId: number | null;
updatedAt: string;
updatedBy: string;
}
// ============================================
// 플로우 오딧 로그
// ============================================
export interface FlowAuditLog {
id: number;
flowDefinitionId: number;
tableName: string;
recordId: string;
fromStepId: number | null;
toStepId: number | null;
changedAt: string;
changedBy: string;
note?: string;
fromStepName?: string;
toStepName?: string;
}
// ============================================
// 플로우 실행 관련
// ============================================
export interface FlowStepDataCount {
stepId: number;
stepName: string;
count: number;
}
export interface FlowStepDataList {
records: any[];
total: number;
page: number;
pageSize: number;
}
export interface MoveDataRequest {
flowId: number;
recordId: string;
toStepId: number;
note?: string;
}
export interface MoveBatchDataRequest {
flowId: number;
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;
}

View File

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

View File

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