로그시스템 개선
This commit is contained in:
parent
f14d9ee66c
commit
5fdefffd26
|
|
@ -62,6 +62,7 @@ import yardLayoutRoutes from "./routes/yardLayoutRoutes"; // 야드 관리 3D
|
||||||
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
import flowRoutes from "./routes/flowRoutes"; // 플로우 관리
|
||||||
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
import flowExternalDbConnectionRoutes from "./routes/flowExternalDbConnectionRoutes"; // 플로우 전용 외부 DB 연결
|
||||||
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
import workHistoryRoutes from "./routes/workHistoryRoutes"; // 작업 이력 관리
|
||||||
|
import tableHistoryRoutes from "./routes/tableHistoryRoutes"; // 테이블 변경 이력 조회
|
||||||
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
import { BatchSchedulerService } from "./services/batchSchedulerService";
|
||||||
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
// import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석
|
||||||
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
// import batchRoutes from "./routes/batchRoutes"; // 임시 주석
|
||||||
|
|
@ -218,6 +219,7 @@ app.use("/api/yard-layouts", yardLayoutRoutes); // 야드 관리 3D
|
||||||
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
app.use("/api/flow-external-db", flowExternalDbConnectionRoutes); // 플로우 전용 외부 DB 연결
|
||||||
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
app.use("/api/flow", flowRoutes); // 플로우 관리 (마지막에 등록하여 다른 라우트와 충돌 방지)
|
||||||
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
app.use("/api/work-history", workHistoryRoutes); // 작업 이력 관리
|
||||||
|
app.use("/api/table-history", tableHistoryRoutes); // 테이블 변경 이력 조회
|
||||||
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
// app.use("/api/collections", collectionRoutes); // 임시 주석
|
||||||
// app.use("/api/batch", batchRoutes); // 임시 주석
|
// app.use("/api/batch", batchRoutes); // 임시 주석
|
||||||
// app.use('/api/users', userRoutes);
|
// app.use('/api/users', userRoutes);
|
||||||
|
|
@ -245,12 +247,19 @@ app.listen(PORT, HOST, async () => {
|
||||||
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
logger.info(`🔗 Health check: http://${HOST}:${PORT}/health`);
|
||||||
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
logger.info(`🌐 External access: http://39.117.244.52:${PORT}/health`);
|
||||||
|
|
||||||
// 대시보드 마이그레이션 실행
|
// 데이터베이스 마이그레이션 실행
|
||||||
try {
|
try {
|
||||||
const { runDashboardMigration } = await import("./database/runMigration");
|
const {
|
||||||
|
runDashboardMigration,
|
||||||
|
runTableHistoryActionMigration,
|
||||||
|
runDtgManagementLogMigration,
|
||||||
|
} = await import("./database/runMigration");
|
||||||
|
|
||||||
await runDashboardMigration();
|
await runDashboardMigration();
|
||||||
|
await runTableHistoryActionMigration();
|
||||||
|
await runDtgManagementLogMigration();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 대시보드 마이그레이션 실패:`, error);
|
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 배치 스케줄러 초기화
|
// 배치 스케줄러 초기화
|
||||||
|
|
@ -279,17 +288,18 @@ app.listen(PORT, HOST, async () => {
|
||||||
const { mailSentHistoryService } = await import(
|
const { mailSentHistoryService } = await import(
|
||||||
"./services/mailSentHistoryService"
|
"./services/mailSentHistoryService"
|
||||||
);
|
);
|
||||||
|
|
||||||
cron.schedule("0 2 * * *", async () => {
|
cron.schedule("0 2 * * *", async () => {
|
||||||
try {
|
try {
|
||||||
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
|
logger.info("🗑️ 30일 지난 삭제된 메일 자동 삭제 시작...");
|
||||||
const deletedCount = await mailSentHistoryService.cleanupOldDeletedMails();
|
const deletedCount =
|
||||||
|
await mailSentHistoryService.cleanupOldDeletedMails();
|
||||||
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
|
logger.info(`✅ 30일 지난 메일 ${deletedCount}개 자동 삭제 완료`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("❌ 메일 자동 삭제 실패:", error);
|
logger.error("❌ 메일 자동 삭제 실패:", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
|
logger.info(`⏰ 메일 자동 삭제 스케줄러가 시작되었습니다. (매일 새벽 2시)`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
logger.error(`❌ 메일 자동 삭제 스케줄러 시작 실패:`, error);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,406 @@
|
||||||
|
/**
|
||||||
|
* 테이블 이력 조회 컨트롤러
|
||||||
|
* 테이블 타입 관리의 {테이블명}_log 테이블과 연동
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Request, Response } from "express";
|
||||||
|
import { query } from "../database/db";
|
||||||
|
import { logger } from "../utils/logger";
|
||||||
|
|
||||||
|
export class TableHistoryController {
|
||||||
|
/**
|
||||||
|
* 특정 레코드의 변경 이력 조회
|
||||||
|
*/
|
||||||
|
static async getRecordHistory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, recordId } = req.params;
|
||||||
|
const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
logger.info(`📜 테이블 이력 조회 요청:`, {
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그 테이블명 생성
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
|
||||||
|
// 동적 WHERE 조건 생성
|
||||||
|
const whereConditions: string[] = [`original_id = $1`];
|
||||||
|
const queryParams: any[] = [recordId];
|
||||||
|
let paramIndex = 2;
|
||||||
|
|
||||||
|
// 작업 유형 필터
|
||||||
|
if (operationType) {
|
||||||
|
whereConditions.push(`operation_type = $${paramIndex}`);
|
||||||
|
queryParams.push(operationType);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경자 필터
|
||||||
|
if (changedBy) {
|
||||||
|
whereConditions.push(`changed_by ILIKE $${paramIndex}`);
|
||||||
|
queryParams.push(`%${changedBy}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 범위 필터
|
||||||
|
if (startDate) {
|
||||||
|
whereConditions.push(`changed_at >= $${paramIndex}`);
|
||||||
|
queryParams.push(startDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
whereConditions.push(`changed_at <= $${paramIndex}`);
|
||||||
|
queryParams.push(endDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIMIT과 OFFSET 파라미터 추가
|
||||||
|
queryParams.push(limit);
|
||||||
|
const limitParam = `$${paramIndex}`;
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
queryParams.push(offset);
|
||||||
|
const offsetParam = `$${paramIndex}`;
|
||||||
|
|
||||||
|
const whereClause = whereConditions.join(" AND ");
|
||||||
|
|
||||||
|
// 이력 조회 쿼리
|
||||||
|
const historyQuery = `
|
||||||
|
SELECT
|
||||||
|
log_id,
|
||||||
|
operation_type,
|
||||||
|
original_id,
|
||||||
|
changed_column,
|
||||||
|
old_value,
|
||||||
|
new_value,
|
||||||
|
changed_by,
|
||||||
|
changed_at,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
full_row_before,
|
||||||
|
full_row_after
|
||||||
|
FROM ${logTableName}
|
||||||
|
WHERE ${whereClause}
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 전체 카운트 쿼리
|
||||||
|
const countQuery = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM ${logTableName}
|
||||||
|
WHERE ${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [historyRecords, countResult] = await Promise.all([
|
||||||
|
query<any>(historyQuery, queryParams),
|
||||||
|
query<any>(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult[0]?.total || "0", 10);
|
||||||
|
|
||||||
|
logger.info(`✅ 이력 조회 완료: ${historyRecords.length}건 / 전체 ${total}건`);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
records: historyRecords,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
limit: parseInt(limit as string, 10),
|
||||||
|
offset: parseInt(offset as string, 10),
|
||||||
|
hasMore: parseInt(offset as string, 10) + historyRecords.length < total,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: "이력 조회 성공",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ 테이블 이력 조회 실패:`, error);
|
||||||
|
|
||||||
|
// 테이블이 존재하지 않는 경우
|
||||||
|
if (error.code === "42P01") {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "이력 테이블이 존재하지 않습니다. 테이블 타입 관리에서 이력 관리를 활성화해주세요.",
|
||||||
|
errorCode: "TABLE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "이력 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 테이블 이력 조회 (레코드 ID 없이)
|
||||||
|
*/
|
||||||
|
static async getAllTableHistory(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query;
|
||||||
|
|
||||||
|
logger.info(`📜 전체 테이블 이력 조회 요청:`, {
|
||||||
|
tableName,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 로그 테이블명 생성
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
|
||||||
|
// 동적 WHERE 조건 생성
|
||||||
|
const whereConditions: string[] = [];
|
||||||
|
const queryParams: any[] = [];
|
||||||
|
let paramIndex = 1;
|
||||||
|
|
||||||
|
// 작업 유형 필터
|
||||||
|
if (operationType) {
|
||||||
|
whereConditions.push(`operation_type = $${paramIndex}`);
|
||||||
|
queryParams.push(operationType);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경자 필터
|
||||||
|
if (changedBy) {
|
||||||
|
whereConditions.push(`changed_by ILIKE $${paramIndex}`);
|
||||||
|
queryParams.push(`%${changedBy}%`);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜 범위 필터
|
||||||
|
if (startDate) {
|
||||||
|
whereConditions.push(`changed_at >= $${paramIndex}`);
|
||||||
|
queryParams.push(startDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
if (endDate) {
|
||||||
|
whereConditions.push(`changed_at <= $${paramIndex}`);
|
||||||
|
queryParams.push(endDate);
|
||||||
|
paramIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIMIT과 OFFSET 파라미터 추가
|
||||||
|
queryParams.push(limit);
|
||||||
|
const limitParam = `$${paramIndex}`;
|
||||||
|
paramIndex++;
|
||||||
|
|
||||||
|
queryParams.push(offset);
|
||||||
|
const offsetParam = `$${paramIndex}`;
|
||||||
|
|
||||||
|
const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||||
|
|
||||||
|
// 이력 조회 쿼리
|
||||||
|
const historyQuery = `
|
||||||
|
SELECT
|
||||||
|
log_id,
|
||||||
|
operation_type,
|
||||||
|
original_id,
|
||||||
|
changed_column,
|
||||||
|
old_value,
|
||||||
|
new_value,
|
||||||
|
changed_by,
|
||||||
|
changed_at,
|
||||||
|
ip_address,
|
||||||
|
user_agent,
|
||||||
|
full_row_before,
|
||||||
|
full_row_after
|
||||||
|
FROM ${logTableName}
|
||||||
|
${whereClause}
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT ${limitParam} OFFSET ${offsetParam}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 전체 카운트 쿼리
|
||||||
|
const countQuery = `
|
||||||
|
SELECT COUNT(*) as total
|
||||||
|
FROM ${logTableName}
|
||||||
|
${whereClause}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const [historyRecords, countResult] = await Promise.all([
|
||||||
|
query<any>(historyQuery, queryParams),
|
||||||
|
query<any>(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult[0]?.total || "0", 10);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
records: historyRecords,
|
||||||
|
pagination: {
|
||||||
|
total,
|
||||||
|
limit: Number(limit),
|
||||||
|
offset: Number(offset),
|
||||||
|
hasMore: Number(offset) + Number(limit) < total,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
message: "전체 테이블 이력 조회 성공",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ 전체 테이블 이력 조회 실패:`, error);
|
||||||
|
|
||||||
|
if (error.code === "42P01") {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "이력 테이블이 존재하지 않습니다.",
|
||||||
|
errorCode: "TABLE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "이력 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 전체 이력 요약 조회
|
||||||
|
*/
|
||||||
|
static async getTableHistorySummary(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
|
||||||
|
const summaryQuery = `
|
||||||
|
SELECT
|
||||||
|
operation_type,
|
||||||
|
COUNT(*) as count,
|
||||||
|
COUNT(DISTINCT original_id) as affected_records,
|
||||||
|
COUNT(DISTINCT changed_by) as unique_users,
|
||||||
|
MIN(changed_at) as first_change,
|
||||||
|
MAX(changed_at) as last_change
|
||||||
|
FROM ${logTableName}
|
||||||
|
GROUP BY operation_type
|
||||||
|
`;
|
||||||
|
|
||||||
|
const summary = await query<any>(summaryQuery);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: summary,
|
||||||
|
message: "이력 요약 조회 성공",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ 테이블 이력 요약 조회 실패:`, error);
|
||||||
|
|
||||||
|
if (error.code === "42P01") {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: "이력 테이블이 존재하지 않습니다.",
|
||||||
|
errorCode: "TABLE_NOT_FOUND",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "이력 요약 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레코드의 변경 타임라인 조회 (그룹화)
|
||||||
|
*/
|
||||||
|
static async getRecordTimeline(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName, recordId } = req.params;
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
|
||||||
|
// 변경 이벤트별로 그룹화 (동일 시간대 변경을 하나의 이벤트로)
|
||||||
|
const timelineQuery = `
|
||||||
|
WITH grouped_changes AS (
|
||||||
|
SELECT
|
||||||
|
changed_at,
|
||||||
|
changed_by,
|
||||||
|
operation_type,
|
||||||
|
ip_address,
|
||||||
|
json_agg(
|
||||||
|
json_build_object(
|
||||||
|
'column', changed_column,
|
||||||
|
'oldValue', old_value,
|
||||||
|
'newValue', new_value
|
||||||
|
) ORDER BY changed_column
|
||||||
|
) as changes,
|
||||||
|
full_row_before,
|
||||||
|
full_row_after
|
||||||
|
FROM ${logTableName}
|
||||||
|
WHERE original_id = $1
|
||||||
|
GROUP BY changed_at, changed_by, operation_type, ip_address, full_row_before, full_row_after
|
||||||
|
ORDER BY changed_at DESC
|
||||||
|
LIMIT 100
|
||||||
|
)
|
||||||
|
SELECT * FROM grouped_changes
|
||||||
|
`;
|
||||||
|
|
||||||
|
const timeline = await query<any>(timelineQuery, [recordId]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: timeline,
|
||||||
|
message: "타임라인 조회 성공",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ 레코드 타임라인 조회 실패:`, error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "타임라인 조회 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이력 테이블 존재 여부 확인
|
||||||
|
*/
|
||||||
|
static async checkHistoryTableExists(req: Request, res: Response): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { tableName } = req.params;
|
||||||
|
const logTableName = `${tableName}_log`;
|
||||||
|
|
||||||
|
const checkQuery = `
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = $1
|
||||||
|
) as exists
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await query<any>(checkQuery, [logTableName]);
|
||||||
|
const exists = result[0]?.exists || false;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
tableName,
|
||||||
|
logTableName,
|
||||||
|
exists,
|
||||||
|
historyEnabled: exists,
|
||||||
|
},
|
||||||
|
message: exists ? "이력 테이블이 존재합니다." : "이력 테이블이 존재하지 않습니다.",
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
logger.error(`❌ 이력 테이블 존재 여부 확인 실패:`, error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: "이력 테이블 확인 중 오류가 발생했습니다.",
|
||||||
|
error: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { PostgreSQLService } from './PostgreSQLService';
|
import { PostgreSQLService } from "./PostgreSQLService";
|
||||||
|
import fs from "fs";
|
||||||
|
import path from "path";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터베이스 마이그레이션 실행
|
* 데이터베이스 마이그레이션 실행
|
||||||
|
|
@ -6,21 +8,21 @@ import { PostgreSQLService } from './PostgreSQLService';
|
||||||
*/
|
*/
|
||||||
export async function runDashboardMigration() {
|
export async function runDashboardMigration() {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 대시보드 마이그레이션 시작...');
|
console.log("🔄 대시보드 마이그레이션 시작...");
|
||||||
|
|
||||||
// custom_title 컬럼 추가
|
// custom_title 컬럼 추가
|
||||||
await PostgreSQLService.query(`
|
await PostgreSQLService.query(`
|
||||||
ALTER TABLE dashboard_elements
|
ALTER TABLE dashboard_elements
|
||||||
ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255)
|
ADD COLUMN IF NOT EXISTS custom_title VARCHAR(255)
|
||||||
`);
|
`);
|
||||||
console.log('✅ custom_title 컬럼 추가 완료');
|
console.log("✅ custom_title 컬럼 추가 완료");
|
||||||
|
|
||||||
// show_header 컬럼 추가
|
// show_header 컬럼 추가
|
||||||
await PostgreSQLService.query(`
|
await PostgreSQLService.query(`
|
||||||
ALTER TABLE dashboard_elements
|
ALTER TABLE dashboard_elements
|
||||||
ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true
|
ADD COLUMN IF NOT EXISTS show_header BOOLEAN DEFAULT true
|
||||||
`);
|
`);
|
||||||
console.log('✅ show_header 컬럼 추가 완료');
|
console.log("✅ show_header 컬럼 추가 완료");
|
||||||
|
|
||||||
// 기존 데이터 업데이트
|
// 기존 데이터 업데이트
|
||||||
await PostgreSQLService.query(`
|
await PostgreSQLService.query(`
|
||||||
|
|
@ -28,15 +30,83 @@ export async function runDashboardMigration() {
|
||||||
SET show_header = true
|
SET show_header = true
|
||||||
WHERE show_header IS NULL
|
WHERE show_header IS NULL
|
||||||
`);
|
`);
|
||||||
console.log('✅ 기존 데이터 업데이트 완료');
|
console.log("✅ 기존 데이터 업데이트 완료");
|
||||||
|
|
||||||
console.log('✅ 대시보드 마이그레이션 완료!');
|
console.log("✅ 대시보드 마이그레이션 완료!");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ 대시보드 마이그레이션 실패:', error);
|
console.error("❌ 대시보드 마이그레이션 실패:", error);
|
||||||
// 이미 컬럼이 있는 경우는 무시
|
// 이미 컬럼이 있는 경우는 무시
|
||||||
if (error instanceof Error && error.message.includes('already exists')) {
|
if (error instanceof Error && error.message.includes("already exists")) {
|
||||||
console.log('ℹ️ 컬럼이 이미 존재합니다.');
|
console.log("ℹ️ 컬럼이 이미 존재합니다.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 이력 보기 버튼 액션 마이그레이션
|
||||||
|
*/
|
||||||
|
export async function runTableHistoryActionMigration() {
|
||||||
|
try {
|
||||||
|
console.log("🔄 테이블 이력 보기 액션 마이그레이션 시작...");
|
||||||
|
|
||||||
|
// SQL 파일 읽기
|
||||||
|
const sqlFilePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"../../db/migrations/024_add_table_history_view_action.sql"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(sqlFilePath)) {
|
||||||
|
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||||
|
|
||||||
|
// SQL 실행
|
||||||
|
await PostgreSQLService.query(sqlContent);
|
||||||
|
|
||||||
|
console.log("✅ 테이블 이력 보기 액션 마이그레이션 완료!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 테이블 이력 보기 액션 마이그레이션 실패:", error);
|
||||||
|
// 이미 액션이 있는 경우는 무시
|
||||||
|
if (
|
||||||
|
error instanceof Error &&
|
||||||
|
error.message.includes("duplicate key value")
|
||||||
|
) {
|
||||||
|
console.log("ℹ️ 액션이 이미 존재합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTG Management 테이블 이력 시스템 마이그레이션
|
||||||
|
*/
|
||||||
|
export async function runDtgManagementLogMigration() {
|
||||||
|
try {
|
||||||
|
console.log("🔄 DTG Management 이력 테이블 마이그레이션 시작...");
|
||||||
|
|
||||||
|
// SQL 파일 읽기
|
||||||
|
const sqlFilePath = path.join(
|
||||||
|
__dirname,
|
||||||
|
"../../db/migrations/025_create_dtg_management_log.sql"
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fs.existsSync(sqlFilePath)) {
|
||||||
|
console.log("⚠️ 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||||
|
|
||||||
|
// SQL 실행
|
||||||
|
await PostgreSQLService.query(sqlContent);
|
||||||
|
|
||||||
|
console.log("✅ DTG Management 이력 테이블 마이그레이션 완료!");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ DTG Management 이력 테이블 마이그레이션 실패:", error);
|
||||||
|
// 이미 테이블이 있는 경우는 무시
|
||||||
|
if (error instanceof Error && error.message.includes("already exists")) {
|
||||||
|
console.log("ℹ️ 이력 테이블이 이미 존재합니다.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
/**
|
||||||
|
* 테이블 이력 조회 라우트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from "express";
|
||||||
|
import { authenticateToken } from "../middleware/authMiddleware";
|
||||||
|
import { TableHistoryController } from "../controllers/tableHistoryController";
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// 모든 라우트에 인증 미들웨어 적용
|
||||||
|
router.use(authenticateToken);
|
||||||
|
|
||||||
|
// 이력 테이블 존재 여부 확인
|
||||||
|
router.get("/:tableName/check", TableHistoryController.checkHistoryTableExists);
|
||||||
|
|
||||||
|
// 테이블 전체 이력 요약
|
||||||
|
router.get(
|
||||||
|
"/:tableName/summary",
|
||||||
|
TableHistoryController.getTableHistorySummary
|
||||||
|
);
|
||||||
|
|
||||||
|
// 전체 테이블 이력 조회 (레코드 ID 없이)
|
||||||
|
router.get("/:tableName/all", TableHistoryController.getAllTableHistory);
|
||||||
|
|
||||||
|
// 특정 레코드의 타임라인
|
||||||
|
router.get(
|
||||||
|
"/:tableName/:recordId/timeline",
|
||||||
|
TableHistoryController.getRecordTimeline
|
||||||
|
);
|
||||||
|
|
||||||
|
// 특정 레코드의 변경 이력 (상세)
|
||||||
|
router.get("/:tableName/:recordId", TableHistoryController.getRecordHistory);
|
||||||
|
|
||||||
|
export default router;
|
||||||
|
|
@ -60,7 +60,7 @@ export default function TableManagementPage() {
|
||||||
|
|
||||||
// 페이지네이션 상태
|
// 페이지네이션 상태
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(50);
|
const [pageSize, setPageSize] = useState(9999); // 전체 컬럼 표시
|
||||||
const [totalColumns, setTotalColumns] = useState(0);
|
const [totalColumns, setTotalColumns] = useState(0);
|
||||||
|
|
||||||
// 테이블 라벨 상태
|
// 테이블 라벨 상태
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,396 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 이력 뷰어 모달
|
||||||
|
* 테이블 타입 관리의 {테이블명}_log 테이블 데이터를 표시
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { Clock, User, FileEdit, Trash2, Plus, AlertCircle, Loader2, Search, X } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getRecordHistory,
|
||||||
|
getRecordTimeline,
|
||||||
|
TableHistoryRecord,
|
||||||
|
TableHistoryTimelineEvent,
|
||||||
|
} from "@/lib/api/tableHistory";
|
||||||
|
import { format } from "date-fns";
|
||||||
|
import { ko } from "date-fns/locale";
|
||||||
|
|
||||||
|
interface TableHistoryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
tableName: string;
|
||||||
|
recordId?: string | number | null; // 선택사항: null이면 전체 테이블 이력
|
||||||
|
recordLabel?: string;
|
||||||
|
displayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TableHistoryModal({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
recordLabel,
|
||||||
|
displayColumn,
|
||||||
|
}: TableHistoryModalProps) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [timeline, setTimeline] = useState<TableHistoryTimelineEvent[]>([]);
|
||||||
|
const [detailRecords, setDetailRecords] = useState<TableHistoryRecord[]>([]);
|
||||||
|
const [allRecords, setAllRecords] = useState<TableHistoryRecord[]>([]); // 검색용 원본 데이터
|
||||||
|
// recordId가 없으면 (전체 테이블 모드) detail 탭부터 시작
|
||||||
|
const [activeTab, setActiveTab] = useState<"timeline" | "detail">(recordId ? "timeline" : "detail");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [searchTerm, setSearchTerm] = useState<string>(""); // 검색어
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
loadHistory();
|
||||||
|
// recordId 변경 시 탭도 초기화
|
||||||
|
setActiveTab(recordId ? "timeline" : "detail");
|
||||||
|
}
|
||||||
|
}, [open, tableName, recordId]);
|
||||||
|
|
||||||
|
const loadHistory = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (recordId) {
|
||||||
|
// 단일 레코드 이력 로드
|
||||||
|
const [timelineRes, detailRes] = await Promise.all([
|
||||||
|
getRecordTimeline(tableName, recordId),
|
||||||
|
getRecordHistory(tableName, recordId, { limit: 100 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (timelineRes.success && timelineRes.data) {
|
||||||
|
setTimeline(timelineRes.data);
|
||||||
|
} else {
|
||||||
|
setError(timelineRes.error || "타임라인 로드 실패");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailRes.success && detailRes.data) {
|
||||||
|
setDetailRecords(detailRes.data.records);
|
||||||
|
setAllRecords(detailRes.data.records);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 전체 테이블 이력 로드 (recordId 없이)
|
||||||
|
const detailRes = await getRecordHistory(tableName, null, { limit: 200 });
|
||||||
|
|
||||||
|
if (detailRes.success && detailRes.data) {
|
||||||
|
const records = detailRes.data.records;
|
||||||
|
setAllRecords(records); // 원본 데이터 저장
|
||||||
|
setDetailRecords(records); // 초기 표시 데이터
|
||||||
|
// 타임라인은 전체 테이블에서는 사용하지 않음
|
||||||
|
setTimeline([]);
|
||||||
|
} else {
|
||||||
|
setError(detailRes.error || "이력 로드 실패");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || "이력 로드 중 오류 발생");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOperationIcon = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "INSERT":
|
||||||
|
return <Plus className="h-4 w-4 text-green-600" />;
|
||||||
|
case "UPDATE":
|
||||||
|
return <FileEdit className="h-4 w-4 text-blue-600" />;
|
||||||
|
case "DELETE":
|
||||||
|
return <Trash2 className="h-4 w-4 text-red-600" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="h-4 w-4 text-gray-600" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOperationBadge = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "INSERT":
|
||||||
|
return <Badge className="bg-green-100 text-xs text-green-800">추가</Badge>;
|
||||||
|
case "UPDATE":
|
||||||
|
return <Badge className="bg-blue-100 text-xs text-blue-800">수정</Badge>;
|
||||||
|
case "DELETE":
|
||||||
|
return <Badge className="bg-red-100 text-xs text-red-800">삭제</Badge>;
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{type}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
try {
|
||||||
|
return format(new Date(dateString), "yyyy년 MM월 dd일 HH:mm:ss", { locale: ko });
|
||||||
|
} catch {
|
||||||
|
return dateString;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 검색 필터링 (전체 테이블 모드에서만)
|
||||||
|
const handleSearch = (term: string) => {
|
||||||
|
setSearchTerm(term);
|
||||||
|
|
||||||
|
if (!term.trim()) {
|
||||||
|
// 검색어가 없으면 전체 표시
|
||||||
|
setDetailRecords(allRecords);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lowerTerm = term.toLowerCase();
|
||||||
|
const filtered = allRecords.filter((record) => {
|
||||||
|
// 레코드 ID로 검색
|
||||||
|
if (record.original_id?.toString().toLowerCase().includes(lowerTerm)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// displayColumn 값으로 검색 (full_row_after에서 추출)
|
||||||
|
if (displayColumn && record.full_row_after) {
|
||||||
|
const displayValue = record.full_row_after[displayColumn];
|
||||||
|
if (displayValue && displayValue.toString().toLowerCase().includes(lowerTerm)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 변경자로 검색
|
||||||
|
if (record.changed_by?.toLowerCase().includes(lowerTerm)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 컬럼명으로 검색
|
||||||
|
if (record.changed_column?.toLowerCase().includes(lowerTerm)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
setDetailRecords(filtered);
|
||||||
|
};
|
||||||
|
|
||||||
|
// displayColumn 값 추출 헬퍼 함수
|
||||||
|
const getDisplayValue = (record: TableHistoryRecord): string | null => {
|
||||||
|
if (!displayColumn) return null;
|
||||||
|
|
||||||
|
// full_row_after에서 먼저 시도
|
||||||
|
if (record.full_row_after && record.full_row_after[displayColumn]) {
|
||||||
|
return record.full_row_after[displayColumn];
|
||||||
|
}
|
||||||
|
|
||||||
|
// full_row_before에서 시도 (DELETE의 경우)
|
||||||
|
if (record.full_row_before && record.full_row_before[displayColumn]) {
|
||||||
|
return record.full_row_before[displayColumn];
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-h-[90vh] max-w-[95vw] sm:max-w-[900px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||||
|
<Clock className="h-5 w-5" />
|
||||||
|
변경 이력{" "}
|
||||||
|
{!recordId && (
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
전체
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="text-xs sm:text-sm">
|
||||||
|
{recordId
|
||||||
|
? `${recordLabel || `레코드 ID: ${recordId}`} - ${tableName} 테이블`
|
||||||
|
: `${tableName} 테이블 전체 이력`}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="text-primary h-8 w-8 animate-spin" />
|
||||||
|
<span className="text-muted-foreground ml-2 text-sm">로딩 중...</span>
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12">
|
||||||
|
<AlertCircle className="text-destructive mb-4 h-12 w-12" />
|
||||||
|
<p className="text-destructive text-sm">{error}</p>
|
||||||
|
<Button variant="outline" onClick={loadHistory} className="mt-4 h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
다시 시도
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as any)} className="w-full">
|
||||||
|
{recordId && (
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="timeline" className="flex-1 text-xs sm:text-sm">
|
||||||
|
타임라인 ({timeline.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="detail" className="flex-1 text-xs sm:text-sm">
|
||||||
|
상세 내역 ({detailRecords.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
{!recordId && (
|
||||||
|
<>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="detail" className="flex-1 text-xs sm:text-sm">
|
||||||
|
전체 변경 이력 ({detailRecords.length})
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* 검색창 (전체 테이블 모드) */}
|
||||||
|
<div className="relative mt-4">
|
||||||
|
<Search className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2" />
|
||||||
|
<Input
|
||||||
|
placeholder={`레코드 ID${displayColumn ? `, ${displayColumn}` : ""}, 변경자, 컬럼명으로 검색...`}
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => handleSearch(e.target.value)}
|
||||||
|
className="h-9 pr-10 pl-10 text-xs sm:h-10 sm:text-sm"
|
||||||
|
/>
|
||||||
|
{searchTerm && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSearch("")}
|
||||||
|
className="text-muted-foreground hover:text-foreground absolute top-1/2 right-3 -translate-y-1/2"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{searchTerm && (
|
||||||
|
<p className="text-muted-foreground mt-2 text-xs">
|
||||||
|
검색 결과: {detailRecords.length}개 / 전체 {allRecords.length}개
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 타임라인 뷰 */}
|
||||||
|
<TabsContent value="timeline">
|
||||||
|
<ScrollArea className="h-[500px] w-full rounded-md border p-4">
|
||||||
|
{timeline.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<Clock className="text-muted-foreground mb-2 h-12 w-12" />
|
||||||
|
<p className="text-muted-foreground text-sm">변경 이력이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{timeline.map((event, index) => (
|
||||||
|
<div key={index} className="relative border-l-2 border-gray-200 pb-6 pl-8 last:border-l-0">
|
||||||
|
<div className="absolute top-0 -left-3 rounded-full border-2 border-gray-200 bg-white p-1">
|
||||||
|
{getOperationIcon(event.operation_type)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
{getOperationBadge(event.operation_type)}
|
||||||
|
<span className="text-muted-foreground text-xs">{formatDate(event.changed_at)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<User className="text-muted-foreground h-3 w-3" />
|
||||||
|
<span className="font-medium">{event.changed_by}</span>
|
||||||
|
{event.ip_address && (
|
||||||
|
<span className="text-muted-foreground text-xs">({event.ip_address})</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{event.changes && event.changes.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
<p className="text-muted-foreground text-xs font-medium">변경된 항목:</p>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{event.changes.map((change, idx) => (
|
||||||
|
<div key={idx} className="rounded bg-gray-50 p-2 text-xs">
|
||||||
|
<span className="font-mono font-medium">{change.column}</span>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="text-red-600 line-through">{change.oldValue || "(없음)"}</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span className="font-medium text-green-600">{change.newValue || "(없음)"}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 상세 내역 뷰 */}
|
||||||
|
<TabsContent value="detail">
|
||||||
|
<ScrollArea className="h-[500px] w-full rounded-md border">
|
||||||
|
{detailRecords.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<FileEdit className="text-muted-foreground mb-2 h-12 w-12" />
|
||||||
|
<p className="text-muted-foreground text-sm">변경 내역이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<thead className="sticky top-0 border-b bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
{!recordId && <th className="p-2 text-left font-medium">레코드</th>}
|
||||||
|
<th className="p-2 text-left font-medium">작업</th>
|
||||||
|
<th className="p-2 text-left font-medium">컬럼</th>
|
||||||
|
<th className="p-2 text-left font-medium">이전 값</th>
|
||||||
|
<th className="p-2 text-left font-medium">새 값</th>
|
||||||
|
<th className="p-2 text-left font-medium">변경자</th>
|
||||||
|
<th className="p-2 text-left font-medium">일시</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{detailRecords.map((record) => {
|
||||||
|
const displayValue = getDisplayValue(record);
|
||||||
|
return (
|
||||||
|
<tr key={record.log_id} className="border-b hover:bg-gray-50">
|
||||||
|
{!recordId && (
|
||||||
|
<td className="p-2">
|
||||||
|
{displayValue ? (
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="font-medium text-gray-900">{displayValue}</span>
|
||||||
|
<span className="text-xs text-gray-500">(ID: {record.original_id})</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="font-mono font-medium text-blue-600">{record.original_id}</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td className="p-2">{getOperationBadge(record.operation_type)}</td>
|
||||||
|
<td className="p-2 font-mono">{record.changed_column}</td>
|
||||||
|
<td className="max-w-[200px] truncate p-2 text-red-600">{record.old_value || "-"}</td>
|
||||||
|
<td className="max-w-[200px] truncate p-2 text-green-600">{record.new_value || "-"}</td>
|
||||||
|
<td className="p-2">{record.changed_by}</td>
|
||||||
|
<td className="text-muted-foreground p-2">{formatDate(record.changed_at)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-4">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)} className="h-8 text-xs sm:h-10 sm:text-sm">
|
||||||
|
닫기
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
@ -19,6 +20,7 @@ interface ButtonConfigPanelProps {
|
||||||
component: ComponentData;
|
component: ComponentData;
|
||||||
onUpdateProperty: (path: string, value: any) => void;
|
onUpdateProperty: (path: string, value: any) => void;
|
||||||
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
|
allComponents?: ComponentData[]; // 🆕 플로우 위젯 감지용
|
||||||
|
currentTableName?: string; // 현재 화면의 테이블명 (자동 감지용)
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ScreenOption {
|
interface ScreenOption {
|
||||||
|
|
@ -27,20 +29,23 @@ interface ScreenOption {
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
component,
|
component,
|
||||||
onUpdateProperty,
|
onUpdateProperty,
|
||||||
allComponents = [], // 🆕 기본값 빈 배열
|
allComponents = [], // 🆕 기본값 빈 배열
|
||||||
|
currentTableName, // 현재 화면의 테이블명
|
||||||
}) => {
|
}) => {
|
||||||
console.log("🎨 ButtonConfigPanel 렌더링:", {
|
|
||||||
componentId: component.id,
|
|
||||||
"component.componentConfig?.action?.type": component.componentConfig?.action?.type,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
// 🔧 component에서 직접 읽기 (useMemo 제거)
|
||||||
const config = component.componentConfig || {};
|
const config = component.componentConfig || {};
|
||||||
const currentAction = component.componentConfig?.action || {};
|
const currentAction = component.componentConfig?.action || {};
|
||||||
|
|
||||||
|
console.log("🎨 ButtonConfigPanel 렌더링:", {
|
||||||
|
componentId: component.id,
|
||||||
|
"component.componentConfig?.action?.type": component.componentConfig?.action?.type,
|
||||||
|
currentTableName: currentTableName,
|
||||||
|
"config.action?.historyTableName": config.action?.historyTableName,
|
||||||
|
});
|
||||||
|
|
||||||
// 로컬 상태 관리 (실시간 입력 반영)
|
// 로컬 상태 관리 (실시간 입력 반영)
|
||||||
const [localInputs, setLocalInputs] = useState({
|
const [localInputs, setLocalInputs] = useState({
|
||||||
text: config.text !== undefined ? config.text : "버튼",
|
text: config.text !== undefined ? config.text : "버튼",
|
||||||
|
|
@ -57,6 +62,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
const [modalSearchTerm, setModalSearchTerm] = useState("");
|
||||||
const [navSearchTerm, setNavSearchTerm] = useState("");
|
const [navSearchTerm, setNavSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 테이블 컬럼 목록 상태
|
||||||
|
const [tableColumns, setTableColumns] = useState<string[]>([]);
|
||||||
|
const [columnsLoading, setColumnsLoading] = useState(false);
|
||||||
|
const [displayColumnOpen, setDisplayColumnOpen] = useState(false);
|
||||||
|
const [displayColumnSearch, setDisplayColumnSearch] = useState("");
|
||||||
|
|
||||||
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
|
// 컴포넌트 prop 변경 시 로컬 상태 동기화 (Input만)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const latestConfig = component.componentConfig || {};
|
const latestConfig = component.componentConfig || {};
|
||||||
|
|
@ -103,6 +114,115 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
fetchScreens();
|
fetchScreens();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 테이블 컬럼 목록 가져오기 (테이블 이력 보기 액션일 때)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchTableColumns = async () => {
|
||||||
|
// 테이블 이력 보기 액션이 아니면 스킵
|
||||||
|
if (config.action?.type !== "view_table_history") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 수동 입력된 테이블명 우선
|
||||||
|
// 2. 없으면 현재 화면의 테이블명 사용
|
||||||
|
const tableName = config.action?.historyTableName || currentTableName;
|
||||||
|
|
||||||
|
// 테이블명이 없으면 스킵
|
||||||
|
if (!tableName) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setColumnsLoading(true);
|
||||||
|
const response = await apiClient.get(`/table-management/tables/${tableName}/columns`, {
|
||||||
|
params: {
|
||||||
|
page: 1,
|
||||||
|
size: 9999, // 전체 컬럼 가져오기
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("📋 [ButtonConfigPanel] API 응답:", {
|
||||||
|
tableName,
|
||||||
|
success: response.data.success,
|
||||||
|
hasData: !!response.data.data,
|
||||||
|
hasColumns: !!response.data.data?.columns,
|
||||||
|
totalColumns: response.data.data?.columns?.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 응답 구조: { success, data: { columns: [...], total, page, totalPages } }
|
||||||
|
const columnData = response.data.data?.columns;
|
||||||
|
|
||||||
|
if (!columnData || !Array.isArray(columnData)) {
|
||||||
|
console.error("❌ 컬럼 데이터가 배열이 아닙니다:", columnData);
|
||||||
|
setTableColumns([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.data.success) {
|
||||||
|
// ID 컬럼과 날짜 관련 컬럼 제외
|
||||||
|
const filteredColumns = columnData
|
||||||
|
.filter((col: any) => {
|
||||||
|
const colName = col.columnName.toLowerCase();
|
||||||
|
const dataType = col.dataType?.toLowerCase() || "";
|
||||||
|
|
||||||
|
console.log(`🔍 [필터링 체크] ${col.columnName}:`, {
|
||||||
|
colName,
|
||||||
|
dataType,
|
||||||
|
isId: colName === "id" || colName.endsWith("_id"),
|
||||||
|
hasDateInType: dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp"),
|
||||||
|
hasDateInName:
|
||||||
|
colName.includes("date") ||
|
||||||
|
colName.includes("time") ||
|
||||||
|
colName.endsWith("_at") ||
|
||||||
|
colName.startsWith("created") ||
|
||||||
|
colName.startsWith("updated"),
|
||||||
|
});
|
||||||
|
|
||||||
|
// ID 컬럼 제외 (id, _id로 끝나는 컬럼)
|
||||||
|
if (colName === "id" || colName.endsWith("_id")) {
|
||||||
|
console.log(` ❌ 제외: ID 컬럼`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜/시간 타입 제외 (데이터 타입 기준)
|
||||||
|
if (dataType.includes("date") || dataType.includes("time") || dataType.includes("timestamp")) {
|
||||||
|
console.log(` ❌ 제외: 날짜/시간 타입`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 날짜/시간 관련 컬럼명 제외 (컬럼명에 date, time, at 포함)
|
||||||
|
if (
|
||||||
|
colName.includes("date") ||
|
||||||
|
colName.includes("time") ||
|
||||||
|
colName.endsWith("_at") ||
|
||||||
|
colName.startsWith("created") ||
|
||||||
|
colName.startsWith("updated")
|
||||||
|
) {
|
||||||
|
console.log(` ❌ 제외: 날짜 관련 컬럼명`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(` ✅ 통과`);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((col: any) => col.columnName);
|
||||||
|
|
||||||
|
console.log("✅ [ButtonConfigPanel] 필터링된 컬럼:", {
|
||||||
|
totalFiltered: filteredColumns.length,
|
||||||
|
columns: filteredColumns,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTableColumns(filteredColumns);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 테이블 컬럼 로딩 실패:", error);
|
||||||
|
} finally {
|
||||||
|
setColumnsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchTableColumns();
|
||||||
|
}, [config.action?.type, config.action?.historyTableName, currentTableName]);
|
||||||
|
|
||||||
// 검색 필터링 함수
|
// 검색 필터링 함수
|
||||||
const filterScreens = (searchTerm: string) => {
|
const filterScreens = (searchTerm: string) => {
|
||||||
if (!searchTerm.trim()) return screens;
|
if (!searchTerm.trim()) return screens;
|
||||||
|
|
@ -185,20 +305,20 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
|
key={`action-${component.id}-${component.componentConfig?.action?.type || "save"}`}
|
||||||
value={component.componentConfig?.action?.type || "save"}
|
value={component.componentConfig?.action?.type || "save"}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
console.log("🎯 버튼 액션 드롭다운 변경:", {
|
console.log("🎯 버튼 액션 드롭다운 변경:", {
|
||||||
oldValue: component.componentConfig?.action?.type,
|
oldValue: component.componentConfig?.action?.type,
|
||||||
newValue: value,
|
newValue: value,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 🔥 action.type 업데이트
|
// 🔥 action.type 업데이트
|
||||||
onUpdateProperty("componentConfig.action.type", value);
|
onUpdateProperty("componentConfig.action.type", value);
|
||||||
|
|
||||||
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
|
// 🔥 색상 업데이트는 충분히 지연 (React 리렌더링 완료 후)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const newColor = value === "delete" ? "#ef4444" : "#212121";
|
const newColor = value === "delete" ? "#ef4444" : "#212121";
|
||||||
console.log("🎨 라벨 색상 업데이트:", { value, newColor });
|
console.log("🎨 라벨 색상 업데이트:", { value, newColor });
|
||||||
onUpdateProperty("style.labelColor", newColor);
|
onUpdateProperty("style.labelColor", newColor);
|
||||||
}, 100); // 0 → 100ms로 증가
|
}, 100); // 0 → 100ms로 증가
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
|
|
@ -211,6 +331,7 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
<SelectItem value="navigate">페이지 이동</SelectItem>
|
<SelectItem value="navigate">페이지 이동</SelectItem>
|
||||||
<SelectItem value="modal">모달 열기</SelectItem>
|
<SelectItem value="modal">모달 열기</SelectItem>
|
||||||
<SelectItem value="control">제어 흐름</SelectItem>
|
<SelectItem value="control">제어 흐름</SelectItem>
|
||||||
|
<SelectItem value="view_table_history">테이블 이력 보기</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -476,6 +597,162 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 테이블 이력 보기 액션 설정 */}
|
||||||
|
{(component.componentConfig?.action?.type || "save") === "view_table_history" && (
|
||||||
|
<div className="mt-4 space-y-4 rounded-lg border bg-blue-50 p-4">
|
||||||
|
<h4 className="text-sm font-medium text-blue-900">📜 테이블 이력 보기 설정</h4>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="history-table-name">테이블명 (선택사항)</Label>
|
||||||
|
<Input
|
||||||
|
id="history-table-name"
|
||||||
|
placeholder="자동 감지 (비워두면 현재 화면의 테이블 사용)"
|
||||||
|
value={config.action?.historyTableName || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateProperty("componentConfig.action.historyTableName", e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">비워두면 현재 화면의 테이블을 자동으로 사용합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="history-record-id-field">레코드 ID 필드명</Label>
|
||||||
|
<Input
|
||||||
|
id="history-record-id-field"
|
||||||
|
placeholder="id (기본값)"
|
||||||
|
value={config.action?.historyRecordIdField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateProperty("componentConfig.action.historyRecordIdField", e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">기본키 컬럼명입니다. 대부분 "id"입니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="history-record-id-source">레코드 ID 가져올 위치</Label>
|
||||||
|
<Select
|
||||||
|
value={config.action?.historyRecordIdSource || "selected_row"}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
onUpdateProperty("componentConfig.action.historyRecordIdSource", value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="레코드 ID 소스 선택" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="selected_row">선택된 행 (권장)</SelectItem>
|
||||||
|
<SelectItem value="form_field">폼 필드</SelectItem>
|
||||||
|
<SelectItem value="context">컨텍스트 (원본 데이터)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">테이블 리스트에서 선택된 행의 ID를 사용합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="history-record-label-field">레코드 라벨 필드 (선택사항)</Label>
|
||||||
|
<Input
|
||||||
|
id="history-record-label-field"
|
||||||
|
placeholder="예: name, title, device_code 등"
|
||||||
|
value={config.action?.historyRecordLabelField || ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
onUpdateProperty("componentConfig.action.historyRecordLabelField", e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-gray-600">
|
||||||
|
이력 모달에서 "ID 123의 이력" 대신 "홍길동의 이력" 처럼 표시할 때 사용
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||||
|
<Label className="text-blue-900">
|
||||||
|
전체 이력 표시 컬럼 (필수) <span className="text-red-600">*</span>
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{!config.action?.historyTableName && !currentTableName ? (
|
||||||
|
<div className="mt-2 rounded-md border border-yellow-300 bg-yellow-50 p-3">
|
||||||
|
<p className="text-xs text-yellow-800">
|
||||||
|
⚠️ 먼저 <strong>테이블명</strong>을 입력하거나, 현재 화면에 테이블을 연결해주세요.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{!config.action?.historyTableName && currentTableName && (
|
||||||
|
<div className="mt-2 rounded-md border border-green-300 bg-green-50 p-2">
|
||||||
|
<p className="text-xs text-green-800">
|
||||||
|
✓ 현재 화면의 테이블 <strong>{currentTableName}</strong>을(를) 자동으로 사용합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Popover open={displayColumnOpen} onOpenChange={setDisplayColumnOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
role="combobox"
|
||||||
|
aria-expanded={displayColumnOpen}
|
||||||
|
className="mt-2 h-10 w-full justify-between text-sm"
|
||||||
|
disabled={columnsLoading || tableColumns.length === 0}
|
||||||
|
>
|
||||||
|
{columnsLoading
|
||||||
|
? "로딩 중..."
|
||||||
|
: config.action?.historyDisplayColumn
|
||||||
|
? config.action.historyDisplayColumn
|
||||||
|
: tableColumns.length === 0
|
||||||
|
? "사용 가능한 컬럼이 없습니다"
|
||||||
|
: "컬럼을 선택하세요"}
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="p-0" style={{ width: "var(--radix-popover-trigger-width)" }} align="start">
|
||||||
|
<Command>
|
||||||
|
<CommandInput placeholder="컬럼 검색..." className="text-sm" />
|
||||||
|
<CommandList>
|
||||||
|
<CommandEmpty className="text-sm">컬럼을 찾을 수 없습니다.</CommandEmpty>
|
||||||
|
<CommandGroup>
|
||||||
|
{tableColumns.map((column) => (
|
||||||
|
<CommandItem
|
||||||
|
key={column}
|
||||||
|
value={column}
|
||||||
|
onSelect={(currentValue) => {
|
||||||
|
onUpdateProperty("componentConfig.action.historyDisplayColumn", currentValue);
|
||||||
|
setDisplayColumnOpen(false);
|
||||||
|
}}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Check
|
||||||
|
className={cn(
|
||||||
|
"mr-2 h-4 w-4",
|
||||||
|
config.action?.historyDisplayColumn === column ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{column}
|
||||||
|
</CommandItem>
|
||||||
|
))}
|
||||||
|
</CommandGroup>
|
||||||
|
</CommandList>
|
||||||
|
</Command>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<p className="mt-2 text-xs text-gray-700">
|
||||||
|
<strong>전체 테이블 이력</strong>에서 레코드를 구분하기 위한 컬럼입니다.
|
||||||
|
<br />
|
||||||
|
예: <code className="rounded bg-white px-1">device_code</code>를 설정하면 "레코드 ID: 5"
|
||||||
|
대신 "DTG-001 (ID: 5)"로 표시됩니다.
|
||||||
|
<br />이 컬럼으로 검색도 가능합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{tableColumns.length === 0 && !columnsLoading && (
|
||||||
|
<p className="mt-2 text-xs text-red-600">
|
||||||
|
⚠️ ID 및 날짜 타입 컬럼을 제외한 사용 가능한 컬럼이 없습니다.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 페이지 이동 액션 설정 */}
|
{/* 페이지 이동 액션 설정 */}
|
||||||
{(component.componentConfig?.action?.type || "save") === "navigate" && (
|
{(component.componentConfig?.action?.type || "save") === "navigate" && (
|
||||||
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
<div className="mt-4 space-y-4 rounded-lg border bg-gray-50 p-4">
|
||||||
|
|
@ -580,13 +857,12 @@ export const ButtonConfigPanel: React.FC<ButtonConfigPanelProps> = ({
|
||||||
|
|
||||||
{/* 🆕 플로우 단계별 표시 제어 섹션 */}
|
{/* 🆕 플로우 단계별 표시 제어 섹션 */}
|
||||||
<div className="mt-8 border-t border-gray-200 pt-6">
|
<div className="mt-8 border-t border-gray-200 pt-6">
|
||||||
<FlowVisibilityConfigPanel
|
<FlowVisibilityConfigPanel
|
||||||
component={component}
|
component={component}
|
||||||
allComponents={allComponents}
|
allComponents={allComponents}
|
||||||
onUpdateProperty={onUpdateProperty}
|
onUpdateProperty={onUpdateProperty}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,13 +50,13 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
// 데이터베이스에서 입력 가능한 웹타입들을 동적으로 가져오기
|
||||||
const { webTypes } = useWebTypes({ active: "Y" });
|
const { webTypes } = useWebTypes({ active: "Y" });
|
||||||
|
|
||||||
// console.log(`🔍 DetailSettingsPanel props:`, {
|
console.log(`🔍 DetailSettingsPanel props:`, {
|
||||||
// selectedComponent: selectedComponent?.id,
|
selectedComponent: selectedComponent?.id,
|
||||||
// componentType: selectedComponent?.type,
|
componentType: selectedComponent?.type,
|
||||||
// currentTableName,
|
currentTableName,
|
||||||
// currentTable: currentTable?.tableName,
|
currentTable: currentTable?.tableName,
|
||||||
// selectedComponentTableName: selectedComponent?.tableName,
|
selectedComponentTableName: selectedComponent?.tableName,
|
||||||
// });
|
});
|
||||||
// console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개");
|
// console.log(`🔍 DetailSettingsPanel webTypes 로드됨:`, webTypes?.length, "개");
|
||||||
// console.log(`🔍 webTypes:`, webTypes);
|
// console.log(`🔍 webTypes:`, webTypes);
|
||||||
// console.log(`🔍 DetailSettingsPanel selectedComponent:`, selectedComponent);
|
// console.log(`🔍 DetailSettingsPanel selectedComponent:`, selectedComponent);
|
||||||
|
|
@ -823,7 +823,14 @@ export const DetailSettingsPanel: React.FC<DetailSettingsPanelProps> = ({
|
||||||
case "button-primary":
|
case "button-primary":
|
||||||
case "button-secondary":
|
case "button-secondary":
|
||||||
// 🔧 component.id만 key로 사용 (unmount 방지)
|
// 🔧 component.id만 key로 사용 (unmount 방지)
|
||||||
return <NewButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
return (
|
||||||
|
<NewButtonConfigPanel
|
||||||
|
key={selectedComponent.id}
|
||||||
|
component={selectedComponent}
|
||||||
|
onUpdateProperty={handleUpdateProperty}
|
||||||
|
currentTableName={currentTableName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "card":
|
case "card":
|
||||||
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
|
||||||
|
|
@ -108,16 +108,13 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
{currentResolution && onResolutionChange && (
|
{currentResolution && onResolutionChange && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Monitor className="h-3 w-3 text-primary" />
|
<Monitor className="text-primary h-3 w-3" />
|
||||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||||
</div>
|
</div>
|
||||||
<ResolutionPanel
|
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||||
currentResolution={currentResolution}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 안내 메시지 */}
|
{/* 안내 메시지 */}
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
|
|
@ -156,7 +153,15 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
case "button-primary":
|
case "button-primary":
|
||||||
case "button-secondary":
|
case "button-secondary":
|
||||||
// 🔧 component.id만 key로 사용 (unmount 방지)
|
// 🔧 component.id만 key로 사용 (unmount 방지)
|
||||||
return <ButtonConfigPanel key={selectedComponent.id} component={selectedComponent} onUpdateProperty={handleUpdateProperty} allComponents={allComponents} />;
|
return (
|
||||||
|
<ButtonConfigPanel
|
||||||
|
key={selectedComponent.id}
|
||||||
|
component={selectedComponent}
|
||||||
|
onUpdateProperty={handleUpdateProperty}
|
||||||
|
allComponents={allComponents}
|
||||||
|
currentTableName={currentTableName}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
case "card":
|
case "card":
|
||||||
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
return <CardConfigPanel component={selectedComponent} onUpdateProperty={handleUpdateProperty} />;
|
||||||
|
|
@ -198,12 +203,12 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
{/* 컴포넌트 정보 - 간소화 */}
|
{/* 컴포넌트 정보 - 간소화 */}
|
||||||
<div className="flex items-center justify-between rounded bg-muted px-2 py-1">
|
<div className="bg-muted flex items-center justify-between rounded px-2 py-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Info className="h-2.5 w-2.5 text-muted-foreground" />
|
<Info className="text-muted-foreground h-2.5 w-2.5" />
|
||||||
<span className="text-[10px] font-medium text-foreground">{selectedComponent.type}</span>
|
<span className="text-foreground text-[10px] font-medium">{selectedComponent.type}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[9px] text-muted-foreground">{selectedComponent.id.slice(0, 8)}</span>
|
<span className="text-muted-foreground text-[9px]">{selectedComponent.id.slice(0, 8)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 라벨 + 최소 높이 (같은 행) */}
|
{/* 라벨 + 최소 높이 (같은 행) */}
|
||||||
|
|
@ -609,7 +614,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
{/* 헤더 - 간소화 */}
|
{/* 헤더 - 간소화 */}
|
||||||
<div className="border-b border-gray-200 px-3 py-2">
|
<div className="border-b border-gray-200 px-3 py-2">
|
||||||
{selectedComponent.type === "widget" && (
|
{selectedComponent.type === "widget" && (
|
||||||
<div className="text-[10px] text-gray-600 truncate">
|
<div className="truncate text-[10px] text-gray-600">
|
||||||
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
{(selectedComponent as WidgetComponent).label || selectedComponent.id}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -623,13 +628,10 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<>
|
<>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Monitor className="h-3 w-3 text-primary" />
|
<Monitor className="text-primary h-3 w-3" />
|
||||||
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
<h4 className="text-xs font-semibold">해상도 설정</h4>
|
||||||
</div>
|
</div>
|
||||||
<ResolutionPanel
|
<ResolutionPanel currentResolution={currentResolution} onResolutionChange={onResolutionChange} />
|
||||||
currentResolution={currentResolution}
|
|
||||||
onResolutionChange={onResolutionChange}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
</>
|
</>
|
||||||
|
|
@ -637,7 +639,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
|
|
||||||
{/* 기본 설정 */}
|
{/* 기본 설정 */}
|
||||||
{renderBasicTab()}
|
{renderBasicTab()}
|
||||||
|
|
||||||
{/* 상세 설정 */}
|
{/* 상세 설정 */}
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
{renderDetailTab()}
|
{renderDetailTab()}
|
||||||
|
|
@ -648,7 +650,7 @@ export const UnifiedPropertiesPanel: React.FC<UnifiedPropertiesPanelProps> = ({
|
||||||
<Separator className="my-2" />
|
<Separator className="my-2" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Palette className="h-3 w-3 text-primary" />
|
<Palette className="text-primary h-3 w-3" />
|
||||||
<h4 className="text-xs font-semibold">컴포넌트 스타일</h4>
|
<h4 className="text-xs font-semibold">컴포넌트 스타일</h4>
|
||||||
</div>
|
</div>
|
||||||
<StyleEditor
|
<StyleEditor
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,177 @@
|
||||||
|
/**
|
||||||
|
* 테이블 이력 조회 API 클라이언트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { apiClient } from "./client";
|
||||||
|
|
||||||
|
export interface TableHistoryRecord {
|
||||||
|
log_id: number;
|
||||||
|
operation_type: "INSERT" | "UPDATE" | "DELETE";
|
||||||
|
original_id: string;
|
||||||
|
changed_column: string;
|
||||||
|
old_value: string | null;
|
||||||
|
new_value: string | null;
|
||||||
|
changed_by: string;
|
||||||
|
changed_at: string;
|
||||||
|
ip_address: string | null;
|
||||||
|
user_agent: string | null;
|
||||||
|
full_row_before: Record<string, any> | null;
|
||||||
|
full_row_after: Record<string, any> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableHistoryResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
records: TableHistoryRecord[];
|
||||||
|
pagination: {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
errorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableHistoryTimelineEvent {
|
||||||
|
changed_at: string;
|
||||||
|
changed_by: string;
|
||||||
|
operation_type: "INSERT" | "UPDATE" | "DELETE";
|
||||||
|
ip_address: string | null;
|
||||||
|
changes: Array<{
|
||||||
|
column: string;
|
||||||
|
oldValue: string | null;
|
||||||
|
newValue: string | null;
|
||||||
|
}>;
|
||||||
|
full_row_before: Record<string, any> | null;
|
||||||
|
full_row_after: Record<string, any> | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableHistoryTimelineResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: TableHistoryTimelineEvent[];
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableHistorySummary {
|
||||||
|
operation_type: string;
|
||||||
|
count: number;
|
||||||
|
affected_records: number;
|
||||||
|
unique_users: number;
|
||||||
|
first_change: string;
|
||||||
|
last_change: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableHistorySummaryResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: TableHistorySummary[];
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableHistoryCheckResponse {
|
||||||
|
success: boolean;
|
||||||
|
data?: {
|
||||||
|
tableName: string;
|
||||||
|
logTableName: string;
|
||||||
|
exists: boolean;
|
||||||
|
historyEnabled: boolean;
|
||||||
|
};
|
||||||
|
message?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 레코드 변경 이력 조회 (recordId가 null이면 전체 테이블 이력)
|
||||||
|
*/
|
||||||
|
export async function getRecordHistory(
|
||||||
|
tableName: string,
|
||||||
|
recordId: string | number | null,
|
||||||
|
params?: {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
operationType?: "INSERT" | "UPDATE" | "DELETE";
|
||||||
|
changedBy?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
|
},
|
||||||
|
): Promise<TableHistoryResponse> {
|
||||||
|
try {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
if (params?.limit) queryParams.append("limit", params.limit.toString());
|
||||||
|
if (params?.offset) queryParams.append("offset", params.offset.toString());
|
||||||
|
if (params?.operationType) queryParams.append("operationType", params.operationType);
|
||||||
|
if (params?.changedBy) queryParams.append("changedBy", params.changedBy);
|
||||||
|
if (params?.startDate) queryParams.append("startDate", params.startDate);
|
||||||
|
if (params?.endDate) queryParams.append("endDate", params.endDate);
|
||||||
|
|
||||||
|
// recordId가 null이면 전체 테이블 이력 조회
|
||||||
|
const url = recordId
|
||||||
|
? `/table-history/${tableName}/${recordId}?${queryParams.toString()}`
|
||||||
|
: `/table-history/${tableName}/all?${queryParams.toString()}`;
|
||||||
|
|
||||||
|
const response = await apiClient.get(url);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 레코드 이력 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message || "이력 조회 중 오류가 발생했습니다.",
|
||||||
|
errorCode: error.response?.data?.errorCode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 특정 레코드의 타임라인 조회 (그룹화된 이벤트)
|
||||||
|
*/
|
||||||
|
export async function getRecordTimeline(
|
||||||
|
tableName: string,
|
||||||
|
recordId: string | number,
|
||||||
|
): Promise<TableHistoryTimelineResponse> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-history/${tableName}/${recordId}/timeline`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 타임라인 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message || "타임라인 조회 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 전체 이력 요약
|
||||||
|
*/
|
||||||
|
export async function getTableHistorySummary(tableName: string): Promise<TableHistorySummaryResponse> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-history/${tableName}/summary`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 이력 요약 조회 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message || "이력 요약 조회 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이력 테이블 존재 여부 확인
|
||||||
|
*/
|
||||||
|
export async function checkHistoryTableExists(tableName: string): Promise<TableHistoryCheckResponse> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get(`/table-history/${tableName}/check`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("❌ 이력 테이블 확인 실패:", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: error.response?.data?.message || error.message || "이력 테이블 확인 중 오류가 발생했습니다.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -38,7 +38,7 @@ export interface ButtonPrimaryComponentProps extends ComponentRendererProps {
|
||||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||||
selectedRows?: any[];
|
selectedRows?: any[];
|
||||||
selectedRowsData?: any[];
|
selectedRowsData?: any[];
|
||||||
|
|
||||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
|
|
@ -187,12 +187,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
|
|
||||||
const buttonDarkColor = getDarkColor(buttonColor);
|
const buttonDarkColor = getDarkColor(buttonColor);
|
||||||
|
|
||||||
console.log("🎨 동적 색상 연동:", {
|
|
||||||
labelColor: component.style?.labelColor,
|
|
||||||
buttonColor,
|
|
||||||
buttonDarkColor,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
|
// 액션 설정 처리 - DB에서 문자열로 저장된 액션을 객체로 변환
|
||||||
const processedConfig = { ...componentConfig };
|
const processedConfig = { ...componentConfig };
|
||||||
if (componentConfig.action && typeof componentConfig.action === "string") {
|
if (componentConfig.action && typeof componentConfig.action === "string") {
|
||||||
|
|
@ -213,7 +207,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// 스타일 계산
|
// 스타일 계산
|
||||||
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
// height: 100%로 부모(RealtimePreviewDynamic의 내부 div)의 높이를 따라감
|
||||||
const componentStyle: React.CSSProperties = {
|
const componentStyle: React.CSSProperties = {
|
||||||
|
|
@ -223,7 +216,6 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
...style,
|
...style,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
// 디자인 모드 스타일 (border 속성 분리하여 충돌 방지)
|
||||||
if (isDesignMode) {
|
if (isDesignMode) {
|
||||||
componentStyle.borderWidth = "1px";
|
componentStyle.borderWidth = "1px";
|
||||||
|
|
@ -279,7 +271,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 기본 에러 메시지 결정
|
// 기본 에러 메시지 결정
|
||||||
const defaultErrorMessage =
|
const defaultErrorMessage =
|
||||||
actionConfig.type === "save"
|
actionConfig.type === "save"
|
||||||
? "저장 중 오류가 발생했습니다."
|
? "저장 중 오류가 발생했습니다."
|
||||||
: actionConfig.type === "delete"
|
: actionConfig.type === "delete"
|
||||||
|
|
@ -287,16 +279,15 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
: actionConfig.type === "submit"
|
: actionConfig.type === "submit"
|
||||||
? "제출 중 오류가 발생했습니다."
|
? "제출 중 오류가 발생했습니다."
|
||||||
: "처리 중 오류가 발생했습니다.";
|
: "처리 중 오류가 발생했습니다.";
|
||||||
|
|
||||||
// 커스텀 메시지 사용 조건:
|
// 커스텀 메시지 사용 조건:
|
||||||
// 1. 커스텀 메시지가 있고
|
// 1. 커스텀 메시지가 있고
|
||||||
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
|
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
|
||||||
const useCustomMessage =
|
const useCustomMessage =
|
||||||
actionConfig.errorMessage &&
|
actionConfig.errorMessage && (actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장"));
|
||||||
(actionConfig.type === "save" || !actionConfig.errorMessage.includes("저장"));
|
|
||||||
|
|
||||||
const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage;
|
const errorMessage = useCustomMessage ? actionConfig.errorMessage : defaultErrorMessage;
|
||||||
|
|
||||||
toast.error(errorMessage);
|
toast.error(errorMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -305,7 +296,7 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
// edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요)
|
// edit, modal, navigate 액션은 조용히 처리 (UI 전환만 하므로 토스트 불필요)
|
||||||
if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") {
|
if (actionConfig.type !== "edit" && actionConfig.type !== "modal" && actionConfig.type !== "navigate") {
|
||||||
// 기본 성공 메시지 결정
|
// 기본 성공 메시지 결정
|
||||||
const defaultSuccessMessage =
|
const defaultSuccessMessage =
|
||||||
actionConfig.type === "save"
|
actionConfig.type === "save"
|
||||||
? "저장되었습니다."
|
? "저장되었습니다."
|
||||||
: actionConfig.type === "delete"
|
: actionConfig.type === "delete"
|
||||||
|
|
@ -313,14 +304,14 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
: actionConfig.type === "submit"
|
: actionConfig.type === "submit"
|
||||||
? "제출되었습니다."
|
? "제출되었습니다."
|
||||||
: "완료되었습니다.";
|
: "완료되었습니다.";
|
||||||
|
|
||||||
// 커스텀 메시지 사용 조건:
|
// 커스텀 메시지 사용 조건:
|
||||||
// 1. 커스텀 메시지가 있고
|
// 1. 커스텀 메시지가 있고
|
||||||
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
|
// 2. (액션 타입이 save이거나 OR 메시지에 "저장"이 포함되지 않은 경우)
|
||||||
const useCustomMessage =
|
const useCustomMessage =
|
||||||
actionConfig.successMessage &&
|
actionConfig.successMessage &&
|
||||||
(actionConfig.type === "save" || !actionConfig.successMessage.includes("저장"));
|
(actionConfig.type === "save" || !actionConfig.successMessage.includes("저장"));
|
||||||
|
|
||||||
const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage;
|
const successMessage = useCustomMessage ? actionConfig.successMessage : defaultSuccessMessage;
|
||||||
|
|
||||||
toast.success(successMessage);
|
toast.success(successMessage);
|
||||||
|
|
@ -539,7 +530,8 @@ export const ButtonPrimaryComponent: React.FC<ButtonPrimaryComponentProps> = ({
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
// 🔧 크기에 따른 패딩 조정
|
// 🔧 크기에 따른 패딩 조정
|
||||||
padding: componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
padding:
|
||||||
|
componentConfig.size === "sm" ? "0 0.75rem" : componentConfig.size === "lg" ? "0 1.25rem" : "0 1rem",
|
||||||
margin: "0",
|
margin: "0",
|
||||||
lineHeight: "1.25",
|
lineHeight: "1.25",
|
||||||
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
|
boxShadow: componentConfig.disabled ? "0 1px 2px 0 rgba(0, 0, 0, 0.05)" : `0 1px 3px 0 ${buttonColor}40`,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { screenApi } from "@/lib/api/screen";
|
import { screenApi } from "@/lib/api/screen";
|
||||||
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
import { DynamicFormApi } from "@/lib/api/dynamicForm";
|
||||||
|
|
@ -15,7 +16,8 @@ export type ButtonActionType =
|
||||||
| "edit" // 편집
|
| "edit" // 편집
|
||||||
| "navigate" // 페이지 이동
|
| "navigate" // 페이지 이동
|
||||||
| "modal" // 모달 열기
|
| "modal" // 모달 열기
|
||||||
| "control"; // 제어 흐름
|
| "control" // 제어 흐름
|
||||||
|
| "view_table_history"; // 테이블 이력 보기
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 버튼 액션 설정
|
* 버튼 액션 설정
|
||||||
|
|
@ -46,6 +48,13 @@ export interface ButtonActionConfig {
|
||||||
enableDataflowControl?: boolean;
|
enableDataflowControl?: boolean;
|
||||||
dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용)
|
dataflowConfig?: any; // ButtonDataflowConfig 타입 (순환 참조 방지를 위해 any 사용)
|
||||||
dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍
|
dataflowTiming?: "before" | "after" | "replace"; // 제어 실행 타이밍
|
||||||
|
|
||||||
|
// 테이블 이력 보기 관련
|
||||||
|
historyTableName?: string; // 이력을 조회할 테이블명 (자동 감지 또는 수동 지정)
|
||||||
|
historyRecordIdField?: string; // PK 필드명 (기본: "id")
|
||||||
|
historyRecordIdSource?: "selected_row" | "form_field" | "context"; // 레코드 ID 가져올 소스
|
||||||
|
historyRecordLabelField?: string; // 레코드 라벨로 표시할 필드 (선택사항)
|
||||||
|
historyDisplayColumn?: string; // 전체 이력에서 레코드 구분용 컬럼 (예: device_code, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -64,7 +73,7 @@ export interface ButtonActionContext {
|
||||||
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
// 테이블 선택된 행 정보 (다중 선택 액션용)
|
||||||
selectedRows?: any[];
|
selectedRows?: any[];
|
||||||
selectedRowsData?: any[];
|
selectedRowsData?: any[];
|
||||||
|
|
||||||
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
// 플로우 선택된 데이터 정보 (플로우 위젯 선택 액션용)
|
||||||
flowSelectedData?: any[];
|
flowSelectedData?: any[];
|
||||||
flowSelectedStepId?: number | null;
|
flowSelectedStepId?: number | null;
|
||||||
|
|
@ -105,6 +114,9 @@ export class ButtonActionExecutor {
|
||||||
case "control":
|
case "control":
|
||||||
return this.handleControl(config, context);
|
return this.handleControl(config, context);
|
||||||
|
|
||||||
|
case "view_table_history":
|
||||||
|
return this.handleViewTableHistory(config, context);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
console.warn(`지원되지 않는 액션 타입: ${config.type}`);
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -932,7 +944,7 @@ export class ButtonActionExecutor {
|
||||||
console.log("🔄 플로우 새로고침 호출");
|
console.log("🔄 플로우 새로고침 호출");
|
||||||
context.onFlowRefresh();
|
context.onFlowRefresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 테이블 새로고침 (일반 테이블용)
|
// 테이블 새로고침 (일반 테이블용)
|
||||||
if (context.onRefresh) {
|
if (context.onRefresh) {
|
||||||
console.log("🔄 테이블 새로고침 호출");
|
console.log("🔄 테이블 새로고침 호출");
|
||||||
|
|
@ -1473,6 +1485,113 @@ export class ButtonActionExecutor {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 테이블 이력 보기 액션 처리
|
||||||
|
*/
|
||||||
|
private static async handleViewTableHistory(
|
||||||
|
config: ButtonActionConfig,
|
||||||
|
context: ButtonActionContext,
|
||||||
|
): Promise<boolean> {
|
||||||
|
console.log("📜 테이블 이력 보기 액션 실행:", { config, context });
|
||||||
|
|
||||||
|
// 테이블명 결정 (설정 > 컨텍스트 > 폼 데이터)
|
||||||
|
const tableName = config.historyTableName || context.tableName;
|
||||||
|
if (!tableName) {
|
||||||
|
toast.error("테이블명이 지정되지 않았습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 레코드 ID 가져오기 (선택사항 - 없으면 전체 테이블 이력)
|
||||||
|
const recordIdField = config.historyRecordIdField || "id";
|
||||||
|
const recordIdSource = config.historyRecordIdSource || "selected_row";
|
||||||
|
|
||||||
|
let recordId: any = null;
|
||||||
|
let recordLabel: string | undefined;
|
||||||
|
|
||||||
|
switch (recordIdSource) {
|
||||||
|
case "selected_row":
|
||||||
|
// 선택된 행에서 가져오기 (선택사항)
|
||||||
|
if (context.selectedRowsData && context.selectedRowsData.length > 0) {
|
||||||
|
const selectedRow = context.selectedRowsData[0];
|
||||||
|
recordId = selectedRow[recordIdField];
|
||||||
|
|
||||||
|
// 라벨 필드가 지정되어 있으면 사용
|
||||||
|
if (config.historyRecordLabelField) {
|
||||||
|
recordLabel = selectedRow[config.historyRecordLabelField];
|
||||||
|
}
|
||||||
|
} else if (context.flowSelectedData && context.flowSelectedData.length > 0) {
|
||||||
|
// 플로우 선택 데이터 폴백
|
||||||
|
const selectedRow = context.flowSelectedData[0];
|
||||||
|
recordId = selectedRow[recordIdField];
|
||||||
|
|
||||||
|
if (config.historyRecordLabelField) {
|
||||||
|
recordLabel = selectedRow[config.historyRecordLabelField];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "form_field":
|
||||||
|
// 폼 필드에서 가져오기
|
||||||
|
recordId = context.formData?.[recordIdField];
|
||||||
|
if (config.historyRecordLabelField) {
|
||||||
|
recordLabel = context.formData?.[config.historyRecordLabelField];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "context":
|
||||||
|
// 원본 데이터에서 가져오기
|
||||||
|
recordId = context.originalData?.[recordIdField];
|
||||||
|
if (config.historyRecordLabelField) {
|
||||||
|
recordLabel = context.originalData?.[config.historyRecordLabelField];
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordId가 없어도 괜찮음 - 전체 테이블 이력 보기
|
||||||
|
console.log("📋 이력 조회 대상:", {
|
||||||
|
tableName,
|
||||||
|
recordId: recordId || "전체",
|
||||||
|
recordLabel,
|
||||||
|
mode: recordId ? "단일 레코드" : "전체 테이블",
|
||||||
|
});
|
||||||
|
|
||||||
|
// 이력 모달 열기 (동적 import)
|
||||||
|
try {
|
||||||
|
const { TableHistoryModal } = await import("@/components/common/TableHistoryModal");
|
||||||
|
const { createRoot } = await import("react-dom/client");
|
||||||
|
|
||||||
|
// 모달 컨테이너 생성
|
||||||
|
const modalContainer = document.createElement("div");
|
||||||
|
document.body.appendChild(modalContainer);
|
||||||
|
|
||||||
|
const root = createRoot(modalContainer);
|
||||||
|
|
||||||
|
const closeModal = () => {
|
||||||
|
root.unmount();
|
||||||
|
document.body.removeChild(modalContainer);
|
||||||
|
};
|
||||||
|
|
||||||
|
root.render(
|
||||||
|
React.createElement(TableHistoryModal, {
|
||||||
|
open: true,
|
||||||
|
onOpenChange: (open: boolean) => {
|
||||||
|
if (!open) closeModal();
|
||||||
|
},
|
||||||
|
tableName,
|
||||||
|
recordId,
|
||||||
|
recordLabel,
|
||||||
|
displayColumn: config.historyDisplayColumn,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("❌ 이력 모달 열기 실패:", error);
|
||||||
|
toast.error("이력 조회 중 오류가 발생했습니다.");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 폼 데이터 유효성 검사
|
* 폼 데이터 유효성 검사
|
||||||
*/
|
*/
|
||||||
|
|
@ -1539,4 +1658,9 @@ export const DEFAULT_BUTTON_ACTIONS: Record<ButtonActionType, Partial<ButtonActi
|
||||||
control: {
|
control: {
|
||||||
type: "control",
|
type: "control",
|
||||||
},
|
},
|
||||||
|
view_table_history: {
|
||||||
|
type: "view_table_history",
|
||||||
|
historyRecordIdField: "id",
|
||||||
|
historyRecordIdSource: "selected_row",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue