플로우 분기처리 구현
This commit is contained in:
@@ -56,7 +56,7 @@ import todoRoutes from "./routes/todoRoutes"; // To-Do 관리
|
|||||||
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
import bookingRoutes from "./routes/bookingRoutes"; // 예약 요청 관리
|
||||||
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
import mapDataRoutes from "./routes/mapDataRoutes"; // 지도 데이터 관리
|
||||||
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||||
import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
//import materialRoutes from "./routes/materialRoutes"; // 자재 관리
|
||||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
@@ -208,7 +208,7 @@ app.use("/api/todos", todoRoutes); // To-Do 관리
|
|||||||
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
app.use("/api/bookings", bookingRoutes); // 예약 요청 관리
|
||||||
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
app.use("/api/map-data", mapDataRoutes); // 지도 데이터 조회
|
||||||
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||||
app.use("/api/materials", materialRoutes); // 자재 관리
|
// app.use("/api/materials", materialRoutes); // 자재 관리 (임시 주석)
|
||||||
app.use("/api/flow", flowRoutes); // 플로우 관리
|
app.use("/api/flow", flowRoutes); // 플로우 관리
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
|
|||||||
@@ -573,28 +573,46 @@ export class FlowController {
|
|||||||
*/
|
*/
|
||||||
moveBatchData = async (req: Request, res: Response): Promise<void> => {
|
moveBatchData = async (req: Request, res: Response): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const { flowId, recordIds, toStepId, note } = req.body;
|
const { flowId, fromStepId, toStepId, dataIds } = req.body;
|
||||||
const userId = (req as any).user?.userId || "system";
|
const userId = (req as any).user?.userId || "system";
|
||||||
|
|
||||||
if (!flowId || !recordIds || !Array.isArray(recordIds) || !toStepId) {
|
if (
|
||||||
|
!flowId ||
|
||||||
|
!fromStepId ||
|
||||||
|
!toStepId ||
|
||||||
|
!dataIds ||
|
||||||
|
!Array.isArray(dataIds)
|
||||||
|
) {
|
||||||
res.status(400).json({
|
res.status(400).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: "flowId, recordIds (array), and toStepId are required",
|
message:
|
||||||
|
"flowId, fromStepId, toStepId, and dataIds (array) are required",
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.flowDataMoveService.moveBatchData(
|
const result = await this.flowDataMoveService.moveBatchData(
|
||||||
flowId,
|
flowId,
|
||||||
recordIds,
|
fromStepId,
|
||||||
toStepId,
|
toStepId,
|
||||||
userId,
|
dataIds,
|
||||||
note
|
userId
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const successCount = result.results.filter((r) => r.success).length;
|
||||||
|
const failureCount = result.results.filter((r) => !r.success).length;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: result.success,
|
||||||
message: `${recordIds.length} records moved successfully`,
|
message: result.success
|
||||||
|
? `${successCount}건의 데이터를 성공적으로 이동했습니다`
|
||||||
|
: `${successCount}건 성공, ${failureCount}건 실패`,
|
||||||
|
data: {
|
||||||
|
successCount,
|
||||||
|
failureCount,
|
||||||
|
total: dataIds.length,
|
||||||
|
},
|
||||||
|
results: result.results,
|
||||||
});
|
});
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error("Error moving batch data:", error);
|
console.error("Error moving batch data:", error);
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ export class FlowConditionParser {
|
|||||||
|
|
||||||
switch (condition.operator) {
|
switch (condition.operator) {
|
||||||
case "equals":
|
case "equals":
|
||||||
|
case "=":
|
||||||
conditions.push(`${column} = $${paramIndex}`);
|
conditions.push(`${column} = $${paramIndex}`);
|
||||||
params.push(condition.value);
|
params.push(condition.value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "not_equals":
|
case "not_equals":
|
||||||
|
case "!=":
|
||||||
conditions.push(`${column} != $${paramIndex}`);
|
conditions.push(`${column} != $${paramIndex}`);
|
||||||
params.push(condition.value);
|
params.push(condition.value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
@@ -65,24 +67,28 @@ export class FlowConditionParser {
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case "greater_than":
|
case "greater_than":
|
||||||
|
case ">":
|
||||||
conditions.push(`${column} > $${paramIndex}`);
|
conditions.push(`${column} > $${paramIndex}`);
|
||||||
params.push(condition.value);
|
params.push(condition.value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "less_than":
|
case "less_than":
|
||||||
|
case "<":
|
||||||
conditions.push(`${column} < $${paramIndex}`);
|
conditions.push(`${column} < $${paramIndex}`);
|
||||||
params.push(condition.value);
|
params.push(condition.value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "greater_than_or_equal":
|
case "greater_than_or_equal":
|
||||||
|
case ">=":
|
||||||
conditions.push(`${column} >= $${paramIndex}`);
|
conditions.push(`${column} >= $${paramIndex}`);
|
||||||
params.push(condition.value);
|
params.push(condition.value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "less_than_or_equal":
|
case "less_than_or_equal":
|
||||||
|
case "<=":
|
||||||
conditions.push(`${column} <= $${paramIndex}`);
|
conditions.push(`${column} <= $${paramIndex}`);
|
||||||
params.push(condition.value);
|
params.push(condition.value);
|
||||||
paramIndex++;
|
paramIndex++;
|
||||||
@@ -165,13 +171,19 @@ export class FlowConditionParser {
|
|||||||
|
|
||||||
const validOperators = [
|
const validOperators = [
|
||||||
"equals",
|
"equals",
|
||||||
|
"=",
|
||||||
"not_equals",
|
"not_equals",
|
||||||
|
"!=",
|
||||||
"in",
|
"in",
|
||||||
"not_in",
|
"not_in",
|
||||||
"greater_than",
|
"greater_than",
|
||||||
|
">",
|
||||||
"less_than",
|
"less_than",
|
||||||
|
"<",
|
||||||
"greater_than_or_equal",
|
"greater_than_or_equal",
|
||||||
|
">=",
|
||||||
"less_than_or_equal",
|
"less_than_or_equal",
|
||||||
|
"<=",
|
||||||
"is_null",
|
"is_null",
|
||||||
"is_not_null",
|
"is_not_null",
|
||||||
"like",
|
"like",
|
||||||
|
|||||||
@@ -1,118 +1,360 @@
|
|||||||
/**
|
/**
|
||||||
* 플로우 데이터 이동 서비스
|
* 플로우 데이터 이동 서비스 (하이브리드 방식 지원)
|
||||||
* 데이터의 플로우 단계 이동 및 오딧 로그 관리
|
* - 상태 변경 방식: 같은 테이블 내에서 상태 컬럼 업데이트
|
||||||
|
* - 테이블 이동 방식: 다른 테이블로 데이터 복사 및 매핑
|
||||||
|
* - 하이브리드 방식: 두 가지 모두 수행
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import db from "../database/db";
|
import db from "../database/db";
|
||||||
import { FlowAuditLog } from "../types/flow";
|
import { FlowAuditLog } from "../types/flow";
|
||||||
import { FlowDefinitionService } from "./flowDefinitionService";
|
import { FlowDefinitionService } from "./flowDefinitionService";
|
||||||
|
import { FlowStepService } from "./flowStepService";
|
||||||
|
|
||||||
export class FlowDataMoveService {
|
export class FlowDataMoveService {
|
||||||
private flowDefinitionService: FlowDefinitionService;
|
private flowDefinitionService: FlowDefinitionService;
|
||||||
|
private flowStepService: FlowStepService;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.flowDefinitionService = new FlowDefinitionService();
|
this.flowDefinitionService = new FlowDefinitionService();
|
||||||
|
this.flowStepService = new FlowStepService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터를 다음 플로우 단계로 이동
|
* 데이터를 다음 플로우 단계로 이동 (하이브리드 지원)
|
||||||
*/
|
*/
|
||||||
async moveDataToStep(
|
async moveDataToStep(
|
||||||
flowId: number,
|
flowId: number,
|
||||||
recordId: string,
|
fromStepId: number,
|
||||||
toStepId: number,
|
toStepId: number,
|
||||||
userId: string,
|
dataId: any,
|
||||||
note?: string
|
userId: string = "system",
|
||||||
): Promise<void> {
|
additionalData?: Record<string, any>
|
||||||
await db.transaction(async (client) => {
|
): Promise<{ success: boolean; targetDataId?: any; message?: string }> {
|
||||||
// 1. 플로우 정의 조회
|
return await db.transaction(async (client) => {
|
||||||
const flowDef = await this.flowDefinitionService.findById(flowId);
|
try {
|
||||||
if (!flowDef) {
|
// 1. 단계 정보 조회
|
||||||
throw new Error(`Flow definition not found: ${flowId}`);
|
const fromStep = await this.flowStepService.findById(fromStepId);
|
||||||
}
|
const toStep = await this.flowStepService.findById(toStepId);
|
||||||
|
|
||||||
// 2. 현재 상태 조회
|
if (!fromStep || !toStep) {
|
||||||
const currentStatusQuery = `
|
throw new Error("유효하지 않은 단계입니다");
|
||||||
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 업데이트 또는 삽입
|
let targetDataId = dataId;
|
||||||
if (currentStatus) {
|
let sourceTable = fromStep.tableName;
|
||||||
await client.query(
|
let targetTable = toStep.tableName || fromStep.tableName;
|
||||||
`
|
|
||||||
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. 오딧 로그 기록
|
// 2. 이동 방식에 따라 처리
|
||||||
await client.query(
|
switch (toStep.moveType || "status") {
|
||||||
`
|
case "status":
|
||||||
INSERT INTO flow_audit_log
|
// 상태 변경 방식
|
||||||
(flow_definition_id, table_name, record_id, from_step_id, to_step_id, changed_by, note)
|
await this.moveByStatusChange(
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
client,
|
||||||
`,
|
fromStep,
|
||||||
[
|
toStep,
|
||||||
|
dataId,
|
||||||
|
additionalData
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "table":
|
||||||
|
// 테이블 이동 방식
|
||||||
|
targetDataId = await this.moveByTableTransfer(
|
||||||
|
client,
|
||||||
|
fromStep,
|
||||||
|
toStep,
|
||||||
|
dataId,
|
||||||
|
additionalData
|
||||||
|
);
|
||||||
|
targetTable = toStep.targetTable || toStep.tableName;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "both":
|
||||||
|
// 하이브리드 방식: 둘 다 수행
|
||||||
|
await this.moveByStatusChange(
|
||||||
|
client,
|
||||||
|
fromStep,
|
||||||
|
toStep,
|
||||||
|
dataId,
|
||||||
|
additionalData
|
||||||
|
);
|
||||||
|
targetDataId = await this.moveByTableTransfer(
|
||||||
|
client,
|
||||||
|
fromStep,
|
||||||
|
toStep,
|
||||||
|
dataId,
|
||||||
|
additionalData
|
||||||
|
);
|
||||||
|
targetTable = toStep.targetTable || toStep.tableName;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error(`지원하지 않는 이동 방식: ${toStep.moveType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 매핑 테이블 업데이트 (테이블 이동 방식일 때)
|
||||||
|
if (toStep.moveType === "table" || toStep.moveType === "both") {
|
||||||
|
await this.updateDataMapping(
|
||||||
|
client,
|
||||||
|
flowId,
|
||||||
|
toStepId,
|
||||||
|
fromStepId,
|
||||||
|
dataId,
|
||||||
|
targetDataId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 감사 로그 기록
|
||||||
|
await this.logDataMove(client, {
|
||||||
flowId,
|
flowId,
|
||||||
flowDef.tableName,
|
|
||||||
recordId,
|
|
||||||
fromStepId,
|
fromStepId,
|
||||||
toStepId,
|
toStepId,
|
||||||
|
moveType: toStep.moveType || "status",
|
||||||
|
sourceTable,
|
||||||
|
targetTable,
|
||||||
|
sourceDataId: String(dataId),
|
||||||
|
targetDataId: String(targetDataId),
|
||||||
|
statusFrom: fromStep.statusValue,
|
||||||
|
statusTo: toStep.statusValue,
|
||||||
userId,
|
userId,
|
||||||
note || null,
|
});
|
||||||
]
|
|
||||||
);
|
return {
|
||||||
|
success: true,
|
||||||
|
targetDataId,
|
||||||
|
message: "데이터가 성공적으로 이동되었습니다",
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("데이터 이동 실패:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 상태 변경 방식으로 데이터 이동
|
||||||
|
*/
|
||||||
|
private async moveByStatusChange(
|
||||||
|
client: any,
|
||||||
|
fromStep: any,
|
||||||
|
toStep: any,
|
||||||
|
dataId: any,
|
||||||
|
additionalData?: Record<string, any>
|
||||||
|
): Promise<void> {
|
||||||
|
const statusColumn = toStep.statusColumn || "flow_status";
|
||||||
|
const tableName = fromStep.tableName;
|
||||||
|
|
||||||
|
// 추가 필드 업데이트 준비
|
||||||
|
const updates: string[] = [`${statusColumn} = $2`, `updated_at = NOW()`];
|
||||||
|
const values: any[] = [dataId, toStep.statusValue];
|
||||||
|
let paramIndex = 3;
|
||||||
|
|
||||||
|
// 추가 데이터가 있으면 함께 업데이트
|
||||||
|
if (additionalData) {
|
||||||
|
for (const [key, value] of Object.entries(additionalData)) {
|
||||||
|
updates.push(`${key} = $${paramIndex}`);
|
||||||
|
values.push(value);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE ${tableName}
|
||||||
|
SET ${updates.join(", ")}
|
||||||
|
WHERE id = $1
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await client.query(updateQuery, values);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error(`데이터를 찾을 수 없습니다: ${dataId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 이동 방식으로 데이터 이동
|
||||||
|
*/
|
||||||
|
private async moveByTableTransfer(
|
||||||
|
client: any,
|
||||||
|
fromStep: any,
|
||||||
|
toStep: any,
|
||||||
|
dataId: any,
|
||||||
|
additionalData?: Record<string, any>
|
||||||
|
): Promise<any> {
|
||||||
|
const sourceTable = fromStep.tableName;
|
||||||
|
const targetTable = toStep.targetTable || toStep.tableName;
|
||||||
|
const fieldMappings = toStep.fieldMappings || {};
|
||||||
|
|
||||||
|
// 1. 소스 데이터 조회
|
||||||
|
const selectQuery = `SELECT * FROM ${sourceTable} WHERE id = $1`;
|
||||||
|
const sourceResult = await client.query(selectQuery, [dataId]);
|
||||||
|
|
||||||
|
if (sourceResult.length === 0) {
|
||||||
|
throw new Error(`소스 데이터를 찾을 수 없습니다: ${dataId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceData = sourceResult[0];
|
||||||
|
|
||||||
|
// 2. 필드 매핑 적용
|
||||||
|
const mappedData: Record<string, any> = {};
|
||||||
|
|
||||||
|
// 매핑 정의가 있으면 적용
|
||||||
|
for (const [sourceField, targetField] of Object.entries(fieldMappings)) {
|
||||||
|
if (sourceData[sourceField] !== undefined) {
|
||||||
|
mappedData[targetField as string] = sourceData[sourceField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 추가 데이터 병합
|
||||||
|
if (additionalData) {
|
||||||
|
Object.assign(mappedData, additionalData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 타겟 테이블에 데이터 삽입
|
||||||
|
if (Object.keys(mappedData).length === 0) {
|
||||||
|
throw new Error("매핑할 데이터가 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns = Object.keys(mappedData);
|
||||||
|
const values = Object.values(mappedData);
|
||||||
|
const placeholders = columns.map((_, i) => `$${i + 1}`).join(", ");
|
||||||
|
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO ${targetTable} (${columns.join(", ")})
|
||||||
|
VALUES (${placeholders})
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
|
||||||
|
const insertResult = await client.query(insertQuery, values);
|
||||||
|
return insertResult[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 매핑 테이블 업데이트
|
||||||
|
*/
|
||||||
|
private async updateDataMapping(
|
||||||
|
client: any,
|
||||||
|
flowId: number,
|
||||||
|
currentStepId: number,
|
||||||
|
prevStepId: number,
|
||||||
|
sourceDataId: any,
|
||||||
|
targetDataId: any
|
||||||
|
): Promise<void> {
|
||||||
|
// 기존 매핑 조회
|
||||||
|
const selectQuery = `
|
||||||
|
SELECT id, step_data_map
|
||||||
|
FROM flow_data_mapping
|
||||||
|
WHERE flow_definition_id = $1
|
||||||
|
AND step_data_map->$2 = $3
|
||||||
|
`;
|
||||||
|
const mappingResult = await client.query(selectQuery, [
|
||||||
|
flowId,
|
||||||
|
String(prevStepId),
|
||||||
|
JSON.stringify(String(sourceDataId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const stepDataMap: Record<string, string> =
|
||||||
|
mappingResult.length > 0 ? mappingResult[0].step_data_map : {};
|
||||||
|
|
||||||
|
// 새 단계 데이터 추가
|
||||||
|
stepDataMap[String(currentStepId)] = String(targetDataId);
|
||||||
|
|
||||||
|
if (mappingResult.length > 0) {
|
||||||
|
// 기존 매핑 업데이트
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE flow_data_mapping
|
||||||
|
SET current_step_id = $1,
|
||||||
|
step_data_map = $2,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $3
|
||||||
|
`;
|
||||||
|
await client.query(updateQuery, [
|
||||||
|
currentStepId,
|
||||||
|
JSON.stringify(stepDataMap),
|
||||||
|
mappingResult[0].id,
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
// 새 매핑 생성
|
||||||
|
const insertQuery = `
|
||||||
|
INSERT INTO flow_data_mapping
|
||||||
|
(flow_definition_id, current_step_id, step_data_map)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`;
|
||||||
|
await client.query(insertQuery, [
|
||||||
|
flowId,
|
||||||
|
currentStepId,
|
||||||
|
JSON.stringify(stepDataMap),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 감사 로그 기록
|
||||||
|
*/
|
||||||
|
private async logDataMove(client: any, params: any): Promise<void> {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO flow_audit_log (
|
||||||
|
flow_definition_id, from_step_id, to_step_id,
|
||||||
|
move_type, source_table, target_table,
|
||||||
|
source_data_id, target_data_id,
|
||||||
|
status_from, status_to,
|
||||||
|
changed_by, note
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
||||||
|
`;
|
||||||
|
|
||||||
|
await client.query(query, [
|
||||||
|
params.flowId,
|
||||||
|
params.fromStepId,
|
||||||
|
params.toStepId,
|
||||||
|
params.moveType,
|
||||||
|
params.sourceTable,
|
||||||
|
params.targetTable,
|
||||||
|
params.sourceDataId,
|
||||||
|
params.targetDataId,
|
||||||
|
params.statusFrom,
|
||||||
|
params.statusTo,
|
||||||
|
params.userId,
|
||||||
|
params.note || null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 여러 데이터를 동시에 다음 단계로 이동
|
* 여러 데이터를 동시에 다음 단계로 이동
|
||||||
*/
|
*/
|
||||||
async moveBatchData(
|
async moveBatchData(
|
||||||
flowId: number,
|
flowId: number,
|
||||||
recordIds: string[],
|
fromStepId: number,
|
||||||
toStepId: number,
|
toStepId: number,
|
||||||
userId: string,
|
dataIds: any[],
|
||||||
note?: string
|
userId: string = "system"
|
||||||
): Promise<void> {
|
): Promise<{ success: boolean; results: any[] }> {
|
||||||
for (const recordId of recordIds) {
|
const results = [];
|
||||||
await this.moveDataToStep(flowId, recordId, toStepId, userId, note);
|
|
||||||
|
for (const dataId of dataIds) {
|
||||||
|
try {
|
||||||
|
const result = await this.moveDataToStep(
|
||||||
|
flowId,
|
||||||
|
fromStepId,
|
||||||
|
toStepId,
|
||||||
|
dataId,
|
||||||
|
userId
|
||||||
|
);
|
||||||
|
results.push({ dataId, ...result });
|
||||||
|
} catch (error: any) {
|
||||||
|
results.push({ dataId, success: false, message: error.message });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: results.every((r) => r.success),
|
||||||
|
results,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터의 플로우 이력 조회
|
* 데이터의 플로우 이력 조회
|
||||||
*/
|
*/
|
||||||
async getAuditLogs(
|
async getAuditLogs(flowId: number, dataId: string): Promise<FlowAuditLog[]> {
|
||||||
flowId: number,
|
|
||||||
recordId: string
|
|
||||||
): Promise<FlowAuditLog[]> {
|
|
||||||
const query = `
|
const query = `
|
||||||
SELECT
|
SELECT
|
||||||
fal.*,
|
fal.*,
|
||||||
@@ -121,17 +363,18 @@ export class FlowDataMoveService {
|
|||||||
FROM flow_audit_log fal
|
FROM flow_audit_log fal
|
||||||
LEFT JOIN flow_step fs_from ON fal.from_step_id = fs_from.id
|
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
|
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
|
WHERE fal.flow_definition_id = $1
|
||||||
|
AND (fal.source_data_id = $2 OR fal.target_data_id = $2)
|
||||||
ORDER BY fal.changed_at DESC
|
ORDER BY fal.changed_at DESC
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const result = await db.query(query, [flowId, recordId]);
|
const result = await db.query(query, [flowId, dataId]);
|
||||||
|
|
||||||
return result.map((row) => ({
|
return result.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
flowDefinitionId: row.flow_definition_id,
|
flowDefinitionId: row.flow_definition_id,
|
||||||
tableName: row.table_name,
|
tableName: row.table_name || row.source_table,
|
||||||
recordId: row.record_id,
|
recordId: row.record_id || row.source_data_id,
|
||||||
fromStepId: row.from_step_id,
|
fromStepId: row.from_step_id,
|
||||||
toStepId: row.to_step_id,
|
toStepId: row.to_step_id,
|
||||||
changedBy: row.changed_by,
|
changedBy: row.changed_by,
|
||||||
@@ -139,6 +382,13 @@ export class FlowDataMoveService {
|
|||||||
note: row.note,
|
note: row.note,
|
||||||
fromStepName: row.from_step_name,
|
fromStepName: row.from_step_name,
|
||||||
toStepName: row.to_step_name,
|
toStepName: row.to_step_name,
|
||||||
|
moveType: row.move_type,
|
||||||
|
sourceTable: row.source_table,
|
||||||
|
targetTable: row.target_table,
|
||||||
|
sourceDataId: row.source_data_id,
|
||||||
|
targetDataId: row.target_data_id,
|
||||||
|
statusFrom: row.status_from,
|
||||||
|
statusTo: row.status_to,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,8 +417,8 @@ export class FlowDataMoveService {
|
|||||||
return result.map((row) => ({
|
return result.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
flowDefinitionId: row.flow_definition_id,
|
flowDefinitionId: row.flow_definition_id,
|
||||||
tableName: row.table_name,
|
tableName: row.table_name || row.source_table,
|
||||||
recordId: row.record_id,
|
recordId: row.record_id || row.source_data_id,
|
||||||
fromStepId: row.from_step_id,
|
fromStepId: row.from_step_id,
|
||||||
toStepId: row.to_step_id,
|
toStepId: row.to_step_id,
|
||||||
changedBy: row.changed_by,
|
changedBy: row.changed_by,
|
||||||
@@ -176,6 +426,13 @@ export class FlowDataMoveService {
|
|||||||
note: row.note,
|
note: row.note,
|
||||||
fromStepName: row.from_step_name,
|
fromStepName: row.from_step_name,
|
||||||
toStepName: row.to_step_name,
|
toStepName: row.to_step_name,
|
||||||
|
moveType: row.move_type,
|
||||||
|
sourceTable: row.source_table,
|
||||||
|
targetTable: row.target_table,
|
||||||
|
sourceDataId: row.source_data_id,
|
||||||
|
targetDataId: row.target_data_id,
|
||||||
|
statusFrom: row.status_from,
|
||||||
|
statusTo: row.status_to,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ export class FlowStepService {
|
|||||||
color: row.color,
|
color: row.color,
|
||||||
positionX: row.position_x,
|
positionX: row.position_x,
|
||||||
positionY: row.position_y,
|
positionY: row.position_y,
|
||||||
|
// 하이브리드 플로우 지원 필드
|
||||||
|
moveType: row.move_type || undefined,
|
||||||
|
statusColumn: row.status_column || undefined,
|
||||||
|
statusValue: row.status_value || undefined,
|
||||||
|
targetTable: row.target_table || undefined,
|
||||||
|
fieldMappings: row.field_mappings || undefined,
|
||||||
|
requiredFields: row.required_fields || undefined,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
updatedAt: row.updated_at,
|
updatedAt: row.updated_at,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,13 +31,19 @@ export interface UpdateFlowDefinitionRequest {
|
|||||||
// 조건 연산자
|
// 조건 연산자
|
||||||
export type ConditionOperator =
|
export type ConditionOperator =
|
||||||
| "equals"
|
| "equals"
|
||||||
|
| "="
|
||||||
| "not_equals"
|
| "not_equals"
|
||||||
|
| "!="
|
||||||
| "in"
|
| "in"
|
||||||
| "not_in"
|
| "not_in"
|
||||||
| "greater_than"
|
| "greater_than"
|
||||||
|
| ">"
|
||||||
| "less_than"
|
| "less_than"
|
||||||
|
| "<"
|
||||||
| "greater_than_or_equal"
|
| "greater_than_or_equal"
|
||||||
|
| ">="
|
||||||
| "less_than_or_equal"
|
| "less_than_or_equal"
|
||||||
|
| "<="
|
||||||
| "is_null"
|
| "is_null"
|
||||||
| "is_not_null"
|
| "is_not_null"
|
||||||
| "like"
|
| "like"
|
||||||
@@ -67,6 +73,13 @@ export interface FlowStep {
|
|||||||
color: string;
|
color: string;
|
||||||
positionX: number;
|
positionX: number;
|
||||||
positionY: number;
|
positionY: number;
|
||||||
|
// 하이브리드 플로우 지원 필드
|
||||||
|
moveType?: "status" | "table" | "both"; // 데이터 이동 방식
|
||||||
|
statusColumn?: string; // 상태 컬럼명 (상태 변경 방식)
|
||||||
|
statusValue?: string; // 이 단계의 상태값
|
||||||
|
targetTable?: string; // 타겟 테이블명 (테이블 이동 방식)
|
||||||
|
fieldMappings?: Record<string, string>; // 필드 매핑 정보
|
||||||
|
requiredFields?: string[]; // 필수 입력 필드
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
@@ -81,6 +94,13 @@ export interface CreateFlowStepRequest {
|
|||||||
color?: string;
|
color?: string;
|
||||||
positionX?: number;
|
positionX?: number;
|
||||||
positionY?: number;
|
positionY?: number;
|
||||||
|
// 하이브리드 플로우 지원 필드
|
||||||
|
moveType?: "status" | "table" | "both";
|
||||||
|
statusColumn?: string;
|
||||||
|
statusValue?: string;
|
||||||
|
targetTable?: string;
|
||||||
|
fieldMappings?: Record<string, string>;
|
||||||
|
requiredFields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 플로우 단계 수정 요청
|
// 플로우 단계 수정 요청
|
||||||
@@ -92,6 +112,13 @@ export interface UpdateFlowStepRequest {
|
|||||||
color?: string;
|
color?: string;
|
||||||
positionX?: number;
|
positionX?: number;
|
||||||
positionY?: number;
|
positionY?: number;
|
||||||
|
// 하이브리드 플로우 지원 필드
|
||||||
|
moveType?: "status" | "table" | "both";
|
||||||
|
statusColumn?: string;
|
||||||
|
statusValue?: string;
|
||||||
|
targetTable?: string;
|
||||||
|
fieldMappings?: Record<string, string>;
|
||||||
|
requiredFields?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// 플로우 단계 연결
|
// 플로우 단계 연결
|
||||||
@@ -134,6 +161,14 @@ export interface FlowAuditLog {
|
|||||||
changedBy?: string;
|
changedBy?: string;
|
||||||
changedAt: Date;
|
changedAt: Date;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
// 하이브리드 플로우 지원 필드
|
||||||
|
moveType?: "status" | "table" | "both";
|
||||||
|
sourceTable?: string;
|
||||||
|
targetTable?: string;
|
||||||
|
sourceDataId?: string;
|
||||||
|
targetDataId?: string;
|
||||||
|
statusFrom?: string;
|
||||||
|
statusTo?: string;
|
||||||
// 조인 필드
|
// 조인 필드
|
||||||
fromStepName?: string;
|
fromStepName?: string;
|
||||||
toStepName?: string;
|
toStepName?: string;
|
||||||
|
|||||||
302
docs/FLOW_DATA_STRUCTURE_GUIDE.md
Normal file
302
docs/FLOW_DATA_STRUCTURE_GUIDE.md
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
# 플로우 데이터 구조 설계 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
플로우 관리 시스템에서 각 단계별로 테이블 구조가 다른 경우의 데이터 관리 방법
|
||||||
|
|
||||||
|
## 추천 아키텍처: 하이브리드 접근
|
||||||
|
|
||||||
|
### 1. 메인 데이터 테이블 (상태 기반)
|
||||||
|
|
||||||
|
각 플로우의 핵심 데이터를 담는 메인 테이블에 `flow_status` 컬럼을 추가합니다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 예시: 제품 수명주기 관리
|
||||||
|
CREATE TABLE product_lifecycle (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_code VARCHAR(50) UNIQUE NOT NULL,
|
||||||
|
product_name VARCHAR(200) NOT NULL,
|
||||||
|
flow_status VARCHAR(50) NOT NULL, -- 'purchase', 'installation', 'disposal'
|
||||||
|
|
||||||
|
-- 공통 필드
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
created_by VARCHAR(50),
|
||||||
|
|
||||||
|
-- 단계별 핵심 정보 (NULL 허용)
|
||||||
|
purchase_date DATE,
|
||||||
|
purchase_price DECIMAL(15,2),
|
||||||
|
installation_date DATE,
|
||||||
|
installation_location VARCHAR(200),
|
||||||
|
disposal_date DATE,
|
||||||
|
disposal_method VARCHAR(100),
|
||||||
|
|
||||||
|
-- 인덱스
|
||||||
|
INDEX idx_flow_status (flow_status),
|
||||||
|
INDEX idx_product_code (product_code)
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 단계별 상세 정보 테이블 (선택적)
|
||||||
|
|
||||||
|
각 단계에서 필요한 상세 정보는 별도 테이블에 저장합니다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 구매 단계 상세 정보
|
||||||
|
CREATE TABLE product_purchase_detail (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_id INTEGER REFERENCES product_lifecycle(id),
|
||||||
|
vendor_name VARCHAR(200),
|
||||||
|
vendor_contact VARCHAR(100),
|
||||||
|
purchase_order_no VARCHAR(50),
|
||||||
|
warranty_period INTEGER, -- 월 단위
|
||||||
|
warranty_end_date DATE,
|
||||||
|
specifications JSONB, -- 유연한 사양 정보
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 설치 단계 상세 정보
|
||||||
|
CREATE TABLE product_installation_detail (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_id INTEGER REFERENCES product_lifecycle(id),
|
||||||
|
technician_name VARCHAR(100),
|
||||||
|
installation_address TEXT,
|
||||||
|
installation_notes TEXT,
|
||||||
|
installation_photos JSONB, -- [{url, description}]
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 폐기 단계 상세 정보
|
||||||
|
CREATE TABLE product_disposal_detail (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
product_id INTEGER REFERENCES product_lifecycle(id),
|
||||||
|
disposal_company VARCHAR(200),
|
||||||
|
disposal_certificate_no VARCHAR(100),
|
||||||
|
environmental_compliance BOOLEAN,
|
||||||
|
disposal_cost DECIMAL(15,2),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 플로우 단계 설정 테이블 수정
|
||||||
|
|
||||||
|
`flow_step` 테이블에 단계별 필드 매핑 정보를 추가합니다.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE flow_step
|
||||||
|
ADD COLUMN status_value VARCHAR(50), -- 이 단계의 상태값
|
||||||
|
ADD COLUMN required_fields JSONB, -- 필수 입력 필드 목록
|
||||||
|
ADD COLUMN detail_table_name VARCHAR(200), -- 상세 정보 테이블명 (선택적)
|
||||||
|
ADD COLUMN field_mappings JSONB; -- 메인 테이블과 상세 테이블 필드 매핑
|
||||||
|
|
||||||
|
-- 예시 데이터
|
||||||
|
INSERT INTO flow_step (flow_definition_id, step_name, step_order, table_name, status_value, required_fields, detail_table_name) VALUES
|
||||||
|
(1, '구매', 1, 'product_lifecycle', 'purchase',
|
||||||
|
'["product_code", "product_name", "purchase_date", "purchase_price"]'::jsonb,
|
||||||
|
'product_purchase_detail'),
|
||||||
|
(1, '설치', 2, 'product_lifecycle', 'installation',
|
||||||
|
'["installation_date", "installation_location"]'::jsonb,
|
||||||
|
'product_installation_detail'),
|
||||||
|
(1, '폐기', 3, 'product_lifecycle', 'disposal',
|
||||||
|
'["disposal_date", "disposal_method"]'::jsonb,
|
||||||
|
'product_disposal_detail');
|
||||||
|
```
|
||||||
|
|
||||||
|
## 데이터 이동 로직
|
||||||
|
|
||||||
|
### 백엔드 서비스 수정
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// backend-node/src/services/flowDataMoveService.ts
|
||||||
|
|
||||||
|
export class FlowDataMoveService {
|
||||||
|
/**
|
||||||
|
* 다음 단계로 데이터 이동
|
||||||
|
*/
|
||||||
|
async moveToNextStep(
|
||||||
|
flowId: number,
|
||||||
|
currentStepId: number,
|
||||||
|
nextStepId: number,
|
||||||
|
dataId: any
|
||||||
|
): Promise<boolean> {
|
||||||
|
const client = await db.getClient();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
|
||||||
|
// 1. 현재 단계와 다음 단계 정보 조회
|
||||||
|
const currentStep = await this.getStepInfo(currentStepId);
|
||||||
|
const nextStep = await this.getStepInfo(nextStepId);
|
||||||
|
|
||||||
|
if (!currentStep || !nextStep) {
|
||||||
|
throw new Error("유효하지 않은 단계입니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 메인 테이블의 상태 업데이트
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE ${currentStep.table_name}
|
||||||
|
SET flow_status = $1,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
AND flow_status = $3
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await client.query(updateQuery, [
|
||||||
|
nextStep.status_value,
|
||||||
|
dataId,
|
||||||
|
currentStep.status_value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rowCount === 0) {
|
||||||
|
throw new Error("데이터를 찾을 수 없거나 이미 이동되었습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 감사 로그 기록
|
||||||
|
await this.logDataMove(client, {
|
||||||
|
flowId,
|
||||||
|
fromStepId: currentStepId,
|
||||||
|
toStepId: nextStepId,
|
||||||
|
dataId,
|
||||||
|
tableName: currentStep.table_name,
|
||||||
|
statusFrom: currentStep.status_value,
|
||||||
|
statusTo: nextStep.status_value,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getStepInfo(stepId: number) {
|
||||||
|
const query = `
|
||||||
|
SELECT id, table_name, status_value, detail_table_name, required_fields
|
||||||
|
FROM flow_step
|
||||||
|
WHERE id = $1
|
||||||
|
`;
|
||||||
|
const result = await db.query(query, [stepId]);
|
||||||
|
return result[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async logDataMove(client: any, params: any) {
|
||||||
|
const query = `
|
||||||
|
INSERT INTO flow_audit_log (
|
||||||
|
flow_definition_id, from_step_id, to_step_id,
|
||||||
|
data_id, table_name, status_from, status_to,
|
||||||
|
moved_at, moved_by
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW(), 'system')
|
||||||
|
`;
|
||||||
|
|
||||||
|
await client.query(query, [
|
||||||
|
params.flowId,
|
||||||
|
params.fromStepId,
|
||||||
|
params.toStepId,
|
||||||
|
params.dataId,
|
||||||
|
params.tableName,
|
||||||
|
params.statusFrom,
|
||||||
|
params.statusTo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 플로우 조건 설정
|
||||||
|
|
||||||
|
각 단계의 조건은 `flow_status` 컬럼을 기준으로 설정합니다:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// 구매 단계 조건
|
||||||
|
{
|
||||||
|
"operator": "AND",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"column": "flow_status",
|
||||||
|
"operator": "=",
|
||||||
|
"value": "purchase"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// 설치 단계 조건
|
||||||
|
{
|
||||||
|
"operator": "AND",
|
||||||
|
"conditions": [
|
||||||
|
{
|
||||||
|
"column": "flow_status",
|
||||||
|
"operator": "=",
|
||||||
|
"value": "installation"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 프론트엔드 구현
|
||||||
|
|
||||||
|
### 단계별 폼 렌더링
|
||||||
|
|
||||||
|
각 단계에서 필요한 필드를 동적으로 렌더링합니다.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 단계 정보에서 필수 필드 가져오기
|
||||||
|
const requiredFields = step.required_fields; // ["purchase_date", "purchase_price"]
|
||||||
|
|
||||||
|
// 동적 폼 생성
|
||||||
|
{
|
||||||
|
requiredFields.map((fieldName) => (
|
||||||
|
<FormField
|
||||||
|
key={fieldName}
|
||||||
|
name={fieldName}
|
||||||
|
label={getFieldLabel(fieldName)}
|
||||||
|
type={getFieldType(fieldName)}
|
||||||
|
required={true}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 장점
|
||||||
|
|
||||||
|
1. **단순한 데이터 이동**: 상태값만 업데이트
|
||||||
|
2. **유연한 구조**: 단계별 상세 정보는 별도 테이블
|
||||||
|
3. **완벽한 이력 추적**: 감사 로그로 모든 이동 기록
|
||||||
|
4. **쿼리 효율**: 단일 테이블 조회로 각 단계 데이터 확인
|
||||||
|
5. **확장성**: 새로운 단계 추가 시 컬럼 추가 또는 상세 테이블 생성
|
||||||
|
|
||||||
|
## 마이그레이션 스크립트
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 1. 기존 테이블에 flow_status 컬럼 추가
|
||||||
|
ALTER TABLE product_lifecycle
|
||||||
|
ADD COLUMN flow_status VARCHAR(50) DEFAULT 'purchase';
|
||||||
|
|
||||||
|
-- 2. 인덱스 생성
|
||||||
|
CREATE INDEX idx_product_lifecycle_status ON product_lifecycle(flow_status);
|
||||||
|
|
||||||
|
-- 3. flow_step 테이블 확장
|
||||||
|
ALTER TABLE flow_step
|
||||||
|
ADD COLUMN status_value VARCHAR(50),
|
||||||
|
ADD COLUMN required_fields JSONB,
|
||||||
|
ADD COLUMN detail_table_name VARCHAR(200);
|
||||||
|
|
||||||
|
-- 4. 기존 데이터 마이그레이션
|
||||||
|
UPDATE flow_step
|
||||||
|
SET status_value = CASE step_order
|
||||||
|
WHEN 1 THEN 'purchase'
|
||||||
|
WHEN 2 THEN 'installation'
|
||||||
|
WHEN 3 THEN 'disposal'
|
||||||
|
END
|
||||||
|
WHERE flow_definition_id = 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
이 하이브리드 접근 방식을 사용하면:
|
||||||
|
|
||||||
|
- 각 단계의 데이터는 같은 메인 테이블에서 `flow_status`로 구분
|
||||||
|
- 단계별 추가 정보는 별도 상세 테이블에 저장 (선택적)
|
||||||
|
- 데이터 이동은 상태값 업데이트만으로 간단하게 처리
|
||||||
|
- 완전한 감사 로그와 이력 추적 가능
|
||||||
381
docs/FLOW_HYBRID_MODE_USAGE_GUIDE.md
Normal file
381
docs/FLOW_HYBRID_MODE_USAGE_GUIDE.md
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
# 플로우 하이브리드 모드 사용 가이드
|
||||||
|
|
||||||
|
## 개요
|
||||||
|
|
||||||
|
플로우 관리 시스템은 세 가지 데이터 이동 방식을 지원합니다:
|
||||||
|
|
||||||
|
1. **상태 변경 방식(status)**: 같은 테이블 내에서 상태 컬럼만 업데이트
|
||||||
|
2. **테이블 이동 방식(table)**: 완전히 다른 테이블로 데이터 복사 및 이동
|
||||||
|
3. **하이브리드 방식(both)**: 두 가지 모두 수행
|
||||||
|
|
||||||
|
## 1. 상태 변경 방식 (Status Mode)
|
||||||
|
|
||||||
|
### 사용 시나리오
|
||||||
|
|
||||||
|
- 같은 엔티티가 여러 단계를 거치는 경우
|
||||||
|
- 예: 승인 프로세스 (대기 → 검토 → 승인 → 완료)
|
||||||
|
|
||||||
|
### 설정 방법
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 플로우 정의 생성
|
||||||
|
INSERT INTO flow_definition (name, description, table_name, is_active)
|
||||||
|
VALUES ('문서 승인', '문서 승인 프로세스', 'documents', true);
|
||||||
|
|
||||||
|
-- 단계 생성 (상태 변경 방식)
|
||||||
|
INSERT INTO flow_step (
|
||||||
|
flow_definition_id, step_name, step_order,
|
||||||
|
table_name, move_type, status_column, status_value,
|
||||||
|
condition_json
|
||||||
|
) VALUES
|
||||||
|
(1, '대기', 1, 'documents', 'status', 'approval_status', 'pending',
|
||||||
|
'{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"pending"}]}'::jsonb),
|
||||||
|
|
||||||
|
(1, '검토중', 2, 'documents', 'status', 'approval_status', 'reviewing',
|
||||||
|
'{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"reviewing"}]}'::jsonb),
|
||||||
|
|
||||||
|
(1, '승인됨', 3, 'documents', 'status', 'approval_status', 'approved',
|
||||||
|
'{"operator":"AND","conditions":[{"column":"approval_status","operator":"=","value":"approved"}]}'::jsonb);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테이블 구조
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE documents (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
title VARCHAR(200),
|
||||||
|
content TEXT,
|
||||||
|
approval_status VARCHAR(50) DEFAULT 'pending', -- 상태 컬럼
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 이동
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 프론트엔드에서 호출
|
||||||
|
await moveData(flowId, currentStepId, nextStepId, documentId);
|
||||||
|
|
||||||
|
// 백엔드에서 처리
|
||||||
|
// documents 테이블의 approval_status가 'pending' → 'reviewing'으로 변경됨
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. 테이블 이동 방식 (Table Mode)
|
||||||
|
|
||||||
|
### 사용 시나리오
|
||||||
|
|
||||||
|
- 완전히 다른 엔티티를 다루는 경우
|
||||||
|
- 예: 제품 수명주기 (구매 주문 → 설치 작업 → 폐기 신청)
|
||||||
|
|
||||||
|
### 설정 방법
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 플로우 정의 생성
|
||||||
|
INSERT INTO flow_definition (name, description, table_name, is_active)
|
||||||
|
VALUES ('제품 수명주기', '구매→설치→폐기 프로세스', 'purchase_orders', true);
|
||||||
|
|
||||||
|
-- 단계 생성 (테이블 이동 방식)
|
||||||
|
INSERT INTO flow_step (
|
||||||
|
flow_definition_id, step_name, step_order,
|
||||||
|
table_name, move_type, target_table,
|
||||||
|
field_mappings, required_fields
|
||||||
|
) VALUES
|
||||||
|
(2, '구매', 1, 'purchase_orders', 'table', 'installations',
|
||||||
|
'{"order_id":"purchase_order_id","product_name":"product_name","product_code":"product_code"}'::jsonb,
|
||||||
|
'["product_name","purchase_date","purchase_price"]'::jsonb),
|
||||||
|
|
||||||
|
(2, '설치', 2, 'installations', 'table', 'disposals',
|
||||||
|
'{"installation_id":"installation_id","product_name":"product_name","product_code":"product_code"}'::jsonb,
|
||||||
|
'["installation_date","installation_location","technician"]'::jsonb),
|
||||||
|
|
||||||
|
(2, '폐기', 3, 'disposals', 'table', NULL,
|
||||||
|
NULL,
|
||||||
|
'["disposal_date","disposal_method","disposal_cost"]'::jsonb);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 테이블 구조
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 단계 1: 구매 주문 테이블
|
||||||
|
CREATE TABLE purchase_orders (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
order_id VARCHAR(50) UNIQUE,
|
||||||
|
product_name VARCHAR(200),
|
||||||
|
product_code VARCHAR(50),
|
||||||
|
purchase_date DATE,
|
||||||
|
purchase_price DECIMAL(15,2),
|
||||||
|
vendor_name VARCHAR(200),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 단계 2: 설치 작업 테이블
|
||||||
|
CREATE TABLE installations (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
purchase_order_id VARCHAR(50), -- 매핑 필드
|
||||||
|
product_name VARCHAR(200),
|
||||||
|
product_code VARCHAR(50),
|
||||||
|
installation_date DATE,
|
||||||
|
installation_location TEXT,
|
||||||
|
technician VARCHAR(100),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 단계 3: 폐기 신청 테이블
|
||||||
|
CREATE TABLE disposals (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
installation_id INTEGER, -- 매핑 필드
|
||||||
|
product_name VARCHAR(200),
|
||||||
|
product_code VARCHAR(50),
|
||||||
|
disposal_date DATE,
|
||||||
|
disposal_method VARCHAR(100),
|
||||||
|
disposal_cost DECIMAL(15,2),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 데이터 이동
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 구매 → 설치 단계로 이동
|
||||||
|
const result = await moveData(
|
||||||
|
flowId,
|
||||||
|
purchaseStepId,
|
||||||
|
installationStepId,
|
||||||
|
purchaseOrderId
|
||||||
|
);
|
||||||
|
|
||||||
|
// 결과:
|
||||||
|
// 1. purchase_orders 테이블에서 데이터 조회
|
||||||
|
// 2. field_mappings에 따라 필드 매핑
|
||||||
|
// 3. installations 테이블에 새 레코드 생성
|
||||||
|
// 4. flow_data_mapping 테이블에 매핑 정보 저장
|
||||||
|
// 5. flow_audit_log에 이동 이력 기록
|
||||||
|
```
|
||||||
|
|
||||||
|
### 매핑 정보 조회
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 플로우 전체 이력 조회
|
||||||
|
SELECT * FROM flow_data_mapping
|
||||||
|
WHERE flow_definition_id = 2;
|
||||||
|
|
||||||
|
-- 결과 예시:
|
||||||
|
-- {
|
||||||
|
-- "current_step_id": 2,
|
||||||
|
-- "step_data_map": {
|
||||||
|
-- "1": "123", -- 구매 주문 ID
|
||||||
|
-- "2": "456" -- 설치 작업 ID
|
||||||
|
-- }
|
||||||
|
-- }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 하이브리드 방식 (Both Mode)
|
||||||
|
|
||||||
|
### 사용 시나리오
|
||||||
|
|
||||||
|
- 상태도 변경하면서 다른 테이블로도 이동해야 하는 경우
|
||||||
|
- 예: 검토 완료 후 승인 테이블로 이동하면서 원본 테이블의 상태도 변경
|
||||||
|
|
||||||
|
### 설정 방법
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO flow_step (
|
||||||
|
flow_definition_id, step_name, step_order,
|
||||||
|
table_name, move_type,
|
||||||
|
status_column, status_value, -- 상태 변경용
|
||||||
|
target_table, field_mappings, -- 테이블 이동용
|
||||||
|
required_fields
|
||||||
|
) VALUES
|
||||||
|
(3, '검토 완료', 1, 'review_queue', 'both',
|
||||||
|
'status', 'reviewed',
|
||||||
|
'approved_items',
|
||||||
|
'{"item_id":"source_item_id","item_name":"name","review_score":"score"}'::jsonb,
|
||||||
|
'["review_date","reviewer_id","review_comment"]'::jsonb);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 동작
|
||||||
|
|
||||||
|
1. **상태 변경**: review_queue 테이블의 status를 'reviewed'로 업데이트
|
||||||
|
2. **테이블 이동**: approved_items 테이블에 새 레코드 생성
|
||||||
|
3. **매핑 저장**: flow_data_mapping에 양쪽 ID 기록
|
||||||
|
|
||||||
|
## 4. 프론트엔드 구현
|
||||||
|
|
||||||
|
### FlowWidget에서 데이터 이동
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// frontend/components/screen/widgets/FlowWidget.tsx
|
||||||
|
|
||||||
|
const handleMoveToNext = async () => {
|
||||||
|
// ... 선택된 데이터 준비 ...
|
||||||
|
|
||||||
|
for (const data of selectedData) {
|
||||||
|
// Primary Key 추출 (첫 번째 컬럼 또는 'id' 컬럼)
|
||||||
|
const dataId = data.id || data[stepDataColumns[0]];
|
||||||
|
|
||||||
|
// API 호출
|
||||||
|
const response = await moveData(flowId, currentStepId, nextStepId, dataId);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
toast.error(`이동 실패: ${response.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 성공 시 targetDataId 확인 가능
|
||||||
|
if (response.data?.targetDataId) {
|
||||||
|
console.log(`새 테이블 ID: ${response.data.targetDataId}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 새로고침
|
||||||
|
await refreshStepData();
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 추가 데이터 전달
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 다음 단계로 이동하면서 추가 데이터 입력
|
||||||
|
const additionalData = {
|
||||||
|
installation_date: "2025-10-20",
|
||||||
|
technician: "John Doe",
|
||||||
|
installation_notes: "Installed successfully",
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(`/api/flow/${flowId}/move`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
fromStepId: currentStepId,
|
||||||
|
toStepId: nextStepId,
|
||||||
|
dataId: dataId,
|
||||||
|
additionalData: additionalData,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 감사 로그 조회
|
||||||
|
|
||||||
|
### 특정 데이터의 이력 조회
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const auditLogs = await getFlowAuditLogs(flowId, dataId);
|
||||||
|
|
||||||
|
// 결과:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
moveType: "table",
|
||||||
|
sourceTable: "purchase_orders",
|
||||||
|
targetTable: "installations",
|
||||||
|
sourceDataId: "123",
|
||||||
|
targetDataId: "456",
|
||||||
|
fromStepName: "구매",
|
||||||
|
toStepName: "설치",
|
||||||
|
changedBy: "system",
|
||||||
|
changedAt: "2025-10-20T10:30:00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
moveType: "table",
|
||||||
|
sourceTable: "installations",
|
||||||
|
targetTable: "disposals",
|
||||||
|
sourceDataId: "456",
|
||||||
|
targetDataId: "789",
|
||||||
|
fromStepName: "설치",
|
||||||
|
toStepName: "폐기",
|
||||||
|
changedBy: "user123",
|
||||||
|
changedAt: "2025-10-21T14:20:00",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. 모범 사례
|
||||||
|
|
||||||
|
### 상태 변경 방식 사용 시
|
||||||
|
|
||||||
|
✅ **권장**:
|
||||||
|
|
||||||
|
- 단일 엔티티의 생명주기 관리
|
||||||
|
- 간단한 승인 프로세스
|
||||||
|
- 빠른 상태 조회가 필요한 경우
|
||||||
|
|
||||||
|
❌ **비권장**:
|
||||||
|
|
||||||
|
- 각 단계마다 완전히 다른 데이터 구조가 필요한 경우
|
||||||
|
|
||||||
|
### 테이블 이동 방식 사용 시
|
||||||
|
|
||||||
|
✅ **권장**:
|
||||||
|
|
||||||
|
- 각 단계가 독립적인 엔티티
|
||||||
|
- 단계별로 다른 팀/부서에서 관리
|
||||||
|
- 각 단계의 데이터 구조가 완전히 다른 경우
|
||||||
|
|
||||||
|
❌ **비권장**:
|
||||||
|
|
||||||
|
- 단순한 상태 변경만 필요한 경우 (오버엔지니어링)
|
||||||
|
- 실시간 조회 성능이 중요한 경우 (JOIN 비용)
|
||||||
|
|
||||||
|
### 하이브리드 방식 사용 시
|
||||||
|
|
||||||
|
✅ **권장**:
|
||||||
|
|
||||||
|
- 원본 데이터는 보존하면서 처리된 데이터는 별도 저장
|
||||||
|
- 이중 추적이 필요한 경우
|
||||||
|
|
||||||
|
## 7. 주의사항
|
||||||
|
|
||||||
|
1. **필드 매핑 주의**: `field_mappings`의 소스/타겟 필드가 정확해야 함
|
||||||
|
2. **필수 필드 검증**: `required_fields`에 명시된 필드는 반드시 입력
|
||||||
|
3. **트랜잭션**: 모든 이동은 트랜잭션으로 처리되어 원자성 보장
|
||||||
|
4. **Primary Key**: 테이블 이동 시 소스 데이터의 Primary Key가 명확해야 함
|
||||||
|
5. **순환 참조 방지**: 플로우 연결 시 사이클이 발생하지 않도록 주의
|
||||||
|
|
||||||
|
## 8. 트러블슈팅
|
||||||
|
|
||||||
|
### Q1: "데이터를 찾을 수 없습니다" 오류
|
||||||
|
|
||||||
|
- 원인: Primary Key가 잘못되었거나 데이터가 이미 이동됨
|
||||||
|
- 해결: `flow_audit_log`에서 이동 이력 확인
|
||||||
|
|
||||||
|
### Q2: "매핑할 데이터가 없습니다" 오류
|
||||||
|
|
||||||
|
- 원인: `field_mappings`가 비어있거나 소스 필드가 없음
|
||||||
|
- 해결: 소스 테이블에 매핑 필드가 존재하는지 확인
|
||||||
|
|
||||||
|
### Q3: 테이블 이동 후 원본 데이터 처리
|
||||||
|
|
||||||
|
- 원본 데이터는 자동으로 삭제되지 않음
|
||||||
|
- 필요시 별도 로직으로 처리하거나 `is_archived` 플래그 사용
|
||||||
|
|
||||||
|
## 9. 성능 최적화
|
||||||
|
|
||||||
|
1. **인덱스 생성**: 상태 컬럼에 인덱스 필수
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE INDEX idx_documents_status ON documents(approval_status);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **배치 이동**: 대량 데이터는 배치 API 사용
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await moveBatchData(flowId, fromStepId, toStepId, dataIds);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **매핑 테이블 정리**: 주기적으로 완료된 플로우의 매핑 데이터 아카이빙
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DELETE FROM flow_data_mapping
|
||||||
|
WHERE created_at < NOW() - INTERVAL '1 year'
|
||||||
|
AND current_step_id IN (SELECT id FROM flow_step WHERE step_order = (SELECT MAX(step_order) FROM flow_step WHERE flow_definition_id = ?));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 결론
|
||||||
|
|
||||||
|
하이브리드 플로우 시스템은 다양한 비즈니스 요구사항에 유연하게 대응할 수 있습니다:
|
||||||
|
|
||||||
|
- 간단한 상태 관리부터
|
||||||
|
- 복잡한 다단계 프로세스까지
|
||||||
|
- 하나의 시스템으로 통합 관리 가능
|
||||||
@@ -148,94 +148,103 @@ export default function FlowManagementPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto space-y-6 p-6">
|
<div className="container mx-auto space-y-4 p-3 sm:space-y-6 sm:p-4 lg:p-6">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex flex-col items-start justify-between gap-4 sm:flex-row sm:items-center">
|
||||||
<div>
|
<div className="flex-1">
|
||||||
<h1 className="flex items-center gap-2 text-3xl font-bold">
|
<h1 className="flex items-center gap-2 text-xl font-bold sm:text-2xl lg:text-3xl">
|
||||||
<Workflow className="h-8 w-8" />
|
<Workflow className="h-6 w-6 sm:h-7 sm:w-7 lg:h-8 lg:w-8" />
|
||||||
플로우 관리
|
플로우 관리
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-muted-foreground mt-1">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">업무 프로세스 플로우를 생성하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
|
||||||
<Plus className="mr-2 h-4 w-4" />새 플로우 생성
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">새 플로우 생성</span>
|
||||||
|
<span className="sm:hidden">생성</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 카드 목록 */}
|
{/* 플로우 카드 목록 */}
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="py-12 text-center">
|
<div className="py-8 text-center sm:py-12">
|
||||||
<p className="text-muted-foreground">로딩 중...</p>
|
<p className="text-muted-foreground text-sm sm:text-base">로딩 중...</p>
|
||||||
</div>
|
</div>
|
||||||
) : flows.length === 0 ? (
|
) : flows.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="py-12 text-center">
|
<CardContent className="py-8 text-center sm:py-12">
|
||||||
<Workflow className="text-muted-foreground mx-auto mb-4 h-12 w-12" />
|
<Workflow className="text-muted-foreground mx-auto mb-3 h-10 w-10 sm:mb-4 sm:h-12 sm:w-12" />
|
||||||
<p className="text-muted-foreground mb-4">생성된 플로우가 없습니다</p>
|
<p className="text-muted-foreground mb-3 text-sm sm:mb-4 sm:text-base">생성된 플로우가 없습니다</p>
|
||||||
<Button onClick={() => setIsCreateDialogOpen(true)}>
|
<Button onClick={() => setIsCreateDialogOpen(true)} className="w-full sm:w-auto">
|
||||||
<Plus className="mr-2 h-4 w-4" />첫 플로우 만들기
|
<Plus className="mr-2 h-4 w-4" />첫 플로우 만들기
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-4 sm:gap-5 md:grid-cols-2 lg:gap-6 xl:grid-cols-3">
|
||||||
{flows.map((flow) => (
|
{flows.map((flow) => (
|
||||||
<Card
|
<Card
|
||||||
key={flow.id}
|
key={flow.id}
|
||||||
className="cursor-pointer transition-shadow hover:shadow-lg"
|
className="cursor-pointer transition-shadow hover:shadow-lg"
|
||||||
onClick={() => handleEdit(flow.id)}
|
onClick={() => handleEdit(flow.id)}
|
||||||
>
|
>
|
||||||
<CardHeader>
|
<CardHeader className="p-4 sm:p-6">
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div>
|
<div className="min-w-0 flex-1">
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex flex-col gap-1 text-base sm:flex-row sm:items-center sm:gap-2 sm:text-lg">
|
||||||
{flow.name}
|
<span className="truncate">{flow.name}</span>
|
||||||
{flow.isActive && <Badge variant="success">활성</Badge>}
|
{flow.isActive && (
|
||||||
|
<Badge variant="success" className="self-start">
|
||||||
|
활성
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="mt-2">{flow.description || "설명 없음"}</CardDescription>
|
<CardDescription className="mt-1 line-clamp-2 text-xs sm:mt-2 sm:text-sm">
|
||||||
|
{flow.description || "설명 없음"}
|
||||||
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="p-4 pt-0 sm:p-6">
|
||||||
<div className="space-y-2 text-sm">
|
<div className="space-y-1.5 text-xs sm:space-y-2 sm:text-sm">
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
|
||||||
<Table className="h-4 w-4" />
|
<Table className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
||||||
<span>{flow.tableName}</span>
|
<span className="truncate">{flow.tableName}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
|
||||||
<User className="h-4 w-4" />
|
<User className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
||||||
<span>생성자: {flow.createdBy}</span>
|
<span className="truncate">생성자: {flow.createdBy}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-muted-foreground flex items-center gap-2">
|
<div className="text-muted-foreground flex items-center gap-1.5 sm:gap-2">
|
||||||
<Calendar className="h-4 w-4" />
|
<Calendar className="h-3.5 w-3.5 shrink-0 sm:h-4 sm:w-4" />
|
||||||
<span>{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}</span>
|
<span>{new Date(flow.updatedAt).toLocaleDateString("ko-KR")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-4 flex gap-2">
|
<div className="mt-3 flex gap-2 sm:mt-4">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="flex-1"
|
className="h-8 flex-1 text-xs sm:h-9 sm:text-sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleEdit(flow.id);
|
handleEdit(flow.id);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Edit2 className="mr-2 h-4 w-4" />
|
<Edit2 className="mr-1 h-3 w-3 sm:mr-2 sm:h-4 sm:w-4" />
|
||||||
편집
|
편집
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="h-8 px-2 text-xs sm:h-9 sm:px-3 sm:text-sm"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedFlow(flow);
|
setSelectedFlow(flow);
|
||||||
setIsDeleteDialogOpen(true);
|
setIsDeleteDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-3 w-3 sm:h-4 sm:w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -246,77 +255,101 @@ export default function FlowManagementPage() {
|
|||||||
|
|
||||||
{/* 생성 다이얼로그 */}
|
{/* 생성 다이얼로그 */}
|
||||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>새 플로우 생성</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">새 플로우 생성</DialogTitle>
|
||||||
<DialogDescription>새로운 업무 프로세스 플로우를 생성합니다</DialogDescription>
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
새로운 업무 프로세스 플로우를 생성합니다
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-3 sm:space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="name">플로우 이름 *</Label>
|
<Label htmlFor="name" className="text-xs sm:text-sm">
|
||||||
|
플로우 이름 *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="name"
|
id="name"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
placeholder="예: 제품 수명주기 관리"
|
placeholder="예: 제품 수명주기 관리"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="tableName">연결 테이블 *</Label>
|
<Label htmlFor="tableName" className="text-xs sm:text-sm">
|
||||||
|
연결 테이블 *
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
id="tableName"
|
id="tableName"
|
||||||
value={formData.tableName}
|
value={formData.tableName}
|
||||||
onChange={(e) => setFormData({ ...formData, tableName: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, tableName: e.target.value })}
|
||||||
placeholder="예: products"
|
placeholder="예: products"
|
||||||
|
className="h-8 text-xs sm:h-10 sm:text-sm"
|
||||||
/>
|
/>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">플로우가 관리할 데이터 테이블 이름을 입력하세요</p>
|
<p className="text-muted-foreground mt-1 text-[10px] sm:text-xs">
|
||||||
|
플로우가 관리할 데이터 테이블 이름을 입력하세요
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="description">설명</Label>
|
<Label htmlFor="description" className="text-xs sm:text-sm">
|
||||||
|
설명
|
||||||
|
</Label>
|
||||||
<Textarea
|
<Textarea
|
||||||
id="description"
|
id="description"
|
||||||
value={formData.description}
|
value={formData.description}
|
||||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
placeholder="플로우에 대한 설명을 입력하세요"
|
placeholder="플로우에 대한 설명을 입력하세요"
|
||||||
rows={3}
|
rows={3}
|
||||||
|
className="text-xs sm:text-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsCreateDialogOpen(false)}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleCreate}>생성</Button>
|
<Button onClick={handleCreate} className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm">
|
||||||
|
생성
|
||||||
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
||||||
<DialogContent>
|
<DialogContent className="max-w-[95vw] sm:max-w-[500px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>플로우 삭제</DialogTitle>
|
<DialogTitle className="text-base sm:text-lg">플로우 삭제</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
정말로 "{selectedFlow?.name}" 플로우를 삭제하시겠습니까?
|
정말로 "{selectedFlow?.name}" 플로우를 삭제하시겠습니까?
|
||||||
<br />이 작업은 되돌릴 수 없습니다.
|
<br />이 작업은 되돌릴 수 없습니다.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter className="gap-2 sm:gap-0">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDeleteDialogOpen(false);
|
setIsDeleteDialogOpen(false);
|
||||||
setSelectedFlow(null);
|
setSelectedFlow(null);
|
||||||
}}
|
}}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={handleDelete}>
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="h-8 flex-1 text-xs sm:h-10 sm:flex-none sm:text-sm"
|
||||||
|
>
|
||||||
삭제
|
삭제
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
73
frontend/app/(main)/test-flow/page.tsx
Normal file
73
frontend/app/(main)/test-flow/page.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
import { FlowWidget } from "@/components/screen/widgets/FlowWidget";
|
||||||
|
import { FlowComponent } from "@/types/screen-management";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플로우 위젯 테스트 페이지
|
||||||
|
* 문서 승인 플로우 (ID: 8)를 테스트합니다
|
||||||
|
*/
|
||||||
|
export default function TestFlowPage() {
|
||||||
|
// 문서 승인 플로우
|
||||||
|
const documentFlow: FlowComponent = {
|
||||||
|
id: "test-flow-1",
|
||||||
|
type: "flow",
|
||||||
|
flowId: 8, // 문서 승인 플로우
|
||||||
|
flowName: "문서 승인 플로우",
|
||||||
|
showStepCount: true,
|
||||||
|
allowDataMove: true,
|
||||||
|
displayMode: "horizontal",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 1200, height: 600 },
|
||||||
|
style: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 작업 요청 워크플로우
|
||||||
|
const workRequestFlow: FlowComponent = {
|
||||||
|
id: "test-flow-2",
|
||||||
|
type: "flow",
|
||||||
|
flowId: 12, // 작업 요청 워크플로우
|
||||||
|
flowName: "작업 요청 워크플로우",
|
||||||
|
showStepCount: true,
|
||||||
|
allowDataMove: true,
|
||||||
|
displayMode: "horizontal",
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
size: { width: 1200, height: 600 },
|
||||||
|
style: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 p-8">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-8">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900">플로우 위젯 테스트</h1>
|
||||||
|
<p className="mt-2 text-gray-600">두 가지 플로우를 테스트할 수 있습니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 문서 승인 플로우 */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-lg">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-gray-800">문서 승인 플로우 (4단계)</h2>
|
||||||
|
<FlowWidget component={documentFlow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작업 요청 워크플로우 */}
|
||||||
|
<div className="rounded-lg bg-white p-6 shadow-lg">
|
||||||
|
<h2 className="mb-4 text-xl font-semibold text-gray-800">작업 요청 워크플로우 (6단계)</h2>
|
||||||
|
<FlowWidget component={workRequestFlow} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 사용 안내 */}
|
||||||
|
<div className="mt-8 rounded-lg border border-blue-200 bg-blue-50 p-6">
|
||||||
|
<h3 className="mb-2 text-lg font-semibold text-blue-900">사용 방법</h3>
|
||||||
|
<ul className="list-inside list-disc space-y-1 text-blue-800">
|
||||||
|
<li>각 플로우 단계를 클릭하면 해당 단계의 데이터 목록이 표시됩니다</li>
|
||||||
|
<li>데이터 행을 체크하고 "다음 단계로 이동" 버튼을 클릭하면 데이터가 이동됩니다</li>
|
||||||
|
<li>이동 후 자동으로 데이터 목록이 새로고침됩니다</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -41,6 +41,17 @@ export function FlowConditionBuilder({ flowId, tableName, condition, onChange }:
|
|||||||
const [conditionType, setConditionType] = useState<"AND" | "OR">(condition?.type || "AND");
|
const [conditionType, setConditionType] = useState<"AND" | "OR">(condition?.type || "AND");
|
||||||
const [conditions, setConditions] = useState<FlowCondition[]>(condition?.conditions || []);
|
const [conditions, setConditions] = useState<FlowCondition[]>(condition?.conditions || []);
|
||||||
|
|
||||||
|
// condition prop이 변경될 때 상태 동기화
|
||||||
|
useEffect(() => {
|
||||||
|
if (condition) {
|
||||||
|
setConditionType(condition.type || "AND");
|
||||||
|
setConditions(condition.conditions || []);
|
||||||
|
} else {
|
||||||
|
setConditionType("AND");
|
||||||
|
setConditions([]);
|
||||||
|
}
|
||||||
|
}, [condition]);
|
||||||
|
|
||||||
// 테이블 컬럼 로드
|
// 테이블 컬럼 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!tableName) {
|
if (!tableName) {
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow";
|
import { updateFlowStep, deleteFlowStep } from "@/lib/api/flow";
|
||||||
import { FlowStep } from "@/types/flow";
|
import { FlowStep } from "@/types/flow";
|
||||||
import { FlowConditionBuilder } from "./FlowConditionBuilder";
|
import { FlowConditionBuilder } from "./FlowConditionBuilder";
|
||||||
import { tableManagementApi } from "@/lib/api/tableManagement";
|
import { tableManagementApi, getTableColumns } from "@/lib/api/tableManagement";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface FlowStepPanelProps {
|
interface FlowStepPanelProps {
|
||||||
@@ -32,12 +33,23 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
|
|||||||
stepName: step.stepName,
|
stepName: step.stepName,
|
||||||
tableName: step.tableName || "",
|
tableName: step.tableName || "",
|
||||||
conditionJson: step.conditionJson,
|
conditionJson: step.conditionJson,
|
||||||
|
// 하이브리드 모드 필드
|
||||||
|
moveType: step.moveType || "status",
|
||||||
|
statusColumn: step.statusColumn || "",
|
||||||
|
statusValue: step.statusValue || "",
|
||||||
|
targetTable: step.targetTable || "",
|
||||||
|
fieldMappings: step.fieldMappings || {},
|
||||||
});
|
});
|
||||||
|
|
||||||
const [tableList, setTableList] = useState<any[]>([]);
|
const [tableList, setTableList] = useState<any[]>([]);
|
||||||
const [loadingTables, setLoadingTables] = useState(true);
|
const [loadingTables, setLoadingTables] = useState(true);
|
||||||
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
const [openTableCombobox, setOpenTableCombobox] = useState(false);
|
||||||
|
|
||||||
|
// 컬럼 목록 (상태 컬럼 선택용)
|
||||||
|
const [columns, setColumns] = useState<any[]>([]);
|
||||||
|
const [loadingColumns, setLoadingColumns] = useState(false);
|
||||||
|
const [openStatusColumnCombobox, setOpenStatusColumnCombobox] = useState(false);
|
||||||
|
|
||||||
// 테이블 목록 조회
|
// 테이블 목록 조회
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadTables = async () => {
|
const loadTables = async () => {
|
||||||
@@ -61,9 +73,47 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
|
|||||||
stepName: step.stepName,
|
stepName: step.stepName,
|
||||||
tableName: step.tableName || "",
|
tableName: step.tableName || "",
|
||||||
conditionJson: step.conditionJson,
|
conditionJson: step.conditionJson,
|
||||||
|
// 하이브리드 모드 필드
|
||||||
|
moveType: step.moveType || "status",
|
||||||
|
statusColumn: step.statusColumn || "",
|
||||||
|
statusValue: step.statusValue || "",
|
||||||
|
targetTable: step.targetTable || "",
|
||||||
|
fieldMappings: step.fieldMappings || {},
|
||||||
});
|
});
|
||||||
}, [step]);
|
}, [step]);
|
||||||
|
|
||||||
|
// 테이블 선택 시 컬럼 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadColumns = async () => {
|
||||||
|
if (!formData.tableName) {
|
||||||
|
setColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setLoadingColumns(true);
|
||||||
|
console.log("🔍 Loading columns for status column selector:", formData.tableName);
|
||||||
|
const response = await getTableColumns(formData.tableName);
|
||||||
|
console.log("📦 Columns response:", response);
|
||||||
|
|
||||||
|
if (response.success && response.data && response.data.columns) {
|
||||||
|
console.log("✅ Setting columns:", response.data.columns);
|
||||||
|
setColumns(response.data.columns);
|
||||||
|
} else {
|
||||||
|
console.log("❌ No columns in response");
|
||||||
|
setColumns([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load columns:", error);
|
||||||
|
setColumns([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingColumns(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadColumns();
|
||||||
|
}, [formData.tableName]);
|
||||||
|
|
||||||
// 저장
|
// 저장
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -230,6 +280,149 @@ export function FlowStepPanel({ step, flowId, onClose, onUpdate }: FlowStepPanel
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 데이터 이동 설정 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>데이터 이동 설정</CardTitle>
|
||||||
|
<CardDescription>다음 단계로 데이터를 이동할 때의 동작을 설정합니다</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* 이동 방식 선택 */}
|
||||||
|
<div>
|
||||||
|
<Label>이동 방식</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.moveType}
|
||||||
|
onValueChange={(value: "status" | "table" | "both") => setFormData({ ...formData, moveType: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="status">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">상태 변경</div>
|
||||||
|
<div className="text-xs text-gray-500">같은 테이블 내에서 상태 컬럼만 업데이트</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="table">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">테이블 이동</div>
|
||||||
|
<div className="text-xs text-gray-500">다른 테이블로 데이터 복사</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="both">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">하이브리드</div>
|
||||||
|
<div className="text-xs text-gray-500">상태 변경 + 테이블 이동</div>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 변경 설정 (status 또는 both일 때) */}
|
||||||
|
{(formData.moveType === "status" || formData.moveType === "both") && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label>상태 컬럼명</Label>
|
||||||
|
<Popover open={openStatusColumnCombobox} onOpenChange={setOpenStatusColumnCombobox}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={openStatusColumnCombobox}
|
||||||
|
className="w-full justify-between"
|
||||||
|
disabled={!formData.tableName || loadingColumns}
|
||||||
|
>
|
||||||
|
{loadingColumns
|
||||||
|
? "컬럼 로딩 중..."
|
||||||
|
: formData.statusColumn
|
||||||
|
? columns.find((col) => col.columnName === formData.statusColumn)?.columnName ||
|
||||||
|
formData.statusColumn
|
||||||
|
: "상태 컬럼 선택"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-full p-0">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty>컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{columns.map((column) => (
|
||||||
|
<CommandItem
|
||||||
|
key={column.columnName}
|
||||||
|
value={column.columnName}
|
||||||
|
onSelect={() => {
|
||||||
|
setFormData({ ...formData, statusColumn: column.columnName });
|
||||||
|
setOpenStatusColumnCombobox(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
formData.statusColumn === column.columnName ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<div>{column.columnName}</div>
|
||||||
|
<div className="text-xs text-gray-500">({column.dataType})</div>
|
||||||
|
</div>
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">업데이트할 컬럼의 이름</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>이 단계의 상태값</Label>
|
||||||
|
<Input
|
||||||
|
value={formData.statusValue}
|
||||||
|
onChange={(e) => setFormData({ ...formData, statusValue: e.target.value })}
|
||||||
|
placeholder="예: approved"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">이 단계에 있을 때의 상태값</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 이동 설정 (table 또는 both일 때) */}
|
||||||
|
{(formData.moveType === "table" || formData.moveType === "both") && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label>타겟 테이블</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.targetTable}
|
||||||
|
onValueChange={(value) => setFormData({ ...formData, targetTable: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="테이블 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tableList.map((table) => (
|
||||||
|
<SelectItem key={table.tableName} value={table.tableName}>
|
||||||
|
{table.displayName || table.tableName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-gray-500">다음 단계로 이동 시 데이터가 저장될 테이블</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-blue-50 p-3">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
💡 필드 매핑은 향후 구현 예정입니다. 현재는 같은 이름의 컬럼만 자동 매핑됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* 액션 버튼 */}
|
{/* 액션 버튼 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button className="flex-1" onClick={handleSave}>
|
<Button className="flex-1" onClick={handleSave}>
|
||||||
|
|||||||
@@ -4,10 +4,29 @@ import React, { useEffect, useState } from "react";
|
|||||||
import { FlowComponent } from "@/types/screen-management";
|
import { FlowComponent } from "@/types/screen-management";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { AlertCircle, Loader2 } from "lucide-react";
|
import { AlertCircle, Loader2, ChevronDown, ChevronUp, History } from "lucide-react";
|
||||||
import { getFlowById, getAllStepCounts } from "@/lib/api/flow";
|
import { getFlowById, getAllStepCounts, getStepDataList, moveBatchData, getFlowAuditLogs } from "@/lib/api/flow";
|
||||||
import type { FlowDefinition, FlowStep } from "@/types/flow";
|
import type { FlowDefinition, FlowStep, FlowAuditLog } from "@/types/flow";
|
||||||
import { FlowDataListModal } from "@/components/flow/FlowDataListModal";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
PaginationPrevious,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
|
||||||
interface FlowWidgetProps {
|
interface FlowWidgetProps {
|
||||||
component: FlowComponent;
|
component: FlowComponent;
|
||||||
@@ -20,10 +39,23 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
|||||||
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
|
const [stepCounts, setStepCounts] = useState<Record<number, number>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [connections, setConnections] = useState<any[]>([]); // 플로우 연결 정보
|
||||||
|
|
||||||
// 모달 상태
|
// 선택된 스텝의 데이터 리스트 상태
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [selectedStepId, setSelectedStepId] = useState<number | null>(null);
|
||||||
const [selectedStep, setSelectedStep] = useState<{ id: number; name: string } | null>(null);
|
const [stepData, setStepData] = useState<any[]>([]);
|
||||||
|
const [stepDataColumns, setStepDataColumns] = useState<string[]>([]);
|
||||||
|
const [stepDataLoading, setStepDataLoading] = useState(false);
|
||||||
|
const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());
|
||||||
|
const [movingData, setMovingData] = useState(false);
|
||||||
|
const [selectedNextStepId, setSelectedNextStepId] = useState<number | null>(null); // 선택된 다음 단계
|
||||||
|
|
||||||
|
// 오딧 로그 상태
|
||||||
|
const [auditLogs, setAuditLogs] = useState<FlowAuditLog[]>([]);
|
||||||
|
const [auditLogsLoading, setAuditLogsLoading] = useState(false);
|
||||||
|
const [showAuditLogs, setShowAuditLogs] = useState(false);
|
||||||
|
const [auditPage, setAuditPage] = useState(1);
|
||||||
|
const [auditPageSize] = useState(10);
|
||||||
|
|
||||||
// componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨)
|
// componentConfig에서 플로우 설정 추출 (DynamicComponentRenderer에서 전달됨)
|
||||||
const config = (component as any).componentConfig || (component as any).config || {};
|
const config = (component as any).componentConfig || (component as any).config || {};
|
||||||
@@ -78,6 +110,15 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
|||||||
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
|
const sortedSteps = stepsData.data.sort((a: FlowStep, b: FlowStep) => a.stepOrder - b.stepOrder);
|
||||||
setSteps(sortedSteps);
|
setSteps(sortedSteps);
|
||||||
|
|
||||||
|
// 연결 정보 조회
|
||||||
|
const connectionsResponse = await fetch(`/api/flow/connections/${flowId}`);
|
||||||
|
if (connectionsResponse.ok) {
|
||||||
|
const connectionsData = await connectionsResponse.json();
|
||||||
|
if (connectionsData.success && connectionsData.data) {
|
||||||
|
setConnections(connectionsData.data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 스텝별 데이터 건수 조회
|
// 스텝별 데이터 건수 조회
|
||||||
if (showStepCount) {
|
if (showStepCount) {
|
||||||
const countsResponse = await getAllStepCounts(flowId!);
|
const countsResponse = await getAllStepCounts(flowId!);
|
||||||
@@ -103,36 +144,176 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
|||||||
}, [flowId, showStepCount]);
|
}, [flowId, showStepCount]);
|
||||||
|
|
||||||
// 스텝 클릭 핸들러
|
// 스텝 클릭 핸들러
|
||||||
const handleStepClick = (stepId: number, stepName: string) => {
|
const handleStepClick = async (stepId: number, stepName: string) => {
|
||||||
if (onStepClick) {
|
if (onStepClick) {
|
||||||
onStepClick(stepId, stepName);
|
onStepClick(stepId, stepName);
|
||||||
} else {
|
return;
|
||||||
// 기본 동작: 모달 열기
|
}
|
||||||
setSelectedStep({ id: stepId, name: stepName });
|
|
||||||
setModalOpen(true);
|
// 같은 스텝을 다시 클릭하면 접기
|
||||||
|
if (selectedStepId === stepId) {
|
||||||
|
setSelectedStepId(null);
|
||||||
|
setStepData([]);
|
||||||
|
setStepDataColumns([]);
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 새로운 스텝 선택 - 데이터 로드
|
||||||
|
setSelectedStepId(stepId);
|
||||||
|
setStepDataLoading(true);
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getStepDataList(flowId!, stepId, 1, 100);
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || "데이터를 불러올 수 없습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = response.data?.records || [];
|
||||||
|
setStepData(rows);
|
||||||
|
|
||||||
|
// 컬럼 추출
|
||||||
|
if (rows.length > 0) {
|
||||||
|
setStepDataColumns(Object.keys(rows[0]));
|
||||||
|
} else {
|
||||||
|
setStepDataColumns([]);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to load step data:", err);
|
||||||
|
toast.error(err.message || "데이터를 불러오는데 실패했습니다");
|
||||||
|
} finally {
|
||||||
|
setStepDataLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 데이터 이동 후 리프레시
|
// 체크박스 토글
|
||||||
const handleDataMoved = async () => {
|
const toggleRowSelection = (rowIndex: number) => {
|
||||||
if (!flowId) return;
|
const newSelected = new Set(selectedRows);
|
||||||
|
if (newSelected.has(rowIndex)) {
|
||||||
|
newSelected.delete(rowIndex);
|
||||||
|
} else {
|
||||||
|
newSelected.add(rowIndex);
|
||||||
|
}
|
||||||
|
setSelectedRows(newSelected);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 전체 선택/해제
|
||||||
|
const toggleAllRows = () => {
|
||||||
|
if (selectedRows.size === stepData.length) {
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
} else {
|
||||||
|
setSelectedRows(new Set(stepData.map((_, index) => index)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 현재 단계에서 가능한 다음 단계들 찾기
|
||||||
|
const getNextSteps = (currentStepId: number) => {
|
||||||
|
return connections
|
||||||
|
.filter((conn) => conn.fromStepId === currentStepId)
|
||||||
|
.map((conn) => steps.find((s) => s.id === conn.toStepId))
|
||||||
|
.filter((step) => step !== undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다음 단계로 이동
|
||||||
|
const handleMoveToNext = async (targetStepId?: number) => {
|
||||||
|
if (!flowId || !selectedStepId || selectedRows.size === 0) return;
|
||||||
|
|
||||||
|
// 다음 단계 결정
|
||||||
|
let nextStepId = targetStepId || selectedNextStepId;
|
||||||
|
|
||||||
|
if (!nextStepId) {
|
||||||
|
const nextSteps = getNextSteps(selectedStepId);
|
||||||
|
if (nextSteps.length === 0) {
|
||||||
|
toast.error("다음 단계가 없습니다");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextSteps.length === 1) {
|
||||||
|
nextStepId = nextSteps[0].id;
|
||||||
|
} else {
|
||||||
|
toast.error("다음 단계를 선택해주세요");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedData = Array.from(selectedRows).map((index) => stepData[index]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 스텝별 데이터 건수 다시 조회
|
setMovingData(true);
|
||||||
|
|
||||||
|
// Primary Key 컬럼 추출 (첫 번째 컬럼 가정)
|
||||||
|
const primaryKeyColumn = stepDataColumns[0];
|
||||||
|
const dataIds = selectedData.map((data) => String(data[primaryKeyColumn]));
|
||||||
|
|
||||||
|
// 배치 이동 API 호출
|
||||||
|
const response = await moveBatchData({
|
||||||
|
flowId,
|
||||||
|
fromStepId: selectedStepId,
|
||||||
|
toStepId: nextStepId,
|
||||||
|
dataIds,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.success) {
|
||||||
|
throw new Error(response.message || "데이터 이동에 실패했습니다");
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStepName = steps.find((s) => s.id === nextStepId)?.stepName;
|
||||||
|
toast.success(`${selectedRows.size}건의 데이터를 "${nextStepName}"(으)로 이동했습니다`);
|
||||||
|
|
||||||
|
// 선택 초기화
|
||||||
|
setSelectedNextStepId(null);
|
||||||
|
setSelectedRows(new Set());
|
||||||
|
|
||||||
|
// 데이터 새로고침
|
||||||
|
await handleStepClick(selectedStepId, steps.find((s) => s.id === selectedStepId)?.stepName || "");
|
||||||
|
|
||||||
|
// 건수 새로고침
|
||||||
const countsResponse = await getAllStepCounts(flowId);
|
const countsResponse = await getAllStepCounts(flowId);
|
||||||
if (countsResponse.success && countsResponse.data) {
|
if (countsResponse.success && countsResponse.data) {
|
||||||
// 배열을 Record<number, number>로 변환
|
|
||||||
const countsMap: Record<number, number> = {};
|
const countsMap: Record<number, number> = {};
|
||||||
countsResponse.data.forEach((item: any) => {
|
countsResponse.data.forEach((item: any) => {
|
||||||
countsMap[item.stepId] = item.count;
|
countsMap[item.stepId] = item.count;
|
||||||
});
|
});
|
||||||
setStepCounts(countsMap);
|
setStepCounts(countsMap);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error("Failed to refresh step counts:", err);
|
console.error("Failed to move data:", err);
|
||||||
|
toast.error(err.message || "데이터 이동 중 오류가 발생했습니다");
|
||||||
|
} finally {
|
||||||
|
setMovingData(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 오딧 로그 로드
|
||||||
|
const loadAuditLogs = async () => {
|
||||||
|
if (!flowId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setAuditLogsLoading(true);
|
||||||
|
const response = await getFlowAuditLogs(flowId, 100); // 최근 100개
|
||||||
|
if (response.success && response.data) {
|
||||||
|
setAuditLogs(response.data);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to load audit logs:", err);
|
||||||
|
toast.error("이력 조회 중 오류가 발생했습니다");
|
||||||
|
} finally {
|
||||||
|
setAuditLogsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 오딧 로그 모달 열기
|
||||||
|
const handleOpenAuditLogs = () => {
|
||||||
|
setShowAuditLogs(true);
|
||||||
|
setAuditPage(1); // 페이지 초기화
|
||||||
|
loadAuditLogs();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 페이지네이션된 오딧 로그
|
||||||
|
const paginatedAuditLogs = auditLogs.slice((auditPage - 1) * auditPageSize, auditPage * auditPageSize);
|
||||||
|
const totalAuditPages = Math.ceil(auditLogs.length / auditPageSize);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center p-8">
|
<div className="flex items-center justify-center p-8">
|
||||||
@@ -167,17 +348,189 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 반응형 컨테이너 클래스
|
||||||
const containerClass =
|
const containerClass =
|
||||||
displayMode === "horizontal"
|
displayMode === "horizontal"
|
||||||
? "flex flex-wrap items-center justify-center gap-3"
|
? "flex flex-col sm:flex-row sm:flex-wrap items-center justify-center gap-3 sm:gap-4"
|
||||||
: "flex flex-col items-center gap-4";
|
: "flex flex-col items-center gap-4";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full w-full p-4">
|
<div className="@container min-h-full w-full p-2 sm:p-4 lg:p-6">
|
||||||
{/* 플로우 제목 */}
|
{/* 플로우 제목 */}
|
||||||
<div className="mb-4 text-center">
|
<div className="mb-3 sm:mb-4">
|
||||||
<h3 className="text-foreground text-lg font-semibold">{flowData.name}</h3>
|
<div className="flex items-center justify-center gap-2">
|
||||||
{flowData.description && <p className="text-muted-foreground mt-1 text-sm">{flowData.description}</p>}
|
<h3 className="text-foreground text-base font-semibold sm:text-lg lg:text-xl">{flowData.name}</h3>
|
||||||
|
|
||||||
|
{/* 오딧 로그 버튼 */}
|
||||||
|
<Dialog open={showAuditLogs} onOpenChange={setShowAuditLogs}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" onClick={handleOpenAuditLogs} className="gap-1.5">
|
||||||
|
<History className="h-4 w-4" />
|
||||||
|
<span className="hidden sm:inline">변경 이력</span>
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-h-[85vh] max-w-[95vw] sm:max-w-[1000px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>플로우 변경 이력</DialogTitle>
|
||||||
|
<DialogDescription>데이터 이동 및 상태 변경 기록 (총 {auditLogs.length}건)</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{auditLogsLoading ? (
|
||||||
|
<div className="flex items-center justify-center p-8">
|
||||||
|
<Loader2 className="text-muted-foreground h-6 w-6 animate-spin" />
|
||||||
|
<span className="text-muted-foreground ml-2 text-sm">이력 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : auditLogs.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-sm">변경 이력이 없습니다</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* 테이블 */}
|
||||||
|
<div className="bg-card overflow-hidden rounded-lg border">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow className="bg-muted/50">
|
||||||
|
<TableHead className="w-[140px]">변경일시</TableHead>
|
||||||
|
<TableHead className="w-[80px]">타입</TableHead>
|
||||||
|
<TableHead className="w-[120px]">출발 단계</TableHead>
|
||||||
|
<TableHead className="w-[120px]">도착 단계</TableHead>
|
||||||
|
<TableHead className="w-[100px]">데이터 ID</TableHead>
|
||||||
|
<TableHead className="w-[140px]">상태 변경</TableHead>
|
||||||
|
<TableHead className="w-[100px]">변경자</TableHead>
|
||||||
|
<TableHead>테이블</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{paginatedAuditLogs.map((log) => {
|
||||||
|
const fromStep = steps.find((s) => s.id === log.fromStepId);
|
||||||
|
const toStep = steps.find((s) => s.id === log.toStepId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow key={log.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{new Date(log.changedAt).toLocaleString("ko-KR", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{log.moveType === "status"
|
||||||
|
? "상태"
|
||||||
|
: log.moveType === "table"
|
||||||
|
? "테이블"
|
||||||
|
: "하이브리드"}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{fromStep?.stepName || `Step ${log.fromStepId}`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{toStep?.stepName || `Step ${log.toStepId}`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{log.sourceDataId || "-"}
|
||||||
|
{log.targetDataId && log.targetDataId !== log.sourceDataId && (
|
||||||
|
<>
|
||||||
|
<br />→ {log.targetDataId}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{log.statusFrom && log.statusTo ? (
|
||||||
|
<span className="font-mono">
|
||||||
|
{log.statusFrom}
|
||||||
|
<br />→ {log.statusTo}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">{log.changedBy}</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{log.sourceTable || "-"}
|
||||||
|
{log.targetTable && log.targetTable !== log.sourceTable && (
|
||||||
|
<>
|
||||||
|
<br />→ {log.targetTable}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 페이지네이션 */}
|
||||||
|
{totalAuditPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{(auditPage - 1) * auditPageSize + 1}-{Math.min(auditPage * auditPageSize, auditLogs.length)} /{" "}
|
||||||
|
{auditLogs.length}건
|
||||||
|
</div>
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
onClick={() => setAuditPage((p) => Math.max(1, p - 1))}
|
||||||
|
className={auditPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{Array.from({ length: totalAuditPages }, (_, i) => i + 1)
|
||||||
|
.filter((page) => {
|
||||||
|
// 현재 페이지 주변만 표시
|
||||||
|
return (
|
||||||
|
page === 1 ||
|
||||||
|
page === totalAuditPages ||
|
||||||
|
(page >= auditPage - 1 && page <= auditPage + 1)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((page, idx, arr) => (
|
||||||
|
<React.Fragment key={page}>
|
||||||
|
{idx > 0 && arr[idx - 1] !== page - 1 && (
|
||||||
|
<PaginationItem>
|
||||||
|
<span className="text-muted-foreground px-2">...</span>
|
||||||
|
</PaginationItem>
|
||||||
|
)}
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationLink
|
||||||
|
onClick={() => setAuditPage(page)}
|
||||||
|
isActive={auditPage === page}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
</PaginationItem>
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
onClick={() => setAuditPage((p) => Math.min(totalAuditPages, p + 1))}
|
||||||
|
className={
|
||||||
|
auditPage === totalAuditPages ? "pointer-events-none opacity-50" : "cursor-pointer"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{flowData.description && (
|
||||||
|
<p className="text-muted-foreground mt-1 text-center text-xs sm:text-sm">{flowData.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 플로우 스텝 목록 */}
|
{/* 플로우 스텝 목록 */}
|
||||||
@@ -185,53 +538,272 @@ export function FlowWidget({ component, onStepClick }: FlowWidgetProps) {
|
|||||||
{steps.map((step, index) => (
|
{steps.map((step, index) => (
|
||||||
<React.Fragment key={step.id}>
|
<React.Fragment key={step.id}>
|
||||||
{/* 스텝 카드 */}
|
{/* 스텝 카드 */}
|
||||||
<Button
|
<div
|
||||||
variant="outline"
|
className={`group bg-card relative w-full cursor-pointer rounded-lg border-2 p-4 shadow-sm transition-all duration-200 sm:w-auto sm:min-w-[180px] sm:rounded-xl sm:p-5 lg:min-w-[220px] lg:p-6 ${
|
||||||
className="hover:border-primary hover:bg-accent flex shrink-0 flex-col items-start gap-3 p-5"
|
selectedStepId === step.id
|
||||||
|
? "border-primary bg-primary/5 shadow-md"
|
||||||
|
: "border-border hover:border-primary/50 hover:shadow-md"
|
||||||
|
}`}
|
||||||
onClick={() => handleStepClick(step.id, step.stepName)}
|
onClick={() => handleStepClick(step.id, step.stepName)}
|
||||||
>
|
>
|
||||||
<div className="flex w-full items-center justify-between gap-2">
|
{/* 단계 번호 배지 */}
|
||||||
<Badge variant="outline" className="text-sm">
|
<div className="bg-primary/10 text-primary mb-2 inline-flex items-center rounded-full px-2 py-1 text-xs font-medium sm:mb-3 sm:px-3">
|
||||||
단계 {step.stepOrder}
|
Step {step.stepOrder}
|
||||||
</Badge>
|
|
||||||
{showStepCount && (
|
|
||||||
<Badge variant="secondary" className="text-sm font-semibold">
|
|
||||||
{stepCounts[step.id] || 0}건
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full text-left">
|
|
||||||
<div className="text-foreground text-base font-semibold">{step.stepName}</div>
|
{/* 스텝 이름 */}
|
||||||
{step.tableName && (
|
<h4 className="text-foreground mb-2 pr-8 text-base leading-tight font-semibold sm:text-lg">
|
||||||
<div className="text-muted-foreground mt-2 flex items-center gap-1 text-sm">
|
{step.stepName}
|
||||||
<span>📊</span>
|
</h4>
|
||||||
<span>{step.tableName}</span>
|
|
||||||
|
{/* 데이터 건수 */}
|
||||||
|
{showStepCount && (
|
||||||
|
<div className="text-muted-foreground mt-2 flex items-center gap-2 text-xs sm:mt-3 sm:text-sm">
|
||||||
|
<div className="bg-muted flex h-7 items-center rounded-md px-2 sm:h-8 sm:px-3">
|
||||||
|
<span className="text-foreground text-sm font-semibold sm:text-base">
|
||||||
|
{stepCounts[step.id] || 0}
|
||||||
|
</span>
|
||||||
|
<span className="ml-1">건</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</Button>
|
|
||||||
|
{/* 선택 인디케이터 */}
|
||||||
|
{selectedStepId === step.id && (
|
||||||
|
<div className="absolute top-3 right-3 sm:top-4 sm:right-4">
|
||||||
|
<ChevronUp className="text-primary h-4 w-4 sm:h-5 sm:w-5" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 화살표 (마지막 스텝 제외) */}
|
{/* 화살표 (마지막 스텝 제외) */}
|
||||||
{index < steps.length - 1 && (
|
{index < steps.length - 1 && (
|
||||||
<div className="text-muted-foreground flex shrink-0 items-center justify-center text-2xl font-bold">
|
<div className="text-muted-foreground/40 flex shrink-0 items-center justify-center py-2 sm:py-0">
|
||||||
{displayMode === "horizontal" ? "→" : "↓"}
|
{displayMode === "horizontal" ? (
|
||||||
|
<svg
|
||||||
|
className="h-5 w-5 rotate-90 sm:h-6 sm:w-6 sm:rotate-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="h-5 w-5 sm:h-6 sm:w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데이터 목록 모달 */}
|
{/* 선택된 스텝의 데이터 리스트 */}
|
||||||
{selectedStep && flowId && (
|
{selectedStepId !== null && (
|
||||||
<FlowDataListModal
|
<div className="bg-muted/30 mt-4 w-full rounded-lg p-4 sm:mt-6 sm:rounded-xl sm:p-5 lg:mt-8 lg:p-6">
|
||||||
open={modalOpen}
|
{/* 헤더 */}
|
||||||
onOpenChange={setModalOpen}
|
<div className="mb-4 flex flex-col items-start justify-between gap-3 sm:mb-6 sm:flex-row sm:items-center">
|
||||||
flowId={flowId}
|
<div className="flex-1">
|
||||||
stepId={selectedStep.id}
|
<h4 className="text-foreground text-base font-semibold sm:text-lg">
|
||||||
stepName={selectedStep.name}
|
{steps.find((s) => s.id === selectedStepId)?.stepName}
|
||||||
allowDataMove={allowDataMove}
|
</h4>
|
||||||
onDataMoved={handleDataMoved}
|
<p className="text-muted-foreground mt-1 text-xs sm:text-sm">총 {stepData.length}건의 데이터</p>
|
||||||
/>
|
</div>
|
||||||
|
{allowDataMove &&
|
||||||
|
selectedRows.size > 0 &&
|
||||||
|
(() => {
|
||||||
|
const nextSteps = getNextSteps(selectedStepId);
|
||||||
|
return nextSteps.length > 1 ? (
|
||||||
|
// 다음 단계가 여러 개인 경우: 선택 UI 표시
|
||||||
|
<div className="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:items-center">
|
||||||
|
<Select
|
||||||
|
value={selectedNextStepId?.toString() || ""}
|
||||||
|
onValueChange={(value) => setSelectedNextStepId(Number(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-full text-xs sm:h-10 sm:w-[180px] sm:text-sm">
|
||||||
|
<SelectValue placeholder="이동할 단계 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{nextSteps.map((step) => (
|
||||||
|
<SelectItem key={step.id} value={step.id.toString()}>
|
||||||
|
{step.stepName}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMoveToNext()}
|
||||||
|
disabled={movingData || !selectedNextStepId}
|
||||||
|
className="h-8 gap-1 px-3 text-xs sm:h-10 sm:gap-2 sm:px-4 sm:text-sm"
|
||||||
|
>
|
||||||
|
{movingData ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||||
|
<span>이동 중...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>이동 ({selectedRows.size})</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// 다음 단계가 하나인 경우: 바로 이동 버튼만 표시
|
||||||
|
<Button
|
||||||
|
onClick={() => handleMoveToNext()}
|
||||||
|
disabled={movingData}
|
||||||
|
className="h-8 w-full gap-1 px-3 text-xs sm:h-10 sm:w-auto sm:gap-2 sm:px-4 sm:text-sm"
|
||||||
|
>
|
||||||
|
{movingData ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin sm:h-4 sm:w-4" />
|
||||||
|
<span className="hidden sm:inline">이동 중...</span>
|
||||||
|
<span className="sm:hidden">이동중</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg className="h-3 w-3 sm:h-4 sm:w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="hidden sm:inline">
|
||||||
|
{nextSteps.length > 0 ? `${nextSteps[0].stepName}(으)로 이동` : "다음 단계로 이동"} (
|
||||||
|
{selectedRows.size})
|
||||||
|
</span>
|
||||||
|
<span className="sm:hidden">다음 ({selectedRows.size})</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
{stepDataLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8 sm:py-12">
|
||||||
|
<Loader2 className="text-primary h-6 w-6 animate-spin sm:h-8 sm:w-8" />
|
||||||
|
<span className="text-muted-foreground ml-2 text-xs sm:ml-3 sm:text-sm">데이터 로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : stepData.length === 0 ? (
|
||||||
|
<div className="bg-card flex flex-col items-center justify-center rounded-lg border-2 border-dashed py-8 sm:py-12">
|
||||||
|
<svg
|
||||||
|
className="text-muted-foreground/50 mb-2 h-10 w-10 sm:mb-3 sm:h-12 sm:w-12"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span className="text-muted-foreground text-xs sm:text-sm">데이터가 없습니다</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 모바일: 카드 뷰 (컨테이너 640px 미만) */}
|
||||||
|
<div className="space-y-3 @sm:hidden">
|
||||||
|
{stepData.map((row, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`bg-card rounded-lg border p-3 transition-colors ${
|
||||||
|
selectedRows.has(index) ? "border-primary bg-primary/5" : "border-border"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{/* 체크박스 헤더 */}
|
||||||
|
{allowDataMove && (
|
||||||
|
<div className="mb-2 flex items-center justify-between border-b pb-2">
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">선택</span>
|
||||||
|
<Checkbox checked={selectedRows.has(index)} onCheckedChange={() => toggleRowSelection(index)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 데이터 필드들 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<div key={col} className="flex justify-between gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs font-medium">{col}:</span>
|
||||||
|
<span className="text-foreground truncate text-xs">
|
||||||
|
{row[col] !== null && row[col] !== undefined ? (
|
||||||
|
String(row[col])
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 데스크톱: 테이블 뷰 (컨테이너 640px 이상) */}
|
||||||
|
<div className="bg-card hidden overflow-x-auto rounded-lg border shadow-sm @sm:block">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/50">
|
||||||
|
<TableRow className="hover:bg-transparent">
|
||||||
|
{allowDataMove && (
|
||||||
|
<TableHead className="w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.size === stepData.length && stepData.length > 0}
|
||||||
|
onCheckedChange={toggleAllRows}
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
|
)}
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<TableHead key={col} className="text-xs font-semibold whitespace-nowrap sm:text-sm">
|
||||||
|
{col}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{stepData.map((row, index) => (
|
||||||
|
<TableRow
|
||||||
|
key={index}
|
||||||
|
className={`transition-colors ${selectedRows.has(index) ? "bg-primary/5" : "hover:bg-muted/50"}`}
|
||||||
|
>
|
||||||
|
{allowDataMove && (
|
||||||
|
<TableCell className="w-12">
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRows.has(index)}
|
||||||
|
onCheckedChange={() => toggleRowSelection(index)}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
)}
|
||||||
|
{stepDataColumns.map((col) => (
|
||||||
|
<TableCell key={col} className="font-mono text-xs whitespace-nowrap sm:text-sm">
|
||||||
|
{row[col] !== null && row[col] !== undefined ? (
|
||||||
|
String(row[col])
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">-</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -404,7 +404,7 @@ export async function moveBatchData(
|
|||||||
data: MoveBatchDataRequest,
|
data: MoveBatchDataRequest,
|
||||||
): Promise<ApiResponse<{ success: boolean; results: any[] }>> {
|
): Promise<ApiResponse<{ success: boolean; results: any[] }>> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/flow/move/batch`, {
|
const response = await fetch(`${API_BASE}/flow/move-batch`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
|
|||||||
@@ -175,8 +175,9 @@ export interface MoveDataRequest {
|
|||||||
|
|
||||||
export interface MoveBatchDataRequest {
|
export interface MoveBatchDataRequest {
|
||||||
flowId: number;
|
flowId: number;
|
||||||
recordIds: string[];
|
fromStepId: number;
|
||||||
toStepId: number;
|
toStepId: number;
|
||||||
|
dataIds: string[];
|
||||||
note?: string;
|
note?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user